diff --git a/.annotation_safe_list.yml b/.annotation_safe_list.yml index c57eeeb250..e91fe39cd6 100644 --- a/.annotation_safe_list.yml +++ b/.annotation_safe_list.yml @@ -142,8 +142,6 @@ workflow.AssessmentWorkflowStep: # Via edx-celeryutils celery_utils.ChordData: ".. no_pii:": "No PII" -celery_utils.FailedTask: - ".. no_pii:": "No PII" # Via completion XBlock completion.BlockCompletion: diff --git a/.coveragerc b/.coveragerc index 26fe680f84..7d8fe1c269 100644 --- a/.coveragerc +++ b/.coveragerc @@ -22,10 +22,10 @@ omit = lms/envs/* lms/djangoapps/*/migrations/* lms/djangoapps/*/features/* - common/djangoapps/terrain/* common/djangoapps/*/migrations/* openedx/core/djangoapps/*/migrations/* openedx/core/djangoapps/debug/* + openedx/envs/* openedx/features/*/migrations/* concurrency=multiprocessing diff --git a/.coveragerc-local b/.coveragerc-local index 807e68b111..05e79fe343 100644 --- a/.coveragerc-local +++ b/.coveragerc-local @@ -21,10 +21,10 @@ omit = lms/envs/* lms/djangoapps/*/migrations/* lms/djangoapps/*/features/* - common/djangoapps/terrain/* common/djangoapps/*/migrations/* openedx/core/djangoapps/*/migrations/* openedx/core/djangoapps/debug/* + openedx/envs/* openedx/features/*/migrations/* concurrency=multiprocessing diff --git a/.dockerignore b/.dockerignore index c3873d33a5..160c48565f 100644 --- a/.dockerignore +++ b/.dockerignore @@ -20,6 +20,9 @@ cms/envs/private.py ### Python artifacts **/*.pyc +**/__pycache__ +.venv +venv ### Editor and IDE artifacts **/*~ @@ -148,5 +151,3 @@ openedx/core/djangoapps/django_comment_common/comment_client/python # Locally generated PII reports **/pii_report - -/Dockerfile diff --git a/.editorconfig b/.editorconfig index abc3b2a34b..fbc3ba7985 100644 --- a/.editorconfig +++ b/.editorconfig @@ -64,7 +64,7 @@ # SERIOUSLY. # # ------------------------------ -# Generated by edx-lint version: 5.3.0 +# Generated by edx-lint version: 5.6.0 # ------------------------------ [*] end_of_line = lf @@ -97,4 +97,4 @@ max_line_length = 72 [*.rst] max_line_length = 79 -# eecef7d3f7f334de2348fe1b4b0b48d605f7dcab +# 3eb1e01bd9ba6cdf1e5d0a493581c4ea14404b67 diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 9044a0cc71..0000000000 --- a/.eslintignore +++ /dev/null @@ -1,70 +0,0 @@ -# Vendor files and generated test artifacts -**/vendor -test_root/staticfiles - - -# Vendor files living outside the /vendor/ dir -*.min.js -*-min.js -*.nocache.js -**/bootstrap*.js -**/jquery*.js -**/d3*.js - - -# Translations files -**/static/js/i18n - - -# Gitignored xmodule stuff -common/static/xmodule - - -# Symlinks into xmodule/js -cms/static/xmodule_js -lms/static/xmodule_js - - -# Mako templates that generate .js files -cms/djangoapps/pipeline_js/templates - - -# These are es2015 spec files that used to be in an ignored path. -# Now they live with the rest of the code, but we want to ignore them -# until the surrounding code is es2015 and we have a chance to clean them. -# We need to ignore them here, because es2015 will cause a parse error -# even if we add an eslint-disable line to the file. -cms/static/js/spec/models/course_spec.js -cms/static/js/spec/models/metadata_spec.js -cms/static/js/spec/models/section_spec.js -cms/static/js/spec/models/settings_course_grader_spec.js -cms/static/js/spec/models/settings_grading_spec.js -cms/static/js/spec/models/textbook_spec.js -cms/static/js/spec/models/upload_spec.js -cms/static/js/spec/views/assets_squire_spec.js -cms/static/js/spec/views/course_info_spec.js -cms/static/js/spec/views/metadata_edit_spec.js -cms/static/js/spec/views/textbook_spec.js -cms/static/js/spec/views/upload_spec.js -xmodule/capa/tests/test_files/js/test_problem_display.js -xmodule/capa/tests/test_files/js/test_problem_generator.js -xmodule/capa/tests/test_files/js/test_problem_grader.js -xmodule/capa/tests/test_files/js/xproblem.js -lms/static/js/spec/calculator_spec.js -lms/static/js/spec/courseware_spec.js -lms/static/js/spec/feedback_form_spec.js -lms/static/js/spec/helper.js -lms/static/js/spec/histogram_spec.js -lms/static/js/spec/modules/tab_spec.js -lms/static/js/spec/requirejs_spec.js -xmodule/js/spec/annotatable/display_spec.js -xmodule/js/spec/capa/display_spec.js -xmodule/js/spec/html/edit_spec.js -xmodule/js/spec/problem/edit_spec_hint.js -xmodule/js/spec/problem/edit_spec.js -xmodule/js/spec/tabs/edit.js - -xmodule/js/public/js -xmodule/assets/*/public/js - -!**/.eslintrc.js diff --git a/.eslintrc.json b/.eslintrc.json deleted file mode 100644 index 9f92d07d48..0000000000 --- a/.eslintrc.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "extends": "@edx/eslint-config", - "globals": { // Try to avoid adding any new globals. - // Old compatibility things and hacks - "edx": true, - "XBlock": true, - - // added by Django i18n tools - "gettext": true, - "ngettext": true, - - // added by jasmine-jquery - "loadFixtures": true, - "appendLoadFixtures": true, - "readFixtures": true, - "setFixtures": true, - "appendSetFixtures": true, - "spyOnEvent": true, - - // used by our requirejs implementation - "RequireJS": true, - - // enable jquery - "$": true - }, - "rules": { - "func-names": "off", - "indent": ["error", 4], - "react/jsx-indent": ["error", 4], - "react/jsx-indent-props": ["error", 4], - "new-cap": "off", - "no-else-return": "off", - "no-shadow": "error", - "object-curly-spacing": ["error", "never"], - "one-var": "off", - "one-var-declaration-per-line": ["error", "initializations"], - "space-before-function-paren": ["error", "never"], - "strict": "off", - - // Temporary Rules (Will be removed one-by-one to minimize file changes) - "block-scoped-var": "off", - "camelcase": "off", - "comma-dangle": "off", - "consistent-return": "off", - "eqeqeq": "off", - "function-call-argument-newline": "off", - "function-paren-newline": "off", - "import/extensions": "off", - "import/no-amd": "off", - "import/no-dynamic-require": "off", - "import/no-unresolved": "off", - "max-len": "off", - "no-console": "off", - "no-lonely-if": "off", - "no-param-reassign": "off", - "no-proto": "off", - "no-prototype-builtins": "off", - "no-redeclare": "off", - "no-restricted-globals": "off", - "no-restricted-syntax": "off", - "no-throw-literal": "off", - "no-undef": "off", - "no-underscore-dangle": "off", - "no-unused-vars": "off", - "no-use-before-define": "off", - "no-useless-escape": "off", - "no-var": "off", - "object-shorthand": "off", - "prefer-arrow-callback": "off", - "prefer-destructuring": "off", - "prefer-rest-params": "off", - "prefer-template": "off", - "radix": "off", - "react/prop-types": "off", - "vars-on-top": "off" - } -} diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index fc452f7acd..c1ec61e8f1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,9 +1,5 @@ # This does not cover all the code in edx-platform but it's a good start. -# Ensure that the team responsible for upgrades sees any PRs that would -# add GitHub-hosted dependencies to that platform. -requirements/edx/github.in @openedx/2u-arbi-bom - # Core common/djangoapps/student/ common/djangoapps/student/models/__init__.py @openedx/2u-tnl @@ -22,7 +18,7 @@ openedx/core/djangoapps/enrollments/ @openedx/2U- openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/oauth_dispatch openedx/core/djangoapps/user_api/ @openedx/2U-aperture -openedx/core/djangoapps/user_authn/ @openedx/2U-vanguards +openedx/core/djangoapps/user_authn/ @openedx/2U-infinity openedx/core/djangoapps/verified_track_content/ @openedx/2u-infinity openedx/features/course_experience/ xmodule/ @@ -58,3 +54,10 @@ lms/templates/dashboard.html @openedx/ax # Ensure minimal.yml stays minimal, this could be a team in the future # but it's just me for now, others can sign up if they care as well. lms/envs/minimal.yml @feanil + +# Ensure that un-necessary changes don't happen to the settings files as we're cleaning them up. +lms/envs/production.py @feanil @kdmccormick +cms/envs/production.py @feanil @kdmccormick + +# Ensure that this file is only used when strictly necessary +requirements/edx/github.in @feanil @kdmccormick diff --git a/.github/renovate.json5 b/.github/renovate.json5 index 3f771c2c72..edd326558c 100644 --- a/.github/renovate.json5 +++ b/.github/renovate.json5 @@ -1,39 +1,45 @@ -// This file is written in "JSON5" (https://json5.org/) so that we can use comments. { - "extends": [ - "config:base", - "schedule:weekly", - ":automergeLinters", - ":automergeMinor", - ":automergeTesters", - ":enableVulnerabilityAlerts", - ":semanticCommits", - ":updateNotScheduled" + extends: [ + 'config:recommended', + 'schedule:weekly', + ':automergeLinters', + ':automergeMinor', + ':automergeTesters', + ':enableVulnerabilityAlerts', + ':semanticCommits', + ':updateNotScheduled', ], - "packageRules": [ + packageRules: [ { - "matchDepTypes": [ - "devDependencies" + matchDepTypes: [ + 'devDependencies', ], - "matchUpdateTypes": [ - "lockFileMaintenance", - "minor", - "patch", - "pin" + matchUpdateTypes: [ + 'lockFileMaintenance', + 'minor', + 'patch', + 'pin', ], - "automerge": true + automerge: true, }, { - "matchPackagePatterns": ["@edx", "@openedx"], - "matchUpdateTypes": ["minor", "patch"], - "automerge": true - } + matchUpdateTypes: [ + 'minor', + 'patch', + ], + automerge: true, + matchPackageNames: [ + '/@edx/', + '/@openedx/', + ], + }, + ], + ignoreDeps: [ + 'karma-spec-reporter', + ], + timezone: 'America/New_York', + prConcurrentLimit: 3, + enabledManagers: [ + 'npm', ], - // When adding an ignoreDep, please include a reason and a public link that we can use to follow up and ensure - // that the ignoreDep is removed. - // This can be done as a comment within the ignoreDeps list. - "ignoreDeps": [], - "timezone": "America/New_York", - "prConcurrentLimit": 3, - "enabledManagers": ["npm"] } diff --git a/.github/workflows/add-remove-label-on-comment.yml b/.github/workflows/add-remove-label-on-comment.yml index 0f369db7d2..a658064f09 100644 --- a/.github/workflows/add-remove-label-on-comment.yml +++ b/.github/workflows/add-remove-label-on-comment.yml @@ -17,4 +17,3 @@ on: jobs: add_remove_labels: uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master - diff --git a/.github/workflows/check_python_dependencies.yml b/.github/workflows/check_python_dependencies.yml index 85a4e796ce..281e26589d 100644 --- a/.github/workflows/check_python_dependencies.yml +++ b/.github/workflows/check_python_dependencies.yml @@ -14,27 +14,23 @@ 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 \ --req-file requirements/edx/base.txt \ --req-file requirements/edx/testing.txt \ - --ignore https://github.com/edx/codejail-includes \ - --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/docker-publish.yml b/.github/workflows/docker-publish.yml deleted file mode 100644 index 6831e3563d..0000000000 --- a/.github/workflows/docker-publish.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Push Docker Images - -on: - push: - branches: - - master - -jobs: - # Push image to GitHub Packages. - # See also https://docs.docker.com/docker-hub/builds/ - push: - runs-on: ubuntu-latest - if: github.event_name == 'push' - - strategy: - matrix: - variant: - - "lms_dev" - - "cms_dev" - - "cms" - - "lms" - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v3 - - - name: Login to DockerHub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_PASSWORD }} - - - name: Build and push lms/cms base docker images - env: - DOCKERHUB_PASSWORD: ${{ secrets.DOCKERHUB_PASSWORD }} - DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} - run: make docker_tag_build_push_${{matrix.variant}} diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml index c9d2d7ab11..6df9cee794 100644 --- a/.github/workflows/js-tests.yml +++ b/.github/workflows/js-tests.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [ubuntu-latest] - node-version: [18, 20] + node-version: [20] python-version: - "3.11" @@ -26,9 +26,10 @@ jobs: uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} + cache: 'npm' - name: Setup npm - run: npm i -g npm@10.5.x + run: npm i -g npm@10.7.x - name: Install Firefox 123.0 run: | @@ -63,14 +64,12 @@ jobs: run: | make base-requirements - - uses: c-hive/gha-npm-cache@v1 + - name: Install npm + run: npm ci + - name: Run JS Tests - env: - TEST_SUITE: js-unit - SCRIPT_TO_RUN: ./scripts/generic-ci-tests.sh run: | - npm install -g jest - xvfb-run --auto-servernum ./scripts/all-tests.sh + npm run test - name: Save Job Artifacts uses: actions/upload-artifact@v4 diff --git a/.github/workflows/publish-ci-docker-image.yml b/.github/workflows/publish-ci-docker-image.yml deleted file mode 100644 index 6a0f3768b7..0000000000 --- a/.github/workflows/publish-ci-docker-image.yml +++ /dev/null @@ -1,43 +0,0 @@ -name: Push CI Runner Docker Image - -on: - workflow_dispatch: - schedule: - - cron: "0 1 * * 3" - -jobs: - push: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - # This has to happen after checkout in order for gh to work. - - name: "Cancel scheduled job on forks" - if: github.repository != 'openedx/edx-platform' && github.event_name == 'schedule' - env: - GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" - run: | - gh run cancel "${{ github.run_id }}" - gh run watch "${{ github.run_id }}" - - - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: ${{ secrets.TOOLS_EDX_ECR_USER_AWS_ACCESS_KEY_ID }} - aws-secret-access-key: ${{ secrets.TOOLS_EDX_ECR_USER_AWS_SECRET_ACCESS_KEY }} - aws-region: us-east-1 - - - name: Log in to ECR - id: login-ecr - uses: aws-actions/amazon-ecr-login@v2 - - - name: Build, tag, and push image to Amazon ECR - env: - ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }} - ECR_REPOSITORY: actions-runner - IMAGE_TAG: latest - run: | - docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG -f scripts/ci-runner.Dockerfile . - docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml index 9a654e09e7..dd90fd05d9 100644 --- a/.github/workflows/pylint-checks.yml +++ b/.github/workflows/pylint-checks.yml @@ -16,13 +16,13 @@ jobs: - module-name: lms-1 path: "lms/djangoapps/badges/ lms/djangoapps/branding/ lms/djangoapps/bulk_email/ lms/djangoapps/bulk_enroll/ lms/djangoapps/bulk_user_retirement/ lms/djangoapps/ccx/ lms/djangoapps/certificates/ lms/djangoapps/commerce/ lms/djangoapps/course_api/ lms/djangoapps/course_blocks/ lms/djangoapps/course_home_api/ lms/djangoapps/course_wiki/ lms/djangoapps/coursewarehistoryextended/ lms/djangoapps/debug/ lms/djangoapps/courseware/ lms/djangoapps/course_goals/ lms/djangoapps/rss_proxy/" - module-name: lms-2 - path: "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/learner_home/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/djangoapps/mfe_config_api/ lms/envs/ lms/lib/ lms/tests.py" + path: "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/learner_home/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/djangoapps/mfe_config_api/ lms/envs/ lms/lib/ lms/tests.py" - module-name: openedx-1 - path: "openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/content_staging/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/ openedx/core/djangoapps/course_live/" + path: "openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/content_staging/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/djangoapps/course_live/" - module-name: openedx-2 - path: "openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/service_status/ openedx/core/djangoapps/session_inactivity_timeout/ openedx/core/djangoapps/signals/ openedx/core/djangoapps/site_configuration/ openedx/core/djangoapps/system_wide_roles/ openedx/core/djangoapps/theming/ openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/util/ openedx/core/djangoapps/verified_track_content/ openedx/core/djangoapps/video_config/ openedx/core/djangoapps/video_pipeline/ openedx/core/djangoapps/waffle_utils/ openedx/core/djangoapps/xblock/ openedx/core/djangoapps/xmodule_django/ openedx/core/tests/ openedx/features/ openedx/testing/ openedx/tests/ openedx/core/djangoapps/notifications/ openedx/core/djangoapps/staticfiles/ openedx/core/djangoapps/content_tagging/" + path: "openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/service_status/ openedx/core/djangoapps/session_inactivity_timeout/ openedx/core/djangoapps/signals/ openedx/core/djangoapps/site_configuration/ openedx/core/djangoapps/system_wide_roles/ openedx/core/djangoapps/theming/ openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/util/ openedx/core/djangoapps/verified_track_content/ openedx/core/djangoapps/video_config/ openedx/core/djangoapps/video_pipeline/ openedx/core/djangoapps/waffle_utils/ openedx/core/djangoapps/xblock/ openedx/core/djangoapps/xmodule_django/ openedx/core/tests/ openedx/features/ openedx/testing/ openedx/tests/ openedx/envs/ openedx/core/djangoapps/notifications/ openedx/core/djangoapps/staticfiles/ openedx/core/djangoapps/content_tagging/" - module-name: common - path: "common pavelib" + path: "common" - module-name: cms path: "cms" - module-name: xmodule diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 310f9f83bf..3c80c1fac8 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -61,14 +61,26 @@ jobs: run: | make test-requirements + - name: Install npm + env: + PIP_SRC: ${{ runner.temp }} + run: npm ci + + - name: Install python packages + env: + PIP_SRC: ${{ runner.temp }} + run: | + pip install -e . + - name: Run Quality Tests env: - TEST_SUITE: quality - SCRIPT_TO_RUN: ./scripts/generic-ci-tests.sh PIP_SRC: ${{ runner.temp }} TARGET_BRANCH: ${{ github.base_ref }} run: | - ./scripts/all-tests.sh + make pycodestyle + make xsslint + make pii_check + make check_keywords - name: Save Job Artifacts if: always() diff --git a/.github/workflows/static-assets-check.yml b/.github/workflows/static-assets-check.yml index e08b2dce81..502dddce08 100644 --- a/.github/workflows/static-assets-check.yml +++ b/.github/workflows/static-assets-check.yml @@ -15,8 +15,8 @@ jobs: os: [ubuntu-24.04] python-version: - "3.11" - node-version: [18, 20] - npm-version: [10.5.x] + node-version: [20] + npm-version: [10.7.x] mongo-version: - "7.0" diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json index 4ab126cb47..7184bf917e 100644 --- a/.github/workflows/unit-test-shards.json +++ b/.github/workflows/unit-test-shards.json @@ -34,7 +34,6 @@ "paths": [ "lms/djangoapps/discussion/", "lms/djangoapps/edxnotes/", - "lms/djangoapps/email_marketing/", "lms/djangoapps/experiments/" ] }, @@ -239,6 +238,7 @@ "cms/djangoapps/cms_user_tasks/", "cms/djangoapps/course_creators/", "cms/djangoapps/export_course_metadata/", + "cms/djangoapps/import_from_modulestore/", "cms/djangoapps/maintenance/", "cms/djangoapps/models/", "cms/djangoapps/pipeline_js/", @@ -256,15 +256,13 @@ "common-with-lms": { "settings": "lms.envs.test", "paths": [ - "common/djangoapps/", - "pavelib/" + "common/djangoapps/" ] }, "common-with-cms": { "settings": "cms.envs.test", "paths": [ - "common/djangoapps/", - "pavelib/" + "common/djangoapps/" ] }, "xmodule-with-lms": { diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 8366df9367..2e391c9d07 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -71,29 +71,15 @@ jobs: - name: install system requirements run: | - sudo apt-get update && sudo apt-get install libmysqlclient-dev libxmlsec1-dev lynx openssl + sudo apt-get update && sudo apt-get install libmysqlclient-dev libxmlsec1-dev lynx - # This is needed until the ENABLE_BLAKE2B_HASHING can be removed and we - # can stop using MD4 by default. - - name: enable md4 hashing in libssl - run: | - cat <> $GITHUB_ENV - echo "root_lms_unit_tests_count=$(pytest --disable-warnings --collect-only --ds=lms.envs.test lms/ openedx/ common/djangoapps/ xmodule/ pavelib/ -q | head -n -2 | wc -l)" >> $GITHUB_ENV + echo "root_lms_unit_tests_count=$(pytest --disable-warnings --collect-only --ds=lms.envs.test lms/ openedx/ common/djangoapps/ xmodule/ -q | head -n -2 | wc -l)" >> $GITHUB_ENV - name: get GHA unit test paths shell: bash @@ -219,7 +205,6 @@ jobs: to add any missing apps and match the count. for more details please take a look at scripts/gha-shards-readme.md" exit 1 - # This job aggregates test results. It's the required check for branch protection. # https://github.com/marketplace/actions/alls-green#why # https://github.com/orgs/community/discussions/33579 diff --git a/.nvmrc b/.nvmrc index 3c032078a4..209e3ef4b6 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18 +20 diff --git a/.pii_annotations.yml b/.pii_annotations.yml index 328520738f..9000115a25 100644 --- a/.pii_annotations.yml +++ b/.pii_annotations.yml @@ -1,7 +1,7 @@ source_path: ./ report_path: pii_report safelist_path: .annotation_safe_list.yml -coverage_target: 94.5 +coverage_target: 85.3 # See OEP-30 for more information on these values and what they mean: # https://open-edx-proposals.readthedocs.io/en/latest/oep-0030-arch-pii-markup-and-auditing.html#docstring-annotations annotations: diff --git a/.readthedocs.yaml b/.readthedocs.yaml index 548ea72b3e..c0db64e5e1 100644 --- a/.readthedocs.yaml +++ b/.readthedocs.yaml @@ -3,7 +3,7 @@ version: 2 build: os: "ubuntu-22.04" tools: - python: "3.12" + python: "3.11" sphinx: configuration: docs/conf.py diff --git a/.stylelintignore b/.stylelintignore deleted file mode 100644 index cd53bacf3c..0000000000 --- a/.stylelintignore +++ /dev/null @@ -1,5 +0,0 @@ -xmodule/css -common/static/sass/bourbon -common/static/xmodule/modules/css -common/test/test-theme -lms/static/sass/vendor diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0a25729d5c..0658b92b2e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,8 +1,8 @@ We don't maintain a detailed changelog. For details of changes, please see -either the `edX Release Notes`_ or the `GitHub commit history`_. +either the `Open edX Release Notes`_ or the `GitHub commit history`_. -.. _edX Release Notes: https://edx.readthedocs.io/projects/open-edx-release-notes/en/latest/ +.. _Open edX Release Notes: https://docs.openedx.org/en/latest/community/release_notes/index.html .. _GitHub commit history: https://github.com/openedx/edx-platform/commits/master diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 75a716177f..0000000000 --- a/Dockerfile +++ /dev/null @@ -1,200 +0,0 @@ -FROM ubuntu:focal as minimal-system - -# Warning: This file is experimental. -# -# Short-term goals: -# * Be a suitable replacement for the `edxops/edxapp` image in devstack (in progress). -# * Take advantage of Docker caching layers: aim to put commands in order of -# increasing cache-busting frequency. -# * Related to ^, use no Ansible or Paver. -# Long-term goal: -# * Be a suitable base for production LMS and CMS images (THIS IS NOT YET THE CASE!). - -ARG DEBIAN_FRONTEND=noninteractive -ARG SERVICE_VARIANT -ARG SERVICE_PORT - -# Env vars: paver -# We intentionally don't use paver in this Dockerfile, but Devstack may invoke paver commands -# during provisioning. Enabling NO_PREREQ_INSTALL tells paver not to re-install Python -# requirements for every paver command, potentially saving a lot of developer time. -ARG NO_PREREQ_INSTALL='1' - -# Env vars: locale -ENV LANG='en_US.UTF-8' -ENV LANGUAGE='en_US:en' -ENV LC_ALL='en_US.UTF-8' - -# Env vars: configuration -ENV CONFIG_ROOT='/edx/etc' -ENV LMS_CFG="$CONFIG_ROOT/lms.yml" -ENV CMS_CFG="$CONFIG_ROOT/cms.yml" - -# Env vars: path -ENV VIRTUAL_ENV="/edx/app/edxapp/venvs/edxapp" -ENV PATH="${VIRTUAL_ENV}/bin:${PATH}" -ENV PATH="/edx/app/edxapp/edx-platform/node_modules/.bin:${PATH}" -ENV PATH="/edx/app/edxapp/edx-platform/bin:${PATH}" -ENV PATH="/edx/app/edxapp/nodeenv/bin:${PATH}" - -WORKDIR /edx/app/edxapp/edx-platform - -# Create user before assigning any directory ownership to it. -RUN useradd -m --shell /bin/false app - -# Use debconf to set locales to be generated when the locales apt package is installed later. -RUN echo "locales locales/default_environment_locale select en_US.UTF-8" | debconf-set-selections -RUN echo "locales locales/locales_to_be_generated multiselect en_US.UTF-8 UTF-8" | debconf-set-selections - -# Setting up ppa deadsnakes to get python 3.11 -RUN apt-get update && \ - apt-get install -y software-properties-common && \ - apt-add-repository -y ppa:deadsnakes/ppa - -# Install requirements that are absolutely necessary -RUN apt-get update && \ - apt-get -y dist-upgrade && \ - apt-get -y install --no-install-recommends \ - python3-pip \ - python3.11 \ - # python3-dev: required for building mysqlclient python package - python3.11-dev \ - python3.11-venv \ - libpython3.11 \ - libpython3.11-stdlib \ - libmysqlclient21 \ - # libmysqlclient-dev: required for building mysqlclient python package - libmysqlclient-dev \ - pkg-config \ - libssl1.1 \ - libxmlsec1-openssl \ - # lynx: Required by https://github.com/openedx/edx-platform/blob/b489a4ecb122/openedx/core/lib/html_to_text.py#L16 - lynx \ - ntp \ - git \ - build-essential \ - gettext \ - gfortran \ - graphviz \ - locales \ - swig \ - && \ - apt-get clean all && \ - rm -rf /var/lib/apt/* - -RUN mkdir -p /edx/var/edxapp -RUN mkdir -p /edx/etc -RUN chown app:app /edx/var/edxapp - -# The builder-production stage is a temporary stage that installs required packages and builds the python virtualenv, -# installs nodejs and node_modules. -# The built artifacts from this stage are then copied to the base stage. -FROM minimal-system as builder-production - -RUN apt-get update && \ - apt-get -y install --no-install-recommends \ - curl \ - libssl-dev \ - libffi-dev \ - libfreetype6-dev \ - libgeos-dev \ - libgraphviz-dev \ - libjpeg8-dev \ - liblapack-dev \ - libpng-dev \ - libsqlite3-dev \ - libxml2-dev \ - libxmlsec1-dev \ - libxslt1-dev - -# Setup python virtual environment -# It is already 'activated' because $VIRTUAL_ENV/bin was put on $PATH -RUN python3.11 -m venv "${VIRTUAL_ENV}" - -# Install python requirements -# Requires copying over requirements files, but not entire repository -COPY requirements requirements -RUN pip install -r requirements/pip.txt -RUN pip install -r requirements/edx/base.txt - -# Install node and npm -RUN nodeenv /edx/app/edxapp/nodeenv --node=18.19.0 --prebuilt -RUN npm install -g npm@10.5.x - -# This script is used by an npm post-install hook. -# We copy it into the image now so that it will be available when we run `npm install` in the next step. -# The script itself will copy certain modules into some uber-legacy parts of edx-platform which still use RequireJS. -COPY scripts/copy-node-modules.sh scripts/copy-node-modules.sh - -# Install node modules -COPY package.json package.json -COPY package-lock.json package-lock.json -RUN npm set progress=false && npm ci - -# The builder-development stage is a temporary stage that installs python modules required for development purposes -# The built artifacts from this stage are then copied to the development stage. -FROM builder-production as builder-development - -RUN pip install -r requirements/edx/development.txt - -# base stage -FROM minimal-system as base - -# Copy python virtual environment, nodejs and node_modules -COPY --from=builder-production /edx/app/edxapp/venvs/edxapp /edx/app/edxapp/venvs/edxapp -COPY --from=builder-production /edx/app/edxapp/nodeenv /edx/app/edxapp/nodeenv -COPY --from=builder-production /edx/app/edxapp/edx-platform/node_modules /edx/app/edxapp/edx-platform/node_modules - -# Copy over remaining parts of repository (including all code) -COPY . . - -# Install Python requirements again in order to capture local projects -RUN pip install -e . - -# Setting edx-platform directory as safe for git commands -RUN git config --global --add safe.directory /edx/app/edxapp/edx-platform - -# Production target -FROM base as production - -USER app - -ENV EDX_PLATFORM_SETTINGS='docker-production' -ENV SERVICE_VARIANT="${SERVICE_VARIANT}" -ENV SERVICE_PORT="${SERVICE_PORT}" -ENV DJANGO_SETTINGS_MODULE="${SERVICE_VARIANT}.envs.$EDX_PLATFORM_SETTINGS" -EXPOSE ${SERVICE_PORT} - -CMD gunicorn \ - -c /edx/app/edxapp/edx-platform/${SERVICE_VARIANT}/docker_${SERVICE_VARIANT}_gunicorn.py \ - --name ${SERVICE_VARIANT} \ - --bind=0.0.0.0:${SERVICE_PORT} \ - --max-requests=1000 \ - --access-logfile \ - - ${SERVICE_VARIANT}.wsgi:application - -# Development target -FROM base as development - -RUN apt-get update && \ - apt-get -y install --no-install-recommends \ - # wget is used in Makefile for common_constraints.txt - wget \ - && \ - apt-get clean all && \ - rm -rf /var/lib/apt/* - -COPY --from=builder-development /edx/app/edxapp/venvs/edxapp /edx/app/edxapp/venvs/edxapp - -RUN ln -s "$(pwd)/lms/envs/devstack-experimental.yml" "$LMS_CFG" -RUN ln -s "$(pwd)/cms/envs/devstack-experimental.yml" "$CMS_CFG" -# Temporary compatibility hack while devstack is supporting both the old `edxops/edxapp` image and this image. -# * Add in a dummy ../edxapp_env file -# * devstack sets /edx/etc/studio.yml as CMS_CFG. -RUN ln -s "$(pwd)/cms/envs/devstack-experimental.yml" "/edx/etc/studio.yml" -RUN touch ../edxapp_env - -ENV EDX_PLATFORM_SETTINGS='devstack_docker' -ENV SERVICE_VARIANT="${SERVICE_VARIANT}" -EXPOSE ${SERVICE_PORT} -CMD ./manage.py ${SERVICE_VARIANT} runserver 0.0.0.0:${SERVICE_PORT} diff --git a/Makefile b/Makefile index 15bab5df67..08b6f14893 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,7 @@ # Do things in edx-platform .PHONY: base-requirements check-types clean \ compile-requirements detect_changed_source_translations dev-requirements \ - docker_auth docker_build docker_tag_build_push_lms docker_tag_build_push_lms_dev \ - docker_tag_build_push_cms docker_tag_build_push_cms_dev docs extract_translations \ + docs extract_translations \ guides help lint-imports local-requirements migrate migrate-lms migrate-cms \ pre-requirements pull pull_xblock_translations pull_translations push_translations \ requirements shell swagger \ @@ -67,9 +66,6 @@ pull_translations: clean_translations ## pull translations via atlas detect_changed_source_translations: ## check if translation files are up-to-date i18n_tool changed -pull: ## update the Docker image used by "make shell" - docker pull edxops/edxapp:latest - pre-requirements: ## install Python requirements for running pip-tools pip install -r requirements/pip.txt pip install -r requirements/pip-tools.txt @@ -94,17 +90,9 @@ test-requirements: pre-requirements requirements: dev-requirements ## install development environment requirements -shell: ## launch a bash shell in a Docker container with all edx-platform dependencies installed - docker run -it -e "NO_PYTHON_UNINSTALL=1" -e "PIP_INDEX_URL=https://pypi.python.org/simple" -e TERM \ - -v `pwd`:/edx/app/edxapp/edx-platform:cached \ - -v edxapp_lms_assets:/edx/var/edxapp/staticfiles/ \ - -v edxapp_node_modules:/edx/app/edxapp/edx-platform/node_modules \ - edxops/edxapp:latest /edx/app/edxapp/devstack.sh open - # Order is very important in this list: files must appear after everything they include! REQ_FILES = \ requirements/edx/coverage \ - requirements/edx/paver \ requirements/edx-sandbox/base \ requirements/edx/base \ requirements/edx/doc \ @@ -164,27 +152,6 @@ upgrade-package: ## update just one package to the latest usable release check-types: ## run static type-checking tests mypy -docker_auth: - echo "$$DOCKERHUB_PASSWORD" | docker login -u "$$DOCKERHUB_USERNAME" --password-stdin - -docker_build: docker_auth - DOCKER_BUILDKIT=1 docker build . --build-arg SERVICE_VARIANT=lms --build-arg SERVICE_PORT=8000 --target development -t openedx/lms-dev - DOCKER_BUILDKIT=1 docker build . --build-arg SERVICE_VARIANT=lms --build-arg SERVICE_PORT=8000 --target production -t openedx/lms - DOCKER_BUILDKIT=1 docker build . --build-arg SERVICE_VARIANT=cms --build-arg SERVICE_PORT=8010 --target development -t openedx/cms-dev - DOCKER_BUILDKIT=1 docker build . --build-arg SERVICE_VARIANT=cms --build-arg SERVICE_PORT=8010 --target production -t openedx/cms - -docker_tag_build_push_lms: docker_auth - docker buildx build -t openedx/lms:latest -t openedx/lms:${GITHUB_SHA} --platform linux/amd64,linux/arm64 --build-arg SERVICE_VARIANT=lms --build-arg SERVICE_PORT=8000 --target production --push . - -docker_tag_build_push_lms_dev: docker_auth - docker buildx build -t openedx/lms-dev:latest -t openedx/lms-dev:${GITHUB_SHA} --platform linux/amd64,linux/arm64 --build-arg SERVICE_VARIANT=lms --build-arg SERVICE_PORT=8000 --target development --push . - -docker_tag_build_push_cms: docker_auth - docker buildx build -t openedx/cms:latest -t openedx/cms:${GITHUB_SHA} --platform linux/amd64,linux/arm64 --build-arg SERVICE_VARIANT=cms --build-arg SERVICE_PORT=8010 --target production --push . - -docker_tag_build_push_cms_dev: docker_auth - docker buildx build -t openedx/cms-dev:latest -t openedx/cms-dev:${GITHUB_SHA} --platform linux/amd64,linux/arm64 --build-arg SERVICE_VARIANT=cms --build-arg SERVICE_PORT=8010 --target development --push . - lint-imports: lint-imports @@ -204,3 +171,37 @@ migrate: migrate-lms migrate-cms # Part of https://github.com/openedx/wg-developer-experience/issues/136 ubuntu-requirements: ## Install ubuntu 22.04 system packages needed for `pip install` to work on ubuntu. sudo apt install libmysqlclient-dev libxmlsec1-dev + +xsslint: ## check xss for quality issuest + python scripts/xsslint/xss_linter.py \ + --rule-totals \ + --config=scripts.xsslint_config \ + --thresholds=scripts/xsslint_thresholds.json + +pycodestyle: ## check python files for quality issues + pycodestyle . + +## Re-enable --lint flag when this issue https://github.com/openedx/edx-platform/issues/35775 is resolved +pii_check: ## check django models for pii annotations + DJANGO_SETTINGS_MODULE=cms.envs.test \ + code_annotations django_find_annotations \ + --config_file .pii_annotations.yml \ + --app_name cms \ + --coverage \ + --lint + + DJANGO_SETTINGS_MODULE=lms.envs.test \ + code_annotations django_find_annotations \ + --config_file .pii_annotations.yml \ + --app_name lms \ + --coverage \ + --lint + +check_keywords: ## check django models for reserve keywords + DJANGO_SETTINGS_MODULE=cms.envs.test \ + python manage.py cms check_reserved_keywords \ + --override_file db_keyword_overrides.yml + + DJANGO_SETTINGS_MODULE=lms.envs.test \ + python manage.py lms check_reserved_keywords \ + --override_file db_keyword_overrides.yml diff --git a/README.rst b/README.rst index 61f21337ee..08a8853c1f 100644 --- a/README.rst +++ b/README.rst @@ -12,11 +12,11 @@ Open edX Platform Purpose ******* -The `Open edX Platform `_ is a service-oriented platform for authoring and -delivering online learning at any scale. The platform is written in +The `Open edX Platform `_ enables the authoring and +delivery of online learning at any scale. The platform is written in Python and JavaScript and makes extensive use of the Django framework. At the highest level, the platform is composed of a -monolith, some independently deployable applications (IDAs), and +modular monolith, some independently deployable applications (IDAs), and micro-frontends (MFEs) based on the ReactJS. This repository hosts the monolith at the center of the Open edX @@ -71,15 +71,15 @@ System Dependencies ------------------- OS: -* Ubuntu 20.04 - * Ubuntu 22.04 +* Ubuntu 24.04 + Interperters/Tools: * Python 3.11 -* Node 18 +* Node: See the ``.nvmrc`` file in this repository. Services: @@ -103,10 +103,19 @@ Language Packages: * Backend application: - ``pip install -r requirements/edx/base.txt`` (production) - - ``pip install -r requirements/edx/dev.txt`` (development) + - ``pip install -r requirements/edx/development.txt`` (development) Some Python packages have system dependencies. For example, installing these packages on Debian or Ubuntu will require first running ``sudo apt install python3-dev default-libmysqlclient-dev build-essential pkg-config`` to satisfy the requirements of the ``mysqlclient`` Python package. +Codejail Setup +-------------- + +As a part of the baremetal setup, you will need to configure your system to +work properly with codejail. See the `codejail installation steps`_ for more +details. + +.. _codejail installation steps: https://github.com/openedx/codejail?tab=readme-ov-file#installation + Build Steps ----------- diff --git a/cms/README.rst b/cms/README.rst index ee96c5cf09..27aa819a14 100644 --- a/cms/README.rst +++ b/cms/README.rst @@ -1,7 +1,7 @@ CMS === -This directory contains code relating to the Open edX Content Management System ("CMS"). It allows learning content to be created, edited, versioned, and eventually published to the `Open edX Learning Mangement System <../lms>`_ ("LMS"). The main user-facing application that CMS powers is the `Open edX Studio `_ +This directory contains code relating to the Open edX Content Management System ("CMS"). It allows learning content to be created, edited, versioned, and eventually published to the `Open edX Learning Mangement System <../lms>`_ ("LMS"). The main user-facing application that CMS powers is the `Open edX Studio `_ See also -------- diff --git a/cms/conftest.py b/cms/conftest.py index dc360e3df1..80d971070a 100644 --- a/cms/conftest.py +++ b/cms/conftest.py @@ -6,10 +6,7 @@ pytest from looking for the conftest.py module in the parent directory when only running cms tests. """ - -import importlib import logging -import os import pytest @@ -29,13 +26,6 @@ def pytest_configure(config): else: logging.info("pytest did not register json_report correctly") - if config.getoption('help'): - return - settings_module = os.environ.get('DJANGO_SETTINGS_MODULE') - startup_module = 'cms.startup' if settings_module.startswith('cms') else 'lms.startup' - startup = importlib.import_module(startup_module) - startup.run() - @pytest.fixture(autouse=True, scope='function') def _django_clear_site_cache(): diff --git a/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py b/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py index 8366ef7294..78261a4214 100644 --- a/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py +++ b/cms/djangoapps/api/v1/tests/test_views/test_course_runs.py @@ -286,7 +286,7 @@ class CourseRunViewSetTests(ModuleStoreTestCase): data['team'] = [{'user': 'invalid-username'}] response = self.client.post(self.list_url, data, format='json') self.assertEqual(response.status_code, 400) - self.assertDictContainsSubset({'team': ['Course team user does not exist']}, response.data) + self.assertEqual(response.data.get('team'), ['Course team user does not exist']) def test_images_upload(self): # http://www.django-rest-framework.org/api-guide/parsers/#fileuploadparser diff --git a/cms/djangoapps/api/v1/views/course_runs.py b/cms/djangoapps/api/v1/views/course_runs.py index b405207bd9..7b27193d17 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/cms_user_tasks/signals.py b/cms/djangoapps/cms_user_tasks/signals.py index b4f86807fd..40bfd57818 100644 --- a/cms/djangoapps/cms_user_tasks/signals.py +++ b/cms/djangoapps/cms_user_tasks/signals.py @@ -86,9 +86,16 @@ def user_task_stopped_handler(sender, **kwargs): # pylint: disable=unused-argum reverse('usertaskstatus-detail', args=[status.uuid]) ) + # check if this is a course optimizer task + is_course_optimizer_task = False + course_optimizer_artifact = UserTaskArtifact.objects.filter(status=status, name="BrokenLinks").first() + if course_optimizer_artifact: + is_course_optimizer_task = True + user_email = status.user.email olx_validation_text = get_olx_validation_from_artifact() - task_args = [task_name, str(status.state_text), user_email, detail_url, olx_validation_text] + task_args = [task_name, str(status.state_text), user_email, detail_url, + olx_validation_text, is_course_optimizer_task] try: send_task_complete_email.delay(*task_args) except Exception: # pylint: disable=broad-except diff --git a/cms/djangoapps/cms_user_tasks/tasks.py b/cms/djangoapps/cms_user_tasks/tasks.py index e06c58b6f0..0efc852e22 100644 --- a/cms/djangoapps/cms_user_tasks/tasks.py +++ b/cms/djangoapps/cms_user_tasks/tasks.py @@ -21,17 +21,18 @@ TASK_COMPLETE_EMAIL_TIMEOUT = 60 @shared_task(bind=True) @set_code_owner_attribute -def send_task_complete_email(self, task_name, task_state_text, dest_addr, detail_url, olx_validation_text=None): +def send_task_complete_email(self, task_name, task_state_text, dest_addr, detail_url, + olx_validation_text=None, is_course_optimizer_task=False): """ Sending an email to the users when an async task completes. """ retries = self.request.retries - context = { 'task_name': task_name, 'task_status': task_state_text, 'detail_url': detail_url, 'olx_validation_errors': {}, + 'is_course_optimizer_task': is_course_optimizer_task, } if olx_validation_text: try: diff --git a/cms/djangoapps/contentstore/admin.py b/cms/djangoapps/contentstore/admin.py index 5b58c9495f..67bb39b7a3 100644 --- a/cms/djangoapps/contentstore/admin.py +++ b/cms/djangoapps/contentstore/admin.py @@ -13,13 +13,15 @@ from edx_django_utils.admin.mixins import ReadOnlyAdminMixin from cms.djangoapps.contentstore.models import ( BackfillCourseTabsConfig, CleanStaleCertificateAvailabilityDatesConfig, - VideoUploadConfig + ComponentLink, + ContainerLink, + LearningContextLinksStatus, + VideoUploadConfig, ) from cms.djangoapps.contentstore.outlines_regenerate import CourseOutlineRegenerate from openedx.core.djangoapps.content.learning_sequences.api import key_supports_outlines -from .tasks import update_outline_from_modulestore_task, update_all_outlines_from_modulestore_task - +from .tasks import update_all_outlines_from_modulestore_task, update_outline_from_modulestore_task log = logging.getLogger(__name__) @@ -86,6 +88,110 @@ class CleanStaleCertificateAvailabilityDatesConfigAdmin(ConfigurationModelAdmin) pass +@admin.register(ComponentLink) +class ComponentLinkAdmin(admin.ModelAdmin): + """ + ComponentLink admin. + """ + fields = ( + "uuid", + "upstream_block", + "upstream_usage_key", + "upstream_context_key", + "downstream_usage_key", + "downstream_context_key", + "version_synced", + "version_declined", + "created", + "updated", + ) + readonly_fields = fields + list_display = [ + "upstream_block", + "upstream_usage_key", + "downstream_usage_key", + "version_synced", + "updated", + ] + search_fields = [ + "upstream_usage_key", + "upstream_context_key", + "downstream_usage_key", + "downstream_context_key", + ] + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + +@admin.register(ContainerLink) +class ContainerLinkAdmin(admin.ModelAdmin): + """ + ContainerLink admin. + """ + fields = ( + "uuid", + "upstream_container", + "upstream_container_key", + "upstream_context_key", + "downstream_usage_key", + "downstream_context_key", + "version_synced", + "version_declined", + "created", + "updated", + ) + readonly_fields = fields + list_display = [ + "upstream_container", + "upstream_container_key", + "downstream_usage_key", + "version_synced", + "updated", + ] + search_fields = [ + "upstream_container_key", + "upstream_context_key", + "downstream_usage_key", + "downstream_context_key", + ] + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + +@admin.register(LearningContextLinksStatus) +class LearningContextLinksStatusAdmin(admin.ModelAdmin): + """ + LearningContextLinksStatus admin. + """ + fields = ( + "context_key", + "status", + "created", + "updated", + ) + readonly_fields = ("created", "updated") + list_display = ( + "context_key", + "status", + "created", + "updated", + ) + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + admin.site.register(BackfillCourseTabsConfig, ConfigurationModelAdmin) admin.site.register(VideoUploadConfig, ConfigurationModelAdmin) admin.site.register(CourseOutlineRegenerate, CourseOutlineRegenerateAdmin) diff --git a/cms/djangoapps/contentstore/api/tests/test_validation.py b/cms/djangoapps/contentstore/api/tests/test_validation.py index 4b94c35588..4e0a9bbce6 100644 --- a/cms/djangoapps/contentstore/api/tests/test_validation.py +++ b/cms/djangoapps/contentstore/api/tests/test_validation.py @@ -103,7 +103,7 @@ class CourseValidationViewTest(SharedModuleStoreTestCase, APITestCase): 'has_update': True, }, 'certificates': { - 'is_enabled': True, + 'is_enabled': False, 'is_activated': False, 'has_certificate': False, }, diff --git a/cms/djangoapps/contentstore/api/views/course_import.py b/cms/djangoapps/contentstore/api/views/course_import.py index dd7828c2d9..3027b1926d 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 42489978b8..b301f5ac14 100644 --- a/cms/djangoapps/contentstore/api/views/course_quality.py +++ b/cms/djangoapps/contentstore/api/views/course_quality.py @@ -3,7 +3,7 @@ import logging import time import numpy as np -from edxval.api import get_videos_for_course +from edxval.api import get_course_videos_qset from rest_framework.generics import GenericAPIView from rest_framework.response import Response from scipy import stats @@ -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): """ @@ -180,13 +185,11 @@ class CourseQualityView(DeveloperErrorViewMixin, GenericAPIView): def _videos_quality(self, course): # lint-amnesty, pylint: disable=missing-function-docstring video_blocks_in_course = modulestore().get_items(course.id, qualifiers={'category': 'video'}) - videos, __ = get_videos_for_course(course.id) - videos_in_val = list(videos) - video_durations = [video['duration'] for video in videos_in_val] + video_durations = [cv.video.duration for cv in get_course_videos_qset(course.id)] return dict( total_number=len(video_blocks_in_course), - num_mobile_encoded=len(videos_in_val), + num_mobile_encoded=len(video_durations), num_with_val_id=len([v for v in video_blocks_in_course if v.edx_video_id]), durations=self._stats_dict(video_durations), ) diff --git a/cms/djangoapps/contentstore/api/views/course_validation.py b/cms/djangoapps/contentstore/api/views/course_validation.py index 0fa8d9041c..be5208430a 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): """ @@ -212,7 +217,7 @@ class CourseValidationView(DeveloperErrorViewMixin, GenericAPIView): def _certificates_validation(self, course): is_activated, certificates = CertificateManager.is_activated(course) - certificates_enabled = certificates is not None + certificates_enabled = CertificateManager.is_enabled(course) return dict( is_activated=is_activated, has_certificate=certificates_enabled and len(certificates) > 0, diff --git a/cms/djangoapps/contentstore/asset_storage_handlers.py b/cms/djangoapps/contentstore/asset_storage_handlers.py index 97fcb31257..02857b11de 100644 --- a/cms/djangoapps/contentstore/asset_storage_handlers.py +++ b/cms/djangoapps/contentstore/asset_storage_handlers.py @@ -25,7 +25,8 @@ from common.djangoapps.util.date_utils import get_default_time_display from common.djangoapps.util.json_request import JsonResponse from openedx.core.djangoapps.contentserver.caching import del_cached_content from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx_filters.course_authoring.filters import LMSPageURLRequested +from openedx.core.djangoapps.user_api.models import UserPreference +from openedx_filters.content_authoring.filters import LMSPageURLRequested from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.exceptions import NotFoundError # lint-amnesty, pylint: disable=wrong-import-order @@ -194,7 +195,9 @@ def _assets_json(request, course_key): ''' request_options = _parse_request_to_dictionary(request) - filter_parameters = {} + filter_parameters = { + 'user_language': UserPreference.get_value(request.user, 'pref-lang') or 'en', + } if request_options['requested_asset_type']: filters_are_invalid_error = _get_error_if_invalid_parameters(request_options['requested_asset_type']) @@ -717,7 +720,7 @@ def get_asset_json(display_name, content_type, date, location, thumbnail_locatio asset_url = StaticContent.serialize_asset_key_with_slash(location) ## .. filter_implemented_name: LMSPageURLRequested - ## .. filter_type: org.openedx.course_authoring.lms.page.url.requested.v1 + ## .. filter_type: org.openedx.content_authoring.lms.page.url.requested.v1 lms_root, _ = LMSPageURLRequested.run_filter( url=configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL), org=location.org, diff --git a/common/djangoapps/terrain/__init__.py b/cms/djangoapps/contentstore/core/__init__.py similarity index 100% rename from common/djangoapps/terrain/__init__.py rename to cms/djangoapps/contentstore/core/__init__.py diff --git a/cms/djangoapps/contentstore/core/course_optimizer_provider.py b/cms/djangoapps/contentstore/core/course_optimizer_provider.py new file mode 100644 index 0000000000..7b44e05a87 --- /dev/null +++ b/cms/djangoapps/contentstore/core/course_optimizer_provider.py @@ -0,0 +1,319 @@ +""" +Logic for handling actions in Studio related to Course Optimizer. +""" +import json +from user_tasks.conf import settings as user_tasks_settings +from user_tasks.models import UserTaskArtifact, UserTaskStatus + +from cms.djangoapps.contentstore.tasks import CourseLinkCheckTask, LinkState +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import get_xblock +from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import usage_key_with_run +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore + + +# Restricts status in the REST API to only those which the requesting user has permission to view. +# These can be overwritten in django settings. +# By default, these should be the UserTaskStatus statuses: +# 'Pending', 'In Progress', 'Succeeded', 'Failed', 'Canceled', 'Retrying' +STATUS_FILTERS = user_tasks_settings.USER_TASKS_STATUS_FILTERS + + +def get_link_check_data(request, course_id): + """ + Retrives data and formats it for the link check get request. + """ + task_status = _latest_task_status(request, course_id) + status = None + created_at = None + broken_links_dto = None + error = None + if task_status is None: + # The task hasn't been initialized yet; did we store info in the session already? + try: + session_status = request.session['link_check_status'] + status = session_status[course_id] + except KeyError: + status = 'Uninitiated' + else: + status = task_status.state + created_at = task_status.created + if task_status.state == UserTaskStatus.SUCCEEDED: + artifact = UserTaskArtifact.objects.get(status=task_status, name='BrokenLinks') + with artifact.file as file: + content = file.read() + json_content = json.loads(content) + broken_links_dto = generate_broken_links_descriptor(json_content, request.user) + elif task_status.state in (UserTaskStatus.FAILED, UserTaskStatus.CANCELED): + errors = UserTaskArtifact.objects.filter(status=task_status, name='Error') + if errors: + error = errors[0].text + try: + error = json.loads(error) + except ValueError: + # Wasn't JSON, just use the value as a string + pass + + data = { + 'LinkCheckStatus': status, + **({'LinkCheckCreatedAt': created_at} if created_at else {}), + **({'LinkCheckOutput': broken_links_dto} if broken_links_dto else {}), + **({'LinkCheckError': error} if error else {}) + } + return data + + +def _latest_task_status(request, course_key_string, view_func=None): + """ + Get the most recent link check status update for the specified course + key. + """ + args = {'course_key_string': course_key_string} + name = CourseLinkCheckTask.generate_name(args) + task_status = UserTaskStatus.objects.filter(name=name) + for status_filter in STATUS_FILTERS: + task_status = status_filter().filter_queryset(request, task_status, view_func) + return task_status.order_by('-created').first() + + +def generate_broken_links_descriptor(json_content, request_user): + """ + Returns a Data Transfer Object for frontend given a list of broken links. + + ** Example json_content structure ** + Note: link_state is locked if the link is a studio link and returns 403 + link_state is external-forbidden if the link is not a studio link and returns 403 + [ + ['block_id_1', 'link_1', link_state], + ['block_id_1', 'link_2', link_state], + ['block_id_2', 'link_3', link_state], + ... + ] + + ** Example DTO structure ** + { + 'sections': [ + { + 'id': 'section_id', + 'displayName': 'section name', + 'subsections': [ + { + 'id': 'subsection_id', + 'displayName': 'subsection name', + 'units': [ + { + 'id': 'unit_id', + 'displayName': 'unit name', + 'blocks': [ + { + 'id': 'block_id', + 'displayName': 'block name', + 'url': 'url/to/block', + 'brokenLinks: [], + 'lockedLinks: [], + }, + ..., + ] + }, + ..., + ] + }, + ..., + ] + }, + ..., + ] + } + """ + xblock_node_tree = {} # tree representation of xblock relationships + xblock_dictionary = {} # dictionary of xblock attributes + + for item in json_content: + block_id, link, *rest = item + if rest: + link_state = rest[0] + else: + link_state = '' + + usage_key = usage_key_with_run(block_id) + block = get_xblock(usage_key, request_user) + xblock_node_tree, xblock_dictionary = _update_node_tree_and_dictionary( + block=block, + link=link, + link_state=link_state, + node_tree=xblock_node_tree, + dictionary=xblock_dictionary + ) + + return _create_dto_recursive(xblock_node_tree, xblock_dictionary) + + +def _update_node_tree_and_dictionary(block, link, link_state, node_tree, dictionary): + """ + Inserts a block into the node tree and add its attributes to the dictionary. + + ** Example node tree structure ** + { + 'section_id1': { + 'subsection_id1': { + 'unit_id1': { + 'block_id1': {}, + 'block_id2': {}, + ..., + }, + 'unit_id2': { + 'block_id3': {}, + ..., + }, + ..., + }, + ..., + }, + ..., + } + + ** Example dictionary structure ** + { + 'xblock_id: { + 'display_name': 'xblock name', + 'category': 'chapter' + }, + 'html_block_id': { + 'display_name': 'xblock name', + 'category': 'chapter', + 'url': 'url_1', + 'locked_links': [...], + 'broken_links': [...], + 'external_forbidden_links': [...], + } + ..., + } + """ + updated_tree, updated_dictionary = node_tree, dictionary + + path = _get_node_path(block) + current_node = updated_tree + xblock_id = '' + + # Traverse the path and build the tree structure + for xblock in path: + xblock_id = xblock.location.block_id + updated_dictionary.setdefault( + xblock_id, + { + 'display_name': xblock.display_name, + 'category': getattr(xblock, 'category', ''), + } + ) + # Sets new current node and creates the node if it doesn't exist + current_node = current_node.setdefault(xblock_id, {}) + + # Add block-level details for the last xblock in the path (URL and broken/locked links) + updated_dictionary[xblock_id].setdefault( + 'url', + f'/course/{block.course_id}/editor/{block.category}/{block.location}' + ) + + # The link_state == True condition is maintained for backward compatibility. + # Previously, the is_locked attribute was used instead of link_state. + # If is_locked is True, it indicates that the link is locked. + if link_state is True or link_state == LinkState.LOCKED: + updated_dictionary[xblock_id].setdefault('locked_links', []).append(link) + elif link_state == LinkState.EXTERNAL_FORBIDDEN: + updated_dictionary[xblock_id].setdefault('external_forbidden_links', []).append(link) + else: + updated_dictionary[xblock_id].setdefault('broken_links', []).append(link) + + return updated_tree, updated_dictionary + + +def _get_node_path(block): + """ + Retrieves the path from the course root node to a specific block, excluding the root. + + ** Example Path structure ** + [chapter_node, sequential_node, vertical_node, html_node] + """ + path = [] + current_node = block + + while current_node.get_parent(): + path.append(current_node) + current_node = current_node.get_parent() + + return list(reversed(path)) + + +CATEGORY_TO_LEVEL_MAP = { + "chapter": "sections", + "sequential": "subsections", + "vertical": "units" +} + + +def _create_dto_recursive(xblock_node, xblock_dictionary, parent_id=None): + """ + Recursively build the Data Transfer Object by using + the structure from the node tree and data from the dictionary. + """ + # Exit condition when there are no more child nodes (at block level) + if not xblock_node: + return None + + level = None + xblock_children = [] + + for xblock_id, node in xblock_node.items(): + child_blocks = _create_dto_recursive(node, xblock_dictionary, parent_id=xblock_id) + xblock_data = xblock_dictionary.get(xblock_id, {}) + + xblock_entry = { + 'id': xblock_id, + 'displayName': xblock_data.get('display_name', ''), + } + if child_blocks is None: # Leaf node + level = 'blocks' + xblock_entry.update({ + 'url': xblock_data.get('url', ''), + 'brokenLinks': xblock_data.get('broken_links', []), + 'lockedLinks': xblock_data.get('locked_links', []), + 'externalForbiddenLinks': xblock_data.get('external_forbidden_links', []) + }) + else: # Non-leaf node + category = xblock_data.get('category', None) + # If parent and child has same IDs and level is 'sections', change it to 'subsections' + # And if parent and child has same IDs and level is 'subsections', change it to 'units' + if xblock_id == parent_id: + if category == "chapter": + category = "sequential" + elif category == "sequential": + category = "vertical" + level = CATEGORY_TO_LEVEL_MAP.get(category, None) + xblock_entry.update(child_blocks) + + xblock_children.append(xblock_entry) + + return {level: xblock_children} if level else None + + +def sort_course_sections(course_key, data): + """Retrieve and sort course sections based on the published course structure.""" + course_blocks = modulestore().get_items( + course_key, + qualifiers={'category': 'course'}, + revision=ModuleStoreEnum.RevisionOption.published_only + ) + + if not course_blocks or 'LinkCheckOutput' not in data or 'sections' not in data['LinkCheckOutput']: + return data # Return unchanged data if course_blocks or required keys are missing + + sorted_section_ids = [section.location.block_id for section in course_blocks[0].get_children()] + + sections_map = {section['id']: section for section in data['LinkCheckOutput']['sections']} + data['LinkCheckOutput']['sections'] = [ + sections_map[section_id] + for section_id in sorted_section_ids + if section_id in sections_map + ] + + return data diff --git a/common/djangoapps/terrain/stubs/__init__.py b/cms/djangoapps/contentstore/core/tests/__init__.py similarity index 100% rename from common/djangoapps/terrain/stubs/__init__.py rename to cms/djangoapps/contentstore/core/tests/__init__.py diff --git a/cms/djangoapps/contentstore/core/tests/test_course_optimizer_provider.py b/cms/djangoapps/contentstore/core/tests/test_course_optimizer_provider.py new file mode 100644 index 0000000000..ca0b73af71 --- /dev/null +++ b/cms/djangoapps/contentstore/core/tests/test_course_optimizer_provider.py @@ -0,0 +1,297 @@ +""" +Tests for course optimizer +""" +from unittest import mock +from unittest.mock import Mock + +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from cms.djangoapps.contentstore.core.course_optimizer_provider import ( + _update_node_tree_and_dictionary, + _create_dto_recursive, + sort_course_sections +) +from cms.djangoapps.contentstore.tasks import LinkState + + +class TestLinkCheckProvider(CourseTestCase): + """ + Tests for functions that generate a json structure of locked and broken links + to send to the frontend. + """ + def setUp(self): + """Setup course blocks for tests""" + super().setUp() + self.mock_course = Mock() + self.mock_section = Mock( + location=Mock(block_id='chapter_1'), + display_name='Section Name', + category='chapter' + ) + self.mock_subsection = Mock( + location=Mock(block_id='sequential_1'), + display_name='Subsection Name', + category='sequential' + ) + self.mock_unit = Mock( + location=Mock(block_id='vertical_1'), + display_name='Unit Name', + category='vertical' + ) + self.mock_block = Mock( + location=Mock(block_id='block_1'), + display_name='Block Name', + course_id=self.course.id, + category='html' + ) + self.mock_course.get_parent.return_value = None + self.mock_section.get_parent.return_value = self.mock_course + self.mock_subsection.get_parent.return_value = self.mock_section + self.mock_unit.get_parent.return_value = self.mock_subsection + self.mock_block.get_parent.return_value = self.mock_unit + + def test_update_node_tree_and_dictionary_returns_node_tree(self): + """ + Verify _update_node_tree_and_dictionary creates a node tree structure + when passed a block level xblock. + """ + expected_tree = { + 'chapter_1': { + 'sequential_1': { + 'vertical_1': { + 'block_1': {} + } + } + } + } + result_tree, result_dictionary = _update_node_tree_and_dictionary( + self.mock_block, 'example_link', LinkState.LOCKED, {}, {} + ) + + self.assertEqual(expected_tree, result_tree) + + def test_update_node_tree_and_dictionary_returns_dictionary(self): + """ + Verify _update_node_tree_and_dictionary creates a dictionary of parent xblock entries + when passed a block level xblock. + """ + expected_dictionary = { + 'chapter_1': { + 'display_name': 'Section Name', + 'category': 'chapter' + }, + 'sequential_1': { + 'display_name': 'Subsection Name', + 'category': 'sequential' + }, + 'vertical_1': { + 'display_name': 'Unit Name', + 'category': 'vertical' + }, + 'block_1': { + 'display_name': 'Block Name', + 'category': 'html', + 'url': f'/course/{self.course.id}/editor/html/{self.mock_block.location}', + 'locked_links': ['example_link'] + } + } + result_tree, result_dictionary = _update_node_tree_and_dictionary( + self.mock_block, 'example_link', LinkState.LOCKED, {}, {} + ) + + self.assertEqual(expected_dictionary, result_dictionary) + + def test_create_dto_recursive_returns_for_empty_node(self): + """ + Test _create_dto_recursive behavior at the end of recursion. + Function should return None when given empty node tree and empty dictionary. + """ + expected = _create_dto_recursive({}, {}) + self.assertEqual(None, expected) + + def test_create_dto_recursive_returns_for_leaf_node(self): + """ + Test _create_dto_recursive behavior at the step before the end of recursion. + When evaluating a leaf node in the node tree, the function should return broken links + and locked links data from the leaf node. + """ + expected_result = { + 'blocks': [ + { + 'id': 'block_1', + 'displayName': 'Block Name', + 'url': '/block/1', + 'brokenLinks': ['broken_link_1', 'broken_link_2'], + 'lockedLinks': ['locked_link'], + 'externalForbiddenLinks': ['forbidden_link_1'], + } + ] + } + + mock_node_tree = { + 'block_1': {} + } + mock_dictionary = { + 'chapter_1': { + 'display_name': 'Section Name', + 'category': 'chapter' + }, + 'sequential_1': { + 'display_name': 'Subsection Name', + 'category': 'sequential' + }, + 'vertical_1': { + 'display_name': 'Unit Name', + 'category': 'vertical' + }, + 'block_1': { + 'display_name': 'Block Name', + 'url': '/block/1', + 'broken_links': ['broken_link_1', 'broken_link_2'], + 'locked_links': ['locked_link'], + 'external_forbidden_links': ['forbidden_link_1'], + } + } + expected = _create_dto_recursive(mock_node_tree, mock_dictionary) + self.assertEqual(expected_result, expected) + + def test_create_dto_recursive_returns_for_full_tree(self): + """ + Test _create_dto_recursive behavior when recursing many times. + When evaluating a fully mocked node tree and dictionary, the function should return + a full json DTO prepared for frontend. + """ + expected_result = { + 'sections': [ + { + 'id': 'chapter_1', + 'displayName': 'Section Name', + 'subsections': [ + { + 'id': 'sequential_1', + 'displayName': 'Subsection Name', + 'units': [ + { + 'id': 'vertical_1', + 'displayName': 'Unit Name', + 'blocks': [ + { + 'id': 'block_1', + 'displayName': 'Block Name', + 'url': '/block/1', + 'brokenLinks': ['broken_link_1', 'broken_link_2'], + 'lockedLinks': ['locked_link'], + 'externalForbiddenLinks': ['forbidden_link_1'], + } + ] + } + ] + } + ] + } + ] + } + + mock_node_tree = { + 'chapter_1': { + 'sequential_1': { + 'vertical_1': { + 'block_1': {} + } + } + } + } + mock_dictionary = { + 'chapter_1': { + 'display_name': 'Section Name', + 'category': 'chapter' + }, + 'sequential_1': { + 'display_name': 'Subsection Name', + 'category': 'sequential' + }, + 'vertical_1': { + 'display_name': 'Unit Name', + 'category': 'vertical' + }, + 'block_1': { + 'display_name': 'Block Name', + 'url': '/block/1', + 'broken_links': ['broken_link_1', 'broken_link_2'], + 'locked_links': ['locked_link'], + 'external_forbidden_links': ['forbidden_link_1'], + } + } + expected = _create_dto_recursive(mock_node_tree, mock_dictionary) + + self.assertEqual(expected_result, expected) + + @mock.patch('cms.djangoapps.contentstore.core.course_optimizer_provider.modulestore', autospec=True) + def test_returns_unchanged_data_if_no_course_blocks(self, mock_modulestore): + """Test that the function returns unchanged data if no course blocks exist.""" + mock_modulestore_instance = Mock() + mock_modulestore.return_value = mock_modulestore_instance + mock_modulestore_instance.get_items.return_value = [] + + data = {} + result = sort_course_sections("course-v1:Test+Course", data) + assert result == data # Should return the original data + + @mock.patch('cms.djangoapps.contentstore.core.course_optimizer_provider.modulestore', autospec=True) + def test_returns_unchanged_data_if_linkcheckoutput_missing(self, mock_modulestore): + """Test that the function returns unchanged data if 'LinkCheckOutput' is missing.""" + + mock_modulestore_instance = Mock() + mock_modulestore.return_value = mock_modulestore_instance + + data = {'LinkCheckStatus': 'Uninitiated'} # No 'LinkCheckOutput' + mock_modulestore_instance.get_items.return_value = data + + result = sort_course_sections("course-v1:Test+Course", data) + assert result == data + + @mock.patch('cms.djangoapps.contentstore.core.course_optimizer_provider.modulestore', autospec=True) + def test_returns_unchanged_data_if_sections_missing(self, mock_modulestore): + """Test that the function returns unchanged data if 'sections' is missing.""" + + mock_modulestore_instance = Mock() + mock_modulestore.return_value = mock_modulestore_instance + + data = {'LinkCheckStatus': 'Success', 'LinkCheckOutput': {}} # No 'LinkCheckOutput' + mock_modulestore_instance.get_items.return_value = data + + result = sort_course_sections("course-v1:Test+Course", data) + assert result == data + + @mock.patch('cms.djangoapps.contentstore.core.course_optimizer_provider.modulestore', autospec=True) + def test_sorts_sections_correctly(self, mock_modulestore): + """Test that the function correctly sorts sections based on published course structure.""" + + mock_course_block = Mock() + mock_course_block.get_children.return_value = [ + Mock(location=Mock(block_id="section2")), + Mock(location=Mock(block_id="section3")), + Mock(location=Mock(block_id="section1")), + ] + + mock_modulestore_instance = Mock() + mock_modulestore.return_value = mock_modulestore_instance + mock_modulestore_instance.get_items.return_value = [mock_course_block] + + data = { + "LinkCheckOutput": { + "sections": [ + {"id": "section1", "name": "Intro"}, + {"id": "section2", "name": "Advanced"}, + {"id": "section3", "name": "Bonus"}, # Not in course structure + ] + } + } + + result = sort_course_sections("course-v1:Test+Course", data) + expected_sections = [ + {"id": "section2", "name": "Advanced"}, + {"id": "section3", "name": "Bonus"}, + {"id": "section1", "name": "Intro"}, + ] + + assert result["LinkCheckOutput"]["sections"] == expected_sections diff --git a/cms/djangoapps/contentstore/courseware_index.py b/cms/djangoapps/contentstore/courseware_index.py index d3b6f811d5..b7b7499203 100644 --- a/cms/djangoapps/contentstore/courseware_index.py +++ b/cms/djangoapps/contentstore/courseware_index.py @@ -14,7 +14,7 @@ from search.search_engine_base import SearchEngine from cms.djangoapps.contentstore.course_group_config import GroupConfiguration from common.djangoapps.course_modes.models import CourseMode -from openedx.core.lib.courses import course_image_url +from openedx.core.lib.courses import course_image_url, course_organization_image_url from xmodule.annotator_mixin import html_to_text # lint-amnesty, pylint: disable=wrong-import-order from xmodule.library_tools import normalize_key_for_search # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order @@ -612,6 +612,7 @@ class CourseAboutSearchIndexer(CoursewareSearchIndexer): 'course': course_id, 'content': {}, 'image_url': course_image_url(course), + 'org_image_url': course_organization_image_url(course), } # load data for all of the 'about' blocks for this course into a dictionary diff --git a/cms/djangoapps/contentstore/docs/how-tos/test_course_related_view_auth.rst b/cms/djangoapps/contentstore/docs/how-tos/test_course_related_view_auth.rst new file mode 100644 index 0000000000..b9b797ac43 --- /dev/null +++ b/cms/djangoapps/contentstore/docs/how-tos/test_course_related_view_auth.rst @@ -0,0 +1,64 @@ +============================================== +How to test View Auth for course-related views +============================================== + +What to test +------------ +Each view endpoint that exposes an internal API endpoint - like in files in the rest_api folder - must +be tested for the following. + +- Only authenticated users can access the endpoint. +- Only users with the correct permissions (authorization) can access the endpoint. +- All data and params that are part of the request are properly validated. + +How to test +----------- +The `AuthorizeStaffTestCase` class provides a set of tests that can be used to test the authorization +of a view. If you inherit from this class, these tests will be automatically run. For details, +please look at the source code of the `AuthorizeStaffTestCase` class. + +A lot of these tests can be easily implemented by inheriting from the `AuthorizeStaffTestCase`. +This parent class assumes that the view is for a specific course and that only users who have access +to the course can access the view. (They are either staff or instructors for the course, or global admin). + +Here is an example of how to test a view that requires a user to be authenticated and have access to a course. + +.. code-block:: python + + from cms.djangoapps.contentstore.tests.test_utils import AuthorizeStaffTestCase + from django.test import TestCase + from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + from django.urls import reverse + + class TestMyGetView(AuthorizeStaffTestCase, ModuleStoreTestCase, TestCase): + def make_request(self, course_id=None, data=None): + url = self.get_url(self.course.id) + response = self.client.get(url, data) + return response + + def get_url(self, course_key): + url = reverse( + 'cms.djangoapps.contentstore:v0:my_get_view', + kwargs={'course_id': self.course.id} + ) + return url + +As you can see, you need to inherit from `AuthorizeStaffTestCase` and `ModuleStoreTestCase`, and then either +`TestCase` or `APITestCase` depending on the type of view you are testing. For cookie-based +authentication, `TestCase` is sufficient, for Oauth2 use `ApiTestCase`. + +The only two methods you need to implement are `make_request` and `get_url`. The `make_request` method +should make the request to the view and return the response. The `get_url` method should return the URL +for the view you are testing. + +Overwriting Tests +----------------- +If you need different behavior you can overwrite the tests from the parent class. +For example, if students should have access to the view, simply implement the +`test_student` method in your test class. + +Adding other tests +------------------ +If you want to test other things in the view - let's say validation - +it's easy to just add another `test_...` function to your test class +and you can use the `make_request` method to make the request. diff --git a/cms/djangoapps/contentstore/exams.py b/cms/djangoapps/contentstore/exams.py index be94e404cc..6b25147c6a 100644 --- a/cms/djangoapps/contentstore/exams.py +++ b/cms/djangoapps/contentstore/exams.py @@ -71,7 +71,16 @@ def register_exams(course_key): timed_exam.is_onboarding_exam ) - due_date = timed_exam.due.isoformat() if timed_exam.due else (course.end.isoformat() if course.end else None) + # Exams in courses not using an LTI based proctoring provider should use the original definition of due_date + # from contentstore/proctoring.py. These exams are powered by the edx-proctoring plugin and not the edx-exams + # microservice. + if course.proctoring_provider == 'lti_external': + due_date = ( + timed_exam.due.isoformat() if timed_exam.due + else (course.end.isoformat() if course.end else None) + ) + else: + due_date = timed_exam.due if not course.self_paced else None exams_list.append({ 'course_id': str(course_key), diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index 8ff1f1aa39..ff2020afd8 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -7,8 +7,10 @@ import pathlib import urllib from lxml import etree from mimetypes import guess_type +import re from attrs import frozen, Factory +from django.core.files.base import ContentFile from django.conf import settings from django.contrib.auth import get_user_model from django.utils.translation import gettext as _ @@ -22,12 +24,19 @@ from xmodule.contentstore.django import contentstore from xmodule.exceptions import NotFoundError from xmodule.modulestore.django import modulestore from xmodule.xml_block import XmlMixin +from xmodule.video_block.transcripts_utils import Transcript, build_components_import_path +from edxval.api import ( + create_external_video, + create_or_update_video_transcript, +) from cms.djangoapps.models.settings.course_grading import CourseGradingModel -from cms.lib.xblock.upstream_sync import UpstreamLink, UpstreamLinkException, fetch_customizable_fields +from cms.lib.xblock.upstream_sync import UpstreamLink, UpstreamLinkException +from cms.lib.xblock.upstream_sync_block import fetch_customizable_fields_from_block from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers import openedx.core.djangoapps.content_staging.api as content_staging_api import openedx.core.djangoapps.content_tagging.api as content_tagging_api +from openedx.core.djangoapps.content_staging.data import LIBRARY_SYNC_PURPOSE from .utils import reverse_course_url, reverse_library_url, reverse_usage_url @@ -74,6 +83,22 @@ def is_unit(xblock, parent_xblock=None): return False +def is_library_content(xblock): + """ + Returns true if the specified xblock is library content. + """ + return xblock.category == 'library_content' + + +def get_parent_if_split_test(xblock): + """ + Returns the parent of the specified xblock if it is a split test, otherwise returns None. + """ + parent_xblock = get_parent_xblock(xblock) + if parent_xblock and parent_xblock.category == 'split_test': + return parent_xblock + + def xblock_has_own_studio_page(xblock, parent_xblock=None): """ Returns true if the specified xblock has an associated Studio page. Most xblocks do @@ -261,6 +286,66 @@ class StaticFileNotices: error_files: list[str] = Factory(list) +def _insert_static_files_into_downstream_xblock( + downstream_xblock: XBlock, staged_content_id: int, request +) -> StaticFileNotices: + """ + Gets static files from staged content, and inserts them into the downstream XBlock. + """ + static_files = content_staging_api.get_staged_content_static_files(staged_content_id) + notices, substitutions = _import_files_into_course( + course_key=downstream_xblock.context_key, + staged_content_id=staged_content_id, + static_files=static_files, + usage_key=downstream_xblock.usage_key, + ) + # FIXME: This code shouldn't have any special cases for specific block types like video + # in the future. + if downstream_xblock.usage_key.block_type == 'video': + _import_transcripts( + downstream_xblock, + staged_content_id=staged_content_id, + static_files=static_files, + ) + + # Rewrite the OLX's static asset references to point to the new + # locations for those assets. See _import_files_into_course for more + # info on why this is necessary. + store = modulestore() + if hasattr(downstream_xblock, "data") and substitutions: + data_with_substitutions = downstream_xblock.data + for old_static_ref, new_static_ref in substitutions.items(): + data_with_substitutions = _replace_strings( + data_with_substitutions, + old_static_ref, + new_static_ref, + ) + downstream_xblock.data = data_with_substitutions + if store is not None: + store.update_item(downstream_xblock, request.user.id) + return notices + + +def _replace_strings(obj: dict | list | str, old_str: str, new_str: str): + """ + Replacing any instances of the given `old_str` string with `new_str` in any strings found in the the given object. + + Returns the updated object. + """ + if isinstance(obj, dict): + for key, value in obj.items(): + obj[key] = _replace_strings(value, old_str, new_str) + + elif isinstance(obj, list): + for index, item in enumerate(obj): + obj[index] = _replace_strings(item, old_str, new_str) + + elif isinstance(obj, str): + return obj.replace(old_str, new_str) + + return obj + + def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) -> tuple[XBlock | None, StaticFileNotices]: """ Import a block (along with its children and any required static assets) from @@ -274,8 +359,6 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) -> """ from cms.djangoapps.contentstore.views.preview import _load_preview_block - if not content_staging_api: - raise RuntimeError("The required content_staging app is not installed") user_clipboard = content_staging_api.get_user_clipboard(request.user.id) if not user_clipboard: # Clipboard is empty or expired/error/loading @@ -298,31 +381,56 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) -> tags=user_clipboard.content.tags, ) - # Now handle static files that need to go into Files & Uploads. - static_files = content_staging_api.get_staged_content_static_files(user_clipboard.content.id) - notices, substitutions = _import_files_into_course( - course_key=parent_key.context_key, - staged_content_id=user_clipboard.content.id, - static_files=static_files, - usage_key=new_xblock.scope_ids.usage_id, - ) - - # Rewrite the OLX's static asset references to point to the new - # locations for those assets. See _import_files_into_course for more - # info on why this is necessary. - if hasattr(new_xblock, 'data') and substitutions: - data_with_substitutions = new_xblock.data - for old_static_ref, new_static_ref in substitutions.items(): - data_with_substitutions = data_with_substitutions.replace( - old_static_ref, - new_static_ref, - ) - new_xblock.data = data_with_substitutions + usage_key = new_xblock.usage_key + if usage_key.block_type == 'video': + # The edx_video_id must always be new so as not + # to interfere with the data of the copied block + new_xblock.edx_video_id = create_external_video(display_name='external video') store.update_item(new_xblock, request.user.id) + notices = _insert_static_files_into_downstream_xblock(new_xblock, user_clipboard.content.id, request) + return new_xblock, notices +def import_static_assets_for_library_sync(downstream_xblock: XBlock, lib_block: XBlock, request) -> StaticFileNotices: + """ + Import the static assets from the library xblock to the downstream xblock + through staged content. Also updates the OLX references to point to the new + locations of those assets in the downstream course. + + Does not deal with permissions or REST stuff - do that before calling this. + + Returns a summary of changes made to static files in the destination + course. + """ + if not lib_block.runtime.get_block_assets(lib_block, fetch_asset_data=False): + return StaticFileNotices() + staged_content = content_staging_api.stage_xblock_temporarily(lib_block, request.user.id, LIBRARY_SYNC_PURPOSE) + if not staged_content: + # expired/error/loading + return StaticFileNotices() + + store = modulestore() + try: + with store.bulk_operations(downstream_xblock.context_key): + # FIXME: This code shouldn't have any special cases for specific block types like video + # in the future. + if downstream_xblock.usage_key.block_type == 'video' and not downstream_xblock.edx_video_id: + # If the `downstream_xblock` is a new created block, we need to create + # a new `edx_video_id` to import the transcripts. + downstream_xblock.edx_video_id = create_external_video(display_name='external video') + store.update_item(downstream_xblock, request.user.id) + + # Now handle static files that need to go into Files & Uploads. + # If the required files already exist, nothing will happen besides updating the olx. + notices = _insert_static_files_into_downstream_xblock(downstream_xblock, staged_content.id, request) + finally: + staged_content.delete() + + return notices + + def _fetch_and_set_upstream_link( copied_from_block: str, copied_from_version_num: int, @@ -330,7 +438,7 @@ def _fetch_and_set_upstream_link( user: User ): """ - Fetch and set upstream link for the given xblock. This function handles following cases: + Fetch and set upstream link for the given xblock which is being pasted. This function handles following cases: * the xblock is copied from a v2 library; the library block is set as upstream. * the xblock is copied from a course; no upstream is set, only copied_from_block is set. * the xblock is copied from a course where the source block was imported from a library; the original libary block @@ -339,7 +447,7 @@ def _fetch_and_set_upstream_link( # Try to link the pasted block (downstream) to the copied block (upstream). temp_xblock.upstream = copied_from_block try: - UpstreamLink.get_for_block(temp_xblock) + upstream_link = UpstreamLink.get_for_block(temp_xblock) except UpstreamLinkException: # Usually this will fail. For example, if the copied block is a modulestore course block, it can't be an # upstream. That's fine! Instead, we store a reference to where this block was copied from, in the @@ -370,7 +478,8 @@ def _fetch_and_set_upstream_link( # later wants to restore it, it will restore to the value that the field had when the block was pasted. Of # course, if the author later syncs updates from a *future* published upstream version, then that will fetch # new values from the published upstream content. - fetch_customizable_fields(upstream=temp_xblock, downstream=temp_xblock, user=user) + if isinstance(upstream_link.upstream_key, UsageKey): # only if upstream is a block, not a container + fetch_customizable_fields_from_block(downstream=temp_xblock, user=user, upstream=temp_xblock) def _import_xml_node_to_parent( @@ -447,16 +556,20 @@ def _import_xml_node_to_parent( temp_xblock = xblock_class.parse_xml(node_without_children, runtime, keys) child_nodes = list(node) + if issubclass(xblock_class, XmlMixin) and "x-is-pointer-node" in getattr(temp_xblock, "data", ""): + # Undo the "pointer node" hack if needed (e.g. for capa problems) + temp_xblock.data = re.sub(r'([^>]+) x-is-pointer-node="no"', r'\1', temp_xblock.data, count=1) + # Restore the original id_generator runtime.id_generator = original_id_generator if xblock_class.has_children and temp_xblock.children: raise NotImplementedError("We don't yet support pasting XBlocks with children") - temp_xblock.parent = parent_key if copied_from_block: _fetch_and_set_upstream_link(copied_from_block, copied_from_version_num, temp_xblock, user) # Save the XBlock into modulestore. We need to save the block and its parent for this to work: new_xblock = store.update_item(temp_xblock, user.id, allow_not_found=True) + new_xblock.parent = parent_key parent_xblock.children.append(new_xblock.location) store.update_item(parent_xblock, user.id) @@ -543,6 +656,9 @@ def _import_files_into_course( if result is True: new_files.append(file_data_obj.filename) substitutions.update(substitution_for_file) + elif substitution_for_file: + # substitutions need to be made because OLX references to these files need to be updated + substitutions.update(substitution_for_file) elif result is None: pass # This file already exists; no action needed. else: @@ -578,8 +694,8 @@ def _import_file_into_course( # we're not going to attempt to change. if clipboard_file_path.startswith('static/'): # If it's in this form, it came from a library and assumes component-local assets - file_path = clipboard_file_path.lstrip('static/') - import_path = f"components/{usage_key.block_type}/{usage_key.block_id}/{file_path}" + file_path = clipboard_file_path.removeprefix('static/') + import_path = build_components_import_path(usage_key, file_path) filename = pathlib.Path(file_path).name new_key = course_key.make_asset_key("asset", import_path.replace("/", "_")) else: @@ -613,13 +729,57 @@ def _import_file_into_course( contentstore().save(content) return True, {clipboard_file_path: f"static/{import_path}"} elif current_file.content_digest == file_data_obj.md5_hash: - # The file already exists and matches exactly, so no action is needed - return None, {} + # The file already exists and matches exactly, so no action is needed except substitutions + return None, {clipboard_file_path: f"static/{import_path}"} else: # There is a conflict with some other file that has the same name. return False, {} +def _import_transcripts( + block: XBlock, + staged_content_id: int, + static_files: list[content_staging_api.StagedContentFileData], +): + """ + Adds transcripts to VAL using the new edx_video_id. + """ + for file_data_obj in static_files: + clipboard_file_path = file_data_obj.filename + data = content_staging_api.get_staged_content_static_file_data( + staged_content_id, + clipboard_file_path + ) + if data is None: + raise NotFoundError(file_data_obj.source_key) + + if clipboard_file_path.startswith('static/'): + # If it's in this form, it came from a library and assumes component-local assets + file_path = clipboard_file_path.removeprefix('static/') + else: + # Otherwise it came from a course... + file_path = clipboard_file_path + + filename = pathlib.Path(file_path).name + + language_code = next((k for k, v in block.transcripts.items() if v == filename), None) + if language_code: + sjson_subs = Transcript.convert( + content=data, + input_format=Transcript.SRT, + output_format=Transcript.SJSON + ).encode() + create_or_update_video_transcript( + video_id=block.edx_video_id, + language_code=language_code, + metadata={ + 'file_format': Transcript.SJSON, + 'language_code': language_code + }, + file_data=ContentFile(sjson_subs), + ) + + def is_item_in_course_tree(item): """ Check that the item is in the course tree. @@ -653,3 +813,26 @@ def _get_usage_key_from_node(node, parent_id: str) -> UsageKey | None: ) return usage_key + + +def concat_static_file_notices(notices: list[StaticFileNotices]) -> StaticFileNotices: + """Combines multiple static file notices into a single object + + Args: + notices: list of StaticFileNotices + + Returns: + Single StaticFileNotices + """ + new_files = [] + conflicting_files = [] + error_files = [] + for notice in notices: + new_files.extend(notice.new_files) + conflicting_files.extend(notice.conflicting_files) + error_files.extend(notice.error_files) + return StaticFileNotices( + new_files=list(set(new_files)), + conflicting_files=list(set(conflicting_files)), + error_files=list(set(error_files)), + ) diff --git a/cms/djangoapps/contentstore/management/commands/backfill_course_tabs.py b/cms/djangoapps/contentstore/management/commands/backfill_course_tabs.py index 878a8dabaa..768c3a53f7 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 @@ class Command(BaseCommand): 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 0b4cbfb1fb..b56c172e37 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 @@ class Command(BaseCommand): 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/management/commands/export_olx.py b/cms/djangoapps/contentstore/management/commands/export_olx.py index 7561ebdc9b..68b291c013 100644 --- a/cms/djangoapps/contentstore/management/commands/export_olx.py +++ b/cms/djangoapps/contentstore/management/commands/export_olx.py @@ -19,7 +19,7 @@ import os import re import shutil import tarfile -from tempfile import mkdtemp, mktemp +from tempfile import mkdtemp, mkstemp from textwrap import dedent from django.core.management.base import BaseCommand, CommandError @@ -55,7 +55,9 @@ class Command(BaseCommand): pipe_results = False if filename is None: - filename = mktemp() + fd, filename = mkstemp() + os.close(fd) + os.unlink(filename) pipe_results = True export_course_to_tarfile(course_key, filename) diff --git a/cms/djangoapps/contentstore/management/commands/recreate_upstream_links.py b/cms/djangoapps/contentstore/management/commands/recreate_upstream_links.py new file mode 100644 index 0000000000..a0f04bb279 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/recreate_upstream_links.py @@ -0,0 +1,94 @@ +""" +Management command to recreate upstream-dowstream links in ComponentLink for course(s). + +This command can be run for all the courses or for given list of courses. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone + +from django.core.management.base import BaseCommand, CommandError +from django.utils.translation import gettext as _ +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey + +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + +from ...tasks import create_or_update_upstream_links + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Recreate upstream links for course(s) in ComponentLink and ContainerLink tables. + + Examples: + # Recreate upstream links for two courses. + $ ./manage.py cms recreate_upstream_links --course course-v1:edX+DemoX.1+2014 \ + --course course-v1:edX+DemoX.2+2015 + # Force recreate upstream links for one or more courses including processed ones. + $ ./manage.py cms recreate_upstream_links --course course-v1:edX+DemoX.1+2014 \ + --course course-v1:edX+DemoX.2+2015 --force + # Recreate upstream links for all courses. + $ ./manage.py cms recreate_upstream_links --all + # Force recreate links for all courses including completely processed ones. + $ ./manage.py cms recreate_upstream_links --all --force + # Delete all links and force recreate links for all courses + $ ./manage.py cms recreate_upstream_links --all --force --replace + """ + + def add_arguments(self, parser): + parser.add_argument( + '--course', + metavar=_('COURSE_KEY'), + action='append', + help=_('Recreate links for xblocks under given course keys. For eg. course-v1:edX+DemoX.1+2014'), + default=[], + ) + parser.add_argument( + '--all', + action='store_true', + help=_( + 'Recreate links for xblocks under all courses. NOTE: this can take long time depending' + ' on number of course and xblocks' + ), + ) + parser.add_argument( + '--force', + action='store_true', + help=_('Recreate links even for completely processed courses.'), + ) + parser.add_argument( + '--replace', + action='store_true', + help=_('Delete all and create links for given course(s).'), + ) + + def handle(self, *args, **options): + """ + Handle command + """ + courses = options['course'] + should_process_all = options['all'] + force = options['force'] + replace = options['replace'] + time_now = datetime.now(tz=timezone.utc) + if not courses and not should_process_all: + raise CommandError('Either --course or --all argument should be provided.') + + if should_process_all and courses: + raise CommandError('Only one of --course or --all argument should be provided.') + + if should_process_all: + courses = CourseOverview.get_all_course_keys() + for course in courses: + log.info(f"Start processing upstream->dowstream links in course: {course}") + try: + CourseKey.from_string(str(course)) + except InvalidKeyError: + log.error(f"Invalid course key: {course}, skipping..") + continue + create_or_update_upstream_links.delay(str(course), force=force, replace=replace, created=time_now) diff --git a/cms/djangoapps/contentstore/migrations/0009_learningcontextlinksstatus_publishableentitylink.py b/cms/djangoapps/contentstore/migrations/0009_learningcontextlinksstatus_publishableentitylink.py new file mode 100644 index 0000000000..84b80cd633 --- /dev/null +++ b/cms/djangoapps/contentstore/migrations/0009_learningcontextlinksstatus_publishableentitylink.py @@ -0,0 +1,93 @@ +# Generated by Django 4.2.18 on 2025-02-05 05:33 + +import uuid + +import django.db.models.deletion +import opaque_keys.edx.django.models +import openedx_learning.lib.fields +import openedx_learning.lib.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('oel_publishing', '0002_alter_learningpackage_key_and_more'), + ('contentstore', '0008_cleanstalecertificateavailabilitydatesconfig'), + ] + + operations = [ + migrations.CreateModel( + name='LearningContextLinksStatus', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ( + 'context_key', + opaque_keys.edx.django.models.CourseKeyField( + help_text='Linking status for course context key', max_length=255, unique=True + ), + ), + ( + 'status', + models.CharField( + choices=[ + ('pending', 'Pending'), + ('processing', 'Processing'), + ('failed', 'Failed'), + ('completed', 'Completed'), + ], + help_text='Status of links in given learning context/course.', + max_length=20, + ), + ), + ('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ], + options={ + 'verbose_name': 'Learning Context Links status', + 'verbose_name_plural': 'Learning Context Links status', + }, + ), + migrations.CreateModel( + name='PublishableEntityLink', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')), + ( + 'upstream_usage_key', + opaque_keys.edx.django.models.UsageKeyField( + help_text='Upstream block usage key, this value cannot be null and useful to track upstream library blocks that do not exist yet', + max_length=255, + ), + ), + ( + 'upstream_context_key', + openedx_learning.lib.fields.MultiCollationCharField( + db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, + db_index=True, + help_text='Upstream context key i.e., learning_package/library key', + max_length=500, + ), + ), + ('downstream_usage_key', opaque_keys.edx.django.models.UsageKeyField(max_length=255, unique=True)), + ('downstream_context_key', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)), + ('version_synced', models.IntegerField()), + ('version_declined', models.IntegerField(blank=True, null=True)), + ('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ( + 'upstream_block', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='links', + to='oel_publishing.publishableentity', + ), + ), + ], + options={ + 'verbose_name': 'Publishable Entity Link', + 'verbose_name_plural': 'Publishable Entity Links', + }, + ), + ] diff --git a/cms/djangoapps/contentstore/migrations/0010_container_link_models.py b/cms/djangoapps/contentstore/migrations/0010_container_link_models.py new file mode 100644 index 0000000000..8d42ad9614 --- /dev/null +++ b/cms/djangoapps/contentstore/migrations/0010_container_link_models.py @@ -0,0 +1,59 @@ +# Generated by Django 4.2.20 on 2025-04-22 15:08 +import uuid + +import django.db.models.deletion +import opaque_keys.edx.django.models +import openedx_learning.lib.fields +import openedx_learning.lib.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('oel_publishing', '0003_containers'), + ('oel_components', '0003_remove_componentversioncontent_learner_downloadable'), + ('contentstore', '0009_learningcontextlinksstatus_publishableentitylink'), + ] + + operations = [ + migrations.RenameModel( + old_name='PublishableEntityLink', + new_name='ComponentLink', + ), + migrations.AlterModelOptions( + name='componentlink', + options={'verbose_name': 'Component Link', 'verbose_name_plural': 'Component Links'}, + ), + migrations.AlterField( + model_name='componentlink', + name='upstream_block', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='links', + to='oel_components.component', + ), + ), + migrations.CreateModel( + name='ContainerLink', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')), + ('upstream_context_key', openedx_learning.lib.fields.MultiCollationCharField(db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, db_index=True, help_text='Upstream context key i.e., learning_package/library key', max_length=500)), + ('downstream_usage_key', opaque_keys.edx.django.models.UsageKeyField(max_length=255, unique=True)), + ('downstream_context_key', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)), + ('version_synced', models.IntegerField()), + ('version_declined', models.IntegerField(blank=True, null=True)), + ('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ('upstream_container_key', opaque_keys.edx.django.models.ContainerKeyField(help_text='Upstream block key (e.g. lct:...), this value cannot be null and is useful to track upstream library blocks that do not exist yet or were deleted.', max_length=255)), + ('upstream_container', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='links', to='oel_publishing.container')), + ], + options={ + 'abstract': False, + 'verbose_name': 'Container Link', + 'verbose_name_plural': 'Container Links', + }, + ), + ] diff --git a/cms/djangoapps/contentstore/migrations/0011_enable_markdown_editor_flag_by_default.py b/cms/djangoapps/contentstore/migrations/0011_enable_markdown_editor_flag_by_default.py new file mode 100644 index 0000000000..491ae0e422 --- /dev/null +++ b/cms/djangoapps/contentstore/migrations/0011_enable_markdown_editor_flag_by_default.py @@ -0,0 +1,25 @@ +from django.db import migrations + +from cms.djangoapps.contentstore.toggles import ( + ENABLE_REACT_MARKDOWN_EDITOR +) + + +def create_flag(apps, schema_editor): + Flag = apps.get_model('waffle', 'Flag') + Flag.objects.get_or_create( + name=ENABLE_REACT_MARKDOWN_EDITOR.name, defaults={'everyone': True} + ) + + +class Migration(migrations.Migration): + dependencies = [ + ('contentstore', '0010_container_link_models'), + ('waffle', '0001_initial'), + ] + + operations = [ + # Do not remove the flags for rollback. We don't want to lose originals if + # they already existed, and it won't hurt if they are created. + migrations.RunPython(create_flag, reverse_code=migrations.RunPython.noop), + ] diff --git a/cms/djangoapps/contentstore/models.py b/cms/djangoapps/contentstore/models.py index f3b39397cf..65f83c23b1 100644 --- a/cms/djangoapps/contentstore/models.py +++ b/cms/djangoapps/contentstore/models.py @@ -3,8 +3,25 @@ Models for contentstore """ +from datetime import datetime, timezone + from config_models.models import ConfigurationModel +from django.db import models +from django.db.models import Count, F, Q, QuerySet, Max from django.db.models.fields import IntegerField, TextField +from django.db.models.functions import Coalesce +from django.db.models.lookups import GreaterThan +from django.utils.translation import gettext_lazy as _ +from opaque_keys.edx.django.models import CourseKeyField, ContainerKeyField, UsageKeyField +from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.locator import LibraryContainerLocator +from openedx_learning.api.authoring import get_published_version +from openedx_learning.api.authoring_models import Component, Container +from openedx_learning.lib.fields import ( + immutable_uuid_field, + key_field, + manual_date_time_field, +) class VideoUploadConfig(ConfigurationModel): @@ -63,3 +80,403 @@ class CleanStaleCertificateAvailabilityDatesConfig(ConfigurationModel): "`clean_stale_certificate_available_dates` management command.' See the management command for options." ) ) + + +class EntityLinkBase(models.Model): + """ + Abstract base class that defines fields and functions for storing link between two publishable entities + or links between publishable entity and a course xblock. + """ + uuid = immutable_uuid_field() + # Search by library/upstream context key + upstream_context_key = key_field( + help_text=_("Upstream context key i.e., learning_package/library key"), + db_index=True, + ) + # A downstream entity can only link to single upstream entity + # whereas an entity can be upstream for multiple downstream entities. + downstream_usage_key = UsageKeyField(max_length=255, unique=True) + # Search by course/downstream key + downstream_context_key = CourseKeyField(max_length=255, db_index=True) + version_synced = models.IntegerField() + version_declined = models.IntegerField(null=True, blank=True) + created = manual_date_time_field() + updated = manual_date_time_field() + + class Meta: + abstract = True + + +class ComponentLink(EntityLinkBase): + """ + This represents link between any two publishable entities or link between publishable entity and a course + XBlock. It helps in tracking relationship between XBlocks imported from libraries and used in different courses. + """ + upstream_block = models.ForeignKey( + Component, + on_delete=models.SET_NULL, + related_name="links", + null=True, + blank=True, + ) + upstream_usage_key = UsageKeyField( + max_length=255, + help_text=_( + "Upstream block usage key, this value cannot be null" + " and useful to track upstream library blocks that do not exist yet" + ) + ) + + class Meta: + verbose_name = _("Component Link") + verbose_name_plural = _("Component Links") + + def __str__(self): + return f"ComponentLink<{self.upstream_usage_key}->{self.downstream_usage_key}>" + + @property + def upstream_version_num(self) -> int | None: + """ + Returns upstream block version number if available. + """ + published_version = get_published_version(self.upstream_block.publishable_entity.id) + return published_version.version_num if published_version else None + + @property + def upstream_context_title(self) -> str: + """ + Returns upstream context title. + """ + return self.upstream_block.publishable_entity.learning_package.title + + @classmethod + def filter_links( + cls, + **link_filter, + ) -> QuerySet["EntityLinkBase"]: + """ + Get all links along with sync flag, upstream context title and version, with optional filtering. + """ + ready_to_sync = link_filter.pop('ready_to_sync', None) + result = cls.objects.filter(**link_filter).select_related( + "upstream_block__publishable_entity__published__version", + "upstream_block__publishable_entity__learning_package", + "upstream_block__publishable_entity__published__publish_log_record__publish_log", + ).annotate( + ready_to_sync=( + GreaterThan( + Coalesce("upstream_block__publishable_entity__published__version__version_num", 0), + Coalesce("version_synced", 0) + ) & GreaterThan( + Coalesce("upstream_block__publishable_entity__published__version__version_num", 0), + Coalesce("version_declined", 0) + ) + ) + ) + if ready_to_sync is not None: + result = result.filter(ready_to_sync=ready_to_sync) + return result + + @classmethod + def summarize_by_downstream_context(cls, downstream_context_key: CourseKey) -> QuerySet: + """ + Returns a summary of links by upstream context for given downstream_context_key. + Example: + [ + { + "upstream_context_title": "CS problems 3", + "upstream_context_key": "lib:OpenedX:CSPROB3", + "ready_to_sync_count": 11, + "total_count": 14, + "last_published_at": "2025-05-02T20:20:44.989042Z" + }, + { + "upstream_context_title": "CS problems 2", + "upstream_context_key": "lib:OpenedX:CSPROB2", + "ready_to_sync_count": 15, + "total_count": 24, + "last_published_at": "2025-05-03T21:20:44.989042Z" + }, + ] + """ + result = cls.filter_links(downstream_context_key=downstream_context_key).values( + "upstream_context_key", + upstream_context_title=F("upstream_block__publishable_entity__learning_package__title"), + ).annotate( + ready_to_sync_count=Count("id", Q(ready_to_sync=True)), + total_count=Count("id"), + last_published_at=Max( + "upstream_block__publishable_entity__published__publish_log_record__publish_log__published_at" + ) + ) + return result + + @classmethod + def update_or_create( + cls, + upstream_block: Component | None, + /, + upstream_usage_key: UsageKey, + upstream_context_key: str, + downstream_usage_key: UsageKey, + downstream_context_key: CourseKey, + version_synced: int, + version_declined: int | None = None, + created: datetime | None = None, + ) -> "ComponentLink": + """ + Update or create entity link. This will only update `updated` field if something has changed. + """ + if not created: + created = datetime.now(tz=timezone.utc) + new_values = { + 'upstream_usage_key': upstream_usage_key, + 'upstream_context_key': upstream_context_key, + 'downstream_usage_key': downstream_usage_key, + 'downstream_context_key': downstream_context_key, + 'version_synced': version_synced, + 'version_declined': version_declined, + } + if upstream_block: + new_values['upstream_block'] = upstream_block + try: + link = cls.objects.get(downstream_usage_key=downstream_usage_key) + has_changes = False + for key, new_value in new_values.items(): + prev_value = getattr(link, key) + if prev_value != new_value: + has_changes = True + setattr(link, key, new_value) + if has_changes: + link.updated = created + link.save() + except cls.DoesNotExist: + link = cls(**new_values) + link.created = created + link.updated = created + link.save() + return link + + +class ContainerLink(EntityLinkBase): + """ + This represents link between any two publishable entities or link between publishable entity and a course + xblock. It helps in tracking relationship between xblocks imported from libraries and used in different courses. + """ + upstream_container = models.ForeignKey( + Container, + on_delete=models.SET_NULL, + related_name="links", + null=True, + blank=True, + ) + upstream_container_key = ContainerKeyField( + max_length=255, + help_text=_( + "Upstream block key (e.g. lct:...), this value cannot be null " + "and is useful to track upstream library blocks that do not exist yet " + "or were deleted." + ) + ) + + class Meta: + verbose_name = _("Container Link") + verbose_name_plural = _("Container Links") + + def __str__(self): + return f"ContainerLink<{self.upstream_container_key}->{self.downstream_usage_key}>" + + @property + def upstream_version_num(self) -> int | None: + """ + Returns upstream container version number if available. + """ + published_version = get_published_version(self.upstream_container.publishable_entity.id) + return published_version.version_num if published_version else None + + @property + def upstream_context_title(self) -> str: + """ + Returns upstream context title. + """ + return self.upstream_container.publishable_entity.learning_package.title + + @classmethod + def filter_links( + cls, + **link_filter, + ) -> QuerySet["EntityLinkBase"]: + """ + Get all links along with sync flag, upstream context title and version, with optional filtering. + """ + ready_to_sync = link_filter.pop('ready_to_sync', None) + result = cls.objects.filter(**link_filter).select_related( + "upstream_container__publishable_entity__published__version", + "upstream_container__publishable_entity__learning_package", + "upstream_container__publishable_entity__published__publish_log_record__publish_log", + ).annotate( + ready_to_sync=( + GreaterThan( + Coalesce("upstream_container__publishable_entity__published__version__version_num", 0), + Coalesce("version_synced", 0) + ) & GreaterThan( + Coalesce("upstream_container__publishable_entity__published__version__version_num", 0), + Coalesce("version_declined", 0) + ) + ) + ) + if ready_to_sync is not None: + result = result.filter(ready_to_sync=ready_to_sync) + return result + + @classmethod + def summarize_by_downstream_context(cls, downstream_context_key: CourseKey) -> QuerySet: + """ + Returns a summary of links by upstream context for given downstream_context_key. + Example: + [ + { + "upstream_context_title": "CS problems 3", + "upstream_context_key": "lib:OpenedX:CSPROB3", + "ready_to_sync_count": 11, + "total_count": 14, + "last_published_at": "2025-05-02T20:20:44.989042Z" + }, + { + "upstream_context_title": "CS problems 2", + "upstream_context_key": "lib:OpenedX:CSPROB2", + "ready_to_sync_count": 15, + "total_count": 24, + "last_published_at": "2025-05-03T21:20:44.989042Z" + }, + ] + """ + result = cls.filter_links(downstream_context_key=downstream_context_key).values( + "upstream_context_key", + upstream_context_title=F("upstream_container__publishable_entity__learning_package__title"), + ).annotate( + ready_to_sync_count=Count("id", Q(ready_to_sync=True)), + total_count=Count('id'), + last_published_at=Max( + "upstream_container__publishable_entity__published__publish_log_record__publish_log__published_at" + ) + ) + return result + + @classmethod + def update_or_create( + cls, + upstream_container_id: int | None, + /, + upstream_container_key: LibraryContainerLocator, + upstream_context_key: str, + downstream_usage_key: UsageKey, + downstream_context_key: CourseKey, + version_synced: int, + version_declined: int | None = None, + created: datetime | None = None, + ) -> "ContainerLink": + """ + Update or create entity link. This will only update `updated` field if something has changed. + """ + if not created: + created = datetime.now(tz=timezone.utc) + new_values = { + 'upstream_container_key': upstream_container_key, + 'upstream_context_key': upstream_context_key, + 'downstream_usage_key': downstream_usage_key, + 'downstream_context_key': downstream_context_key, + 'version_synced': version_synced, + 'version_declined': version_declined, + } + if upstream_container_id: + new_values['upstream_container_id'] = upstream_container_id + try: + link = cls.objects.get(downstream_usage_key=downstream_usage_key) + has_changes = False + for key, new_value in new_values.items(): + prev_value = getattr(link, key) + if prev_value != new_value: + has_changes = True + setattr(link, key, new_value) + if has_changes: + link.updated = created + link.save() + except cls.DoesNotExist: + link = cls(**new_values) + link.created = created + link.updated = created + link.save() + return link + + +class LearningContextLinksStatusChoices(models.TextChoices): + """ + Enumerates the states that a LearningContextLinksStatus can be in. + """ + PENDING = "pending", _("Pending") + PROCESSING = "processing", _("Processing") + FAILED = "failed", _("Failed") + COMPLETED = "completed", _("Completed") + + +class LearningContextLinksStatus(models.Model): + """ + This table stores current processing status of upstream-downstream links in ComponentLink table for a + course or a learning context. + """ + context_key = CourseKeyField( + max_length=255, + # Single entry for a learning context or course + unique=True, + help_text=_("Linking status for course context key"), + ) + status = models.CharField( + max_length=20, + choices=LearningContextLinksStatusChoices.choices, + help_text=_("Status of links in given learning context/course."), + ) + created = manual_date_time_field() + updated = manual_date_time_field() + + class Meta: + verbose_name = _("Learning Context Links status") + verbose_name_plural = _("Learning Context Links status") + + def __str__(self): + return f"{self.status}|{self.context_key}" + + @classmethod + def get_or_create(cls, context_key: str, created: datetime | None = None) -> "LearningContextLinksStatus": + """ + Get or create course link status row from LearningContextLinksStatus table for given course key. + + Args: + context_key: Learning context or Course key + + Returns: + LearningContextLinksStatus object + """ + if not created: + created = datetime.now(tz=timezone.utc) + status, _ = cls.objects.get_or_create( + context_key=context_key, + defaults={ + 'status': LearningContextLinksStatusChoices.PENDING, + 'created': created, + 'updated': created, + }, + ) + return status + + def update_status( + self, + status: LearningContextLinksStatusChoices, + updated: datetime | None = None + ) -> None: + """ + Updates entity links processing status of given learning context. + """ + self.status = status + self.updated = updated or datetime.now(tz=timezone.utc) + self.save() diff --git a/cms/djangoapps/contentstore/rest_api/v0/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/__init__.py index 33931a4a19..171f746be4 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/__init__.py @@ -4,6 +4,7 @@ Serializers for v0 contentstore API. from .advanced_settings import AdvancedSettingsFieldSerializer, CourseAdvancedSettingsSerializer from .assets import AssetSerializer from .authoring_grading import CourseGradingModelSerializer +from .course_optimizer import LinkCheckSerializer from .tabs import CourseTabSerializer, CourseTabUpdateSerializer, TabIDLocatorSerializer from .transcripts import TranscriptSerializer, YoutubeTranscriptCheckSerializer, YoutubeTranscriptUploadSerializer from .xblock import XblockSerializer 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 e3dd070573..e42c3e2ee3 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/serializers/course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/course_optimizer.py new file mode 100644 index 0000000000..7411192d16 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/course_optimizer.py @@ -0,0 +1,49 @@ +""" +API Serializers for Course Optimizer +""" + +from rest_framework import serializers + + +class LinkCheckBlockSerializer(serializers.Serializer): + """ Serializer for broken links block model data """ + id = serializers.CharField(required=True, allow_null=False, allow_blank=False) + displayName = serializers.CharField(required=True, allow_null=False, allow_blank=True) + url = serializers.CharField(required=True, allow_null=False, allow_blank=False) + brokenLinks = serializers.ListField(required=False) + lockedLinks = serializers.ListField(required=False) + externalForbiddenLinks = serializers.ListField(required=False) + + +class LinkCheckUnitSerializer(serializers.Serializer): + """ Serializer for broken links unit model data """ + id = serializers.CharField(required=True, allow_null=False, allow_blank=False) + displayName = serializers.CharField(required=True, allow_null=False, allow_blank=True) + blocks = LinkCheckBlockSerializer(many=True) + + +class LinkCheckSubsectionSerializer(serializers.Serializer): + """ Serializer for broken links subsection model data """ + id = serializers.CharField(required=True, allow_null=False, allow_blank=False) + displayName = serializers.CharField(required=True, allow_null=False, allow_blank=True) + units = LinkCheckUnitSerializer(many=True) + + +class LinkCheckSectionSerializer(serializers.Serializer): + """ Serializer for broken links section model data """ + id = serializers.CharField(required=True, allow_null=False, allow_blank=False) + displayName = serializers.CharField(required=True, allow_null=False, allow_blank=True) + subsections = LinkCheckSubsectionSerializer(many=True) + + +class LinkCheckOutputSerializer(serializers.Serializer): + """ Serializer for broken links output model data """ + sections = LinkCheckSectionSerializer(many=True) + + +class LinkCheckSerializer(serializers.Serializer): + """ Serializer for broken links """ + LinkCheckStatus = serializers.CharField(required=True) + LinkCheckCreatedAt = serializers.DateTimeField(required=False) + LinkCheckOutput = LinkCheckOutputSerializer(required=False) + LinkCheckError = serializers.CharField(required=False) diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_advanced_settings.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_advanced_settings.py index a87421a08c..765246258b 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/tests/test_advanced_settings.py +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_advanced_settings.py @@ -6,14 +6,11 @@ import json import ddt from django.test import override_settings from django.urls import reverse -from edx_toggles.toggles.testutils import override_waffle_flag from milestones.tests.utils import MilestonesTestCaseMixin from cms.djangoapps.contentstore.tests.utils import CourseTestCase -from cms.djangoapps.contentstore.toggles import ENABLE_NEW_STUDIO_ADVANCED_SETTINGS_PAGE -@override_waffle_flag(ENABLE_NEW_STUDIO_ADVANCED_SETTINGS_PAGE, active=True) @ddt.ddt class CourseAdvanceSettingViewTest(CourseTestCase, MilestonesTestCaseMixin): """ diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_assets.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_assets.py index 46b636916d..3772dbb64e 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/tests/test_assets.py +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_assets.py @@ -60,13 +60,8 @@ class AssetsViewTestCase(AuthorizeStaffTestCase): } ), ) - @patch( - f"cms.djangoapps.contentstore.rest_api.{VERSION}.views.xblock.toggles.use_studio_content_api", - return_value=True, - ) def make_request( self, - mock_use_studio_content_api, mock_handle_assets, run_assertions=None, course_id=None, @@ -125,13 +120,6 @@ class AssetsViewGetTest(AssetsViewTestCase, ModuleStoreTestCase, APITestCase): def send_request(self, url, data): return self.client.get(url) - def test_api_behind_feature_flag(self): - # should return 404 if the feature flag is not enabled - url = self.get_url() - - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_assets_handler_called_with_correct_arguments(self): self.client.login( username=self.course_instructor.username, password=self.password @@ -182,13 +170,6 @@ class AssetsViewPostTest(AssetsViewTestCase, ModuleStoreTestCase, APITestCase): def send_request(self, url, data): return self.client.post(url, data=data, format="multipart") - def test_api_behind_feature_flag(self): - # should return 404 if the feature flag is not enabled - url = self.get_url() - - response = self.client.post(url) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_assets_handler_called_with_correct_arguments(self): self.client.login( username=self.course_instructor.username, password=self.password @@ -232,13 +213,6 @@ class AssetsViewPutTest(AssetsViewTestCase, ModuleStoreTestCase, APITestCase): def send_request(self, url, data): return self.client.put(url, data=data, format="json") - def test_api_behind_feature_flag(self): - # should return 404 if the feature flag is not enabled - url = self.get_url() - - response = self.client.put(url) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_assets_handler_called_with_correct_arguments(self): self.client.login( username=self.course_instructor.username, password=self.password @@ -277,13 +251,6 @@ class AssetsViewDeleteTest(AssetsViewTestCase, ModuleStoreTestCase, APITestCase) def send_request(self, url, data): return self.client.delete(url) - def test_api_behind_feature_flag(self): - # should return 404 if the feature flag is not enabled - url = self.get_url() - - response = self.client.delete(url) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_assets_handler_called_with_correct_arguments(self): self.client.login( username=self.course_instructor.username, password=self.password diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py new file mode 100644 index 0000000000..14d5a20fb4 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py @@ -0,0 +1,79 @@ +""" +Unit tests for course optimizer +""" +from django.test import TestCase +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from django.urls import reverse + +from cms.djangoapps.contentstore.tests.test_utils import AuthorizeStaffTestCase + + +class TestGetLinkCheckStatus(AuthorizeStaffTestCase, ModuleStoreTestCase, TestCase): + ''' + Authentication and Authorization Tests for CourseOptimizer. + For concrete tests that are run, check `AuthorizeStaffTestCase`. + ''' + def make_request(self, course_id=None, data=None, **kwargs): + url = self.get_url(self.course.id) + response = self.client.get(url, data) + return response + + def get_url(self, course_key): + url = reverse( + 'cms.djangoapps.contentstore:v0:link_check_status', + kwargs={'course_id': self.course.id} + ) + return url + + def test_produces_4xx_when_invalid_course_id(self): + ''' + Test course_id validation + ''' + response = self.make_request(course_id='invalid_course_id') + self.assertIn(response.status_code, range(400, 500)) + + def test_produces_4xx_when_additional_kwargs(self): + ''' + Test additional kwargs validation + ''' + response = self.make_request(course_id=self.course.id, malicious_kwarg='malicious_kwarg') + self.assertIn(response.status_code, range(400, 500)) + + +class TestPostLinkCheck(AuthorizeStaffTestCase, ModuleStoreTestCase, TestCase): + ''' + Authentication and Authorization Tests for CourseOptimizer. + For concrete tests that are run, check `AuthorizeStaffTestCase`. + ''' + def make_request(self, course_id=None, data=None, **kwargs): + url = self.get_url(self.course.id) + response = self.client.post(url, data) + return response + + def get_url(self, course_key): + url = reverse( + 'cms.djangoapps.contentstore:v0:link_check', + kwargs={'course_id': self.course.id} + ) + return url + + def test_produces_4xx_when_invalid_course_id(self): + ''' + Test course_id validation + ''' + response = self.make_request(course_id='invalid_course_id') + self.assertIn(response.status_code, range(400, 500)) + + def test_produces_4xx_when_additional_kwargs(self): + ''' + Test additional kwargs validation + ''' + response = self.make_request(course_id=self.course.id, malicious_kwarg='malicious_kwarg') + self.assertIn(response.status_code, range(400, 500)) + + def test_produces_4xx_when_unexpected_data(self): + ''' + Test validation when request contains unexpected data + ''' + response = self.make_request(course_id=self.course.id, data={'unexpected_data': 'unexpected_data'}) + self.assertIn(response.status_code, range(400, 500)) diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs.py index 5da8938935..5e83c93136 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs.py +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_tabs.py @@ -8,15 +8,12 @@ from urllib.parse import urlencode import ddt from django.urls import reverse -from edx_toggles.toggles.testutils import override_waffle_flag from xmodule.modulestore.tests.factories import BlockFactory from xmodule.tabs import CourseTabList from cms.djangoapps.contentstore.tests.utils import CourseTestCase -from cms.djangoapps.contentstore.toggles import ENABLE_NEW_STUDIO_CUSTOM_PAGES -@override_waffle_flag(ENABLE_NEW_STUDIO_CUSTOM_PAGES, active=True) @ddt.ddt class TabsAPITests(CourseTestCase): """ diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_xblock.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_xblock.py index e4c21eb353..512c92f6ff 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/tests/test_xblock.py +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_xblock.py @@ -55,13 +55,8 @@ class XBlockViewTestCase(AuthorizeStaffTestCase): } ), ) - @patch( - f"cms.djangoapps.contentstore.rest_api.{VERSION}.views.xblock.toggles.use_studio_content_api", - return_value=True, - ) def make_request( self, - mock_use_studio_content_api, mock_handle_xblock, run_assertions=None, course_id=None, @@ -111,13 +106,6 @@ class XBlockViewGetTest(XBlockViewTestCase, ModuleStoreTestCase, APITestCase): def send_request(self, url, data): return self.client.get(url) - def test_api_behind_feature_flag(self): - # should return 404 if the feature flag is not enabled - url = self.get_url() - - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_xblock_handler_called_with_correct_arguments(self): self.client.login( username=self.course_instructor.username, password=self.password @@ -167,13 +155,6 @@ class XBlockViewPostTest(XBlockViewTestCase, ModuleStoreTestCase, APITestCase): def send_request(self, url, data): return self.client.post(url, data=data, format="json") - def test_api_behind_feature_flag(self): - # should return 404 if the feature flag is not enabled - url = self.get_url() - - response = self.client.post(url) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_xblock_handler_called_with_correct_arguments(self): self.client.login( username=self.course_instructor.username, password=self.password @@ -218,13 +199,6 @@ class XBlockViewPutTest(XBlockViewTestCase, ModuleStoreTestCase, APITestCase): def send_request(self, url, data): return self.client.put(url, data=data, format="json") - def test_api_behind_feature_flag(self): - # should return 404 if the feature flag is not enabled - url = self.get_url() - - response = self.client.put(url) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_xblock_handler_called_with_correct_arguments(self): self.client.login( username=self.course_instructor.username, password=self.password @@ -269,13 +243,6 @@ class XBlockViewPatchTest(XBlockViewTestCase, ModuleStoreTestCase, APITestCase): def send_request(self, url, data): return self.client.patch(url, data=data, format="json") - def test_api_behind_feature_flag(self): - # should return 404 if the feature flag is not enabled - url = self.get_url() - - response = self.client.patch(url) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_xblock_handler_called_with_correct_arguments(self): self.client.login( username=self.course_instructor.username, password=self.password @@ -310,13 +277,6 @@ class XBlockViewDeleteTest(XBlockViewTestCase, ModuleStoreTestCase, APITestCase) def send_request(self, url, data): return self.client.delete(url) - def test_api_behind_feature_flag(self): - # should return 404 if the feature flag is not enabled - url = self.get_url() - - response = self.client.delete(url) - self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) - def test_xblock_handler_called_with_correct_arguments(self): self.client.login( username=self.course_instructor.username, password=self.password diff --git a/cms/djangoapps/contentstore/rest_api/v0/urls.py b/cms/djangoapps/contentstore/rest_api/v0/urls.py index cc1e13b092..9d7006a708 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v0/urls.py @@ -7,14 +7,16 @@ from openedx.core.constants import COURSE_ID_PATTERN from .views import ( AdvancedCourseSettingsView, + APIHeartBeatView, AuthoringGradingView, CourseTabSettingsView, CourseTabListView, CourseTabReorderView, + LinkCheckView, + LinkCheckStatusView, TranscriptView, YoutubeTranscriptCheckView, YoutubeTranscriptUploadView, - APIHeartBeatView ) from .views import assets from .views import authoring_videos @@ -63,7 +65,7 @@ urlpatterns = [ 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( @@ -102,4 +104,14 @@ urlpatterns = [ fr'^youtube_transcripts/{settings.COURSE_ID_PATTERN}/upload?$', YoutubeTranscriptUploadView.as_view(), name='cms_api_youtube_transcripts_upload' ), + + # Course Optimizer + re_path( + fr'^link_check/{settings.COURSE_ID_PATTERN}$', + LinkCheckView.as_view(), name='link_check' + ), + re_path( + fr'^link_check_status/{settings.COURSE_ID_PATTERN}$', + LinkCheckStatusView.as_view(), name='link_check_status' + ), ] diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py index 00d22a1ea7..2ce3ea22ea 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py @@ -2,7 +2,8 @@ Views for v0 contentstore API. """ from .advanced_settings import AdvancedCourseSettingsView +from .api_heartbeat import APIHeartBeatView from .authoring_grading import AuthoringGradingView +from .course_optimizer import LinkCheckView, LinkCheckStatusView from .tabs import CourseTabSettingsView, CourseTabListView, CourseTabReorderView from .transcripts import TranscriptView, YoutubeTranscriptCheckView, YoutubeTranscriptUploadView -from .api_heartbeat import APIHeartBeatView diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/api_heartbeat.py b/cms/djangoapps/contentstore/rest_api/v0/views/api_heartbeat.py index f8b539557e..f322f23609 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/api_heartbeat.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/api_heartbeat.py @@ -5,7 +5,6 @@ from rest_framework.request import Request from rest_framework.response import Response from rest_framework import status from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes -import cms.djangoapps.contentstore.toggles as toggles class APIHeartBeatView(DeveloperErrorViewMixin, APIView): @@ -43,6 +42,4 @@ class APIHeartBeatView(DeveloperErrorViewMixin, APIView): } ``` """ - if toggles.use_studio_content_api(): - return Response({'status': 'heartbeat successful'}, status=status.HTTP_200_OK) - return Response(status=status.HTTP_403_FORBIDDEN) + return Response({'status': 'heartbeat successful'}, status=status.HTTP_200_OK) diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/assets.py b/cms/djangoapps/contentstore/rest_api/v0/views/assets.py index 0c0c24aeab..a7a4952565 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/assets.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/assets.py @@ -4,7 +4,6 @@ Public rest API endpoints for the CMS API Assets. import logging from rest_framework.generics import CreateAPIView, RetrieveAPIView, UpdateAPIView, DestroyAPIView from django.views.decorators.csrf import csrf_exempt -from django.http import Http404 from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes from common.djangoapps.util.json_request import expect_json_in_class_view @@ -12,7 +11,6 @@ from common.djangoapps.util.json_request import expect_json_in_class_view from cms.djangoapps.contentstore.api import course_author_access_required from cms.djangoapps.contentstore.asset_storage_handlers import handle_assets -import cms.djangoapps.contentstore.toggles as contentstore_toggles from ..serializers.assets import AssetSerializer from .utils import validate_request_with_serializer @@ -20,7 +18,6 @@ from rest_framework.parsers import (MultiPartParser, FormParser, JSONParser) from openedx.core.lib.api.parsers import TypedFileUploadParser log = logging.getLogger(__name__) -toggles = contentstore_toggles @view_auth_classes() @@ -33,17 +30,6 @@ class AssetsCreateRetrieveView(DeveloperErrorViewMixin, CreateAPIView, RetrieveA serializer_class = AssetSerializer parser_classes = (JSONParser, MultiPartParser, FormParser, TypedFileUploadParser) - def dispatch(self, request, *args, **kwargs): - # TODO: probably want to refactor this to a decorator. - """ - The dispatch method of a View class handles HTTP requests in general - and calls other methods to handle specific HTTP methods. - We use this to raise a 404 if the content api is disabled. - """ - if not toggles.use_studio_content_api(): - raise Http404 - return super().dispatch(request, *args, **kwargs) - @csrf_exempt @course_author_access_required @validate_request_with_serializer @@ -66,17 +52,6 @@ class AssetsUpdateDestroyView(DeveloperErrorViewMixin, UpdateAPIView, DestroyAPI serializer_class = AssetSerializer parser_classes = (JSONParser, MultiPartParser, FormParser, TypedFileUploadParser) - def dispatch(self, request, *args, **kwargs): - # TODO: probably want to refactor this to a decorator. - """ - The dispatch method of a View class handles HTTP requests in general - and calls other methods to handle specific HTTP methods. - We use this to raise a 404 if the content api is disabled. - """ - if not toggles.use_studio_content_api(): - raise Http404 - return super().dispatch(request, *args, **kwargs) - @course_author_access_required @expect_json_in_class_view @validate_request_with_serializer 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 972b6229f5..f97bf7a98d 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/authoring_videos.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/authoring_videos.py @@ -9,7 +9,6 @@ from rest_framework.generics import ( ) from rest_framework.parsers import (MultiPartParser, FormParser) from django.views.decorators.csrf import csrf_exempt -from django.http import Http404 from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes from openedx.core.lib.api.parsers import TypedFileUploadParser @@ -27,12 +26,10 @@ from cms.djangoapps.contentstore.rest_api.v1.serializers import ( VideoUploadSerializer, VideoImageSerializer, ) -import cms.djangoapps.contentstore.toggles as contentstore_toggles from .utils import validate_request_with_serializer log = logging.getLogger(__name__) -toggles = contentstore_toggles @view_auth_classes() @@ -44,17 +41,6 @@ class VideosUploadsView(DeveloperErrorViewMixin, RetrieveAPIView, DestroyAPIView """ serializer_class = VideoUploadSerializer - def dispatch(self, request, *args, **kwargs): - # TODO: probably want to refactor this to a decorator. - """ - The dispatch method of a View class handles HTTP requests in general - and calls other methods to handle specific HTTP methods. - We use this to raise a 404 if the content api is disabled. - """ - if not toggles.use_studio_content_api(): - raise Http404 - return super().dispatch(request, *args, **kwargs) - @course_author_access_required def retrieve(self, request, course_key, edx_video_id=None): # pylint: disable=arguments-differ return handle_videos(request, course_key.html_id(), edx_video_id) @@ -73,17 +59,6 @@ class VideosCreateUploadView(DeveloperErrorViewMixin, CreateAPIView): """ serializer_class = VideoUploadSerializer - def dispatch(self, request, *args, **kwargs): - # TODO: probably want to refactor this to a decorator. - """ - The dispatch method of a View class handles HTTP requests in general - and calls other methods to handle specific HTTP methods. - We use this to raise a 404 if the content api is disabled. - """ - if not toggles.use_studio_content_api(): - raise Http404 - return super().dispatch(request, *args, **kwargs) - @csrf_exempt @course_author_access_required @expect_json_in_class_view @@ -102,17 +77,6 @@ class VideoImagesView(DeveloperErrorViewMixin, CreateAPIView): serializer_class = VideoImageSerializer parser_classes = (MultiPartParser, FormParser, TypedFileUploadParser) - def dispatch(self, request, *args, **kwargs): - # TODO: probably want to refactor this to a decorator. - """ - The dispatch method of a View class handles HTTP requests in general - and calls other methods to handle specific HTTP methods. - We use this to raise a 404 if the content api is disabled. - """ - if not toggles.use_studio_content_api(): - raise Http404 - return super().dispatch(request, *args, **kwargs) - @csrf_exempt @course_author_access_required @expect_json_in_class_view @@ -128,16 +92,10 @@ class VideoEncodingsDownloadView(DeveloperErrorViewMixin, RetrieveAPIView): course_key: required argument, needed to authorize course authors and identify relevant videos. """ - def dispatch(self, request, *args, **kwargs): - # TODO: probably want to refactor this to a decorator. - """ - The dispatch method of a View class handles HTTP requests in general - and calls other methods to handle specific HTTP methods. - We use this to raise a 404 if the content api is disabled. - """ - if not toggles.use_studio_content_api(): - raise Http404 - return super().dispatch(request, *args, **kwargs) + # TODO: ARCH-91 + # This view is excluded from Swagger doc generation because it + # does not specify a serializer class. + swagger_schema = None @csrf_exempt @course_author_access_required @@ -151,16 +109,10 @@ class VideoFeaturesView(DeveloperErrorViewMixin, RetrieveAPIView): public rest API endpoint providing a list of enabled video features. """ - def dispatch(self, request, *args, **kwargs): - # TODO: probably want to refactor this to a decorator. - """ - The dispatch method of a View class handles HTTP requests in general - and calls other methods to handle specific HTTP methods. - We use this to raise a 404 if the content api is disabled. - """ - if not toggles.use_studio_content_api(): - raise Http404 - return super().dispatch(request, *args, **kwargs) + # TODO: ARCH-91 + # This view is excluded from Swagger doc generation because it + # does not specify a serializer class. + swagger_schema = None @csrf_exempt def retrieve(self, request): # pylint: disable=arguments-differ diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py new file mode 100644 index 0000000000..24c8dd0d18 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py @@ -0,0 +1,145 @@ +""" API Views for Course Optimizer. """ +import edx_api_doc_tools as apidocs +from opaque_keys.edx.keys import CourseKey +from rest_framework.views import APIView +from rest_framework.request import Request +from rest_framework.response import Response +from user_tasks.models import UserTaskStatus + +from cms.djangoapps.contentstore.core.course_optimizer_provider import get_link_check_data, sort_course_sections +from cms.djangoapps.contentstore.rest_api.v0.serializers.course_optimizer import LinkCheckSerializer +from cms.djangoapps.contentstore.tasks import check_broken_links +from common.djangoapps.student.auth import has_course_author_access, has_studio_read_access +from common.djangoapps.util.json_request import JsonResponse +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes + + +@view_auth_classes(is_authenticated=True) +class LinkCheckView(DeveloperErrorViewMixin, APIView): + """ + View for queueing a celery task to scan a course for broken links. + """ + @apidocs.schema( + parameters=[ + apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + ], + responses={ + 200: "Celery task queued.", + 401: "The requester is not authenticated.", + 403: "The requester cannot access the specified course.", + 404: "The requested course does not exist.", + }, + ) + @verify_course_exists() + def post(self, request: Request, course_id: str): + """ + Queue celery task to scan a course for broken links. + + **Example Request** + POST /api/contentstore/v0/link_check/{course_id} + + **Response Values** + ```json + { + "LinkCheckStatus": "Pending" + } + """ + course_key = CourseKey.from_string(course_id) + + if not has_studio_read_access(request.user, course_key): + self.permission_denied(request) + + check_broken_links.delay(request.user.id, course_id, request.LANGUAGE_CODE) + return JsonResponse({'LinkCheckStatus': UserTaskStatus.PENDING}) + + +@view_auth_classes() +class LinkCheckStatusView(DeveloperErrorViewMixin, APIView): + """ + View for checking the status of the celery task and returning the results. + """ + @apidocs.schema( + parameters=[ + apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + ], + responses={ + 200: "OK", + 401: "The requester is not authenticated.", + 403: "The requester cannot access the specified course.", + 404: "The requested course does not exist.", + }, + ) + def get(self, request: Request, course_id: str): + """ + GET handler to return the status of the link_check task from UserTaskStatus. + If no task has been started for the course, return 'Uninitiated'. + If link_check task was successful, an output result is also returned. + + For reference, the following status are in UserTaskStatus: + 'Pending', 'In Progress' (sent to frontend as 'In-Progress'), + 'Succeeded', 'Failed', 'Canceled', 'Retrying' + This function adds a status for when status from UserTaskStatus is None: + 'Uninitiated' + + **Example Request** + GET /api/contentstore/v0/link_check_status/{course_id} + + **Example Response** + ```json + { + "LinkCheckStatus": "Succeeded", + "LinkCheckCreatedAt": "2025-02-05T14:32:01.294587Z", + "LinkCheckOutput": { + sections: [ + { + id: , + displayName: , + subsections: [ + { + id: , + displayName: , + units: [ + { + id: , + displayName: , + blocks: [ + { + id: , + url: , + brokenLinks: [ + , + , + , + ..., + ], + lockedLinks: [ + , + , + , + ..., + ], + }, + { }, + ], + }, + { }, + ], + }, + { }, + ], + }, + } + """ + course_key = CourseKey.from_string(course_id) + if not has_course_author_access(request.user, course_key): + print('missing course author access') + self.permission_denied(request) + + data = get_link_check_data(request, course_id) + data = sort_course_sections(course_key, data) + + serializer = LinkCheckSerializer(data) + return Response(serializer.data) diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/transcripts.py b/cms/djangoapps/contentstore/rest_api/v0/views/transcripts.py index 9a63693e12..3344fdbd1f 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/transcripts.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/transcripts.py @@ -8,7 +8,6 @@ from rest_framework.generics import ( DestroyAPIView ) from django.views.decorators.csrf import csrf_exempt -from django.http import Http404 from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes from common.djangoapps.util.json_request import expect_json_in_class_view @@ -20,7 +19,6 @@ from cms.djangoapps.contentstore.transcript_storage_handlers import ( delete_video_transcript_or_404, handle_transcript_download, ) -import cms.djangoapps.contentstore.toggles as contentstore_toggles from ..serializers import TranscriptSerializer, YoutubeTranscriptCheckSerializer, YoutubeTranscriptUploadSerializer from rest_framework.parsers import (MultiPartParser, FormParser) from openedx.core.lib.api.parsers import TypedFileUploadParser @@ -28,7 +26,6 @@ from openedx.core.lib.api.parsers import TypedFileUploadParser from cms.djangoapps.contentstore.rest_api.v0.views.utils import validate_request_with_serializer log = logging.getLogger(__name__) -toggles = contentstore_toggles @view_auth_classes() @@ -42,11 +39,6 @@ class TranscriptView(DeveloperErrorViewMixin, CreateAPIView, RetrieveAPIView, De serializer_class = TranscriptSerializer parser_classes = (MultiPartParser, FormParser, TypedFileUploadParser) - def dispatch(self, request, *args, **kwargs): - if not toggles.use_studio_content_api(): - raise Http404 - return super().dispatch(request, *args, **kwargs) - @csrf_exempt @course_author_access_required @expect_json_in_class_view @@ -81,11 +73,6 @@ class YoutubeTranscriptCheckView(DeveloperErrorViewMixin, RetrieveAPIView): serializer_class = YoutubeTranscriptCheckSerializer parser_classes = (MultiPartParser, FormParser, TypedFileUploadParser) - def dispatch(self, request, *args, **kwargs): - if not toggles.use_studio_content_api(): - raise Http404 - return super().dispatch(request, *args, **kwargs) - @course_author_access_required def retrieve(self, request, course_key_string): # pylint: disable=arguments-differ """ @@ -104,11 +91,6 @@ class YoutubeTranscriptUploadView(DeveloperErrorViewMixin, RetrieveAPIView): serializer_class = YoutubeTranscriptUploadSerializer parser_classes = (MultiPartParser, FormParser, TypedFileUploadParser) - def dispatch(self, request, *args, **kwargs): - if not toggles.use_studio_content_api(): - raise Http404 - return super().dispatch(request, *args, **kwargs) - @course_author_access_required def retrieve(self, request, course_key_string): # pylint: disable=arguments-differ """ diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/xblock.py b/cms/djangoapps/contentstore/rest_api/v0/views/xblock.py index cc26619fb8..8e678ae845 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/xblock.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/xblock.py @@ -4,21 +4,18 @@ Public rest API endpoints for the CMS API. import logging from rest_framework.generics import RetrieveUpdateDestroyAPIView, CreateAPIView from django.views.decorators.csrf import csrf_exempt -from django.http import Http404 from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes from common.djangoapps.util.json_request import expect_json_in_class_view from cms.djangoapps.contentstore.api import course_author_access_required from cms.djangoapps.contentstore.xblock_storage_handlers import view_handlers -import cms.djangoapps.contentstore.toggles as contentstore_toggles from ..serializers import XblockSerializer from .utils import validate_request_with_serializer log = logging.getLogger(__name__) -toggles = contentstore_toggles handle_xblock = view_handlers.handle_xblock @@ -32,17 +29,6 @@ class XblockView(DeveloperErrorViewMixin, RetrieveUpdateDestroyAPIView): """ serializer_class = XblockSerializer - def dispatch(self, request, *args, **kwargs): - # TODO: probably want to refactor this to a decorator. - """ - The dispatch method of a View class handles HTTP requests in general - and calls other methods to handle specific HTTP methods. - We use this to raise a 404 if the content api is disabled. - """ - if not toggles.use_studio_content_api(): - raise Http404 - return super().dispatch(request, *args, **kwargs) - # pylint: disable=arguments-differ @course_author_access_required @expect_json_in_class_view @@ -77,17 +63,6 @@ class XblockCreateView(DeveloperErrorViewMixin, CreateAPIView): """ serializer_class = XblockSerializer - def dispatch(self, request, *args, **kwargs): - # TODO: probably want to refactor this to a decorator. - """ - The dispatch method of a View class handles HTTP requests in general - and calls other methods to handle specific HTTP methods. - We use this to raise a 404 if the content api is disabled. - """ - if not toggles.use_studio_content_api(): - raise Http404 - return super().dispatch(request, *args, **kwargs) - # pylint: disable=arguments-differ @csrf_exempt @course_author_access_required diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_index.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_index.py index 29577d9a75..27b7ed8576 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_index.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_index.py @@ -32,3 +32,4 @@ class CourseIndexSerializer(serializers.Serializer): rerun_notification_id = serializers.IntegerField() advance_settings_url = serializers.CharField() is_custom_relative_dates_active = serializers.BooleanField() + created_on = serializers.DateTimeField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py index b372542346..dca8e25cb4 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py @@ -27,6 +27,9 @@ class CourseWaffleFlagsSerializer(serializers.Serializer): use_new_certificates_page = serializers.SerializerMethodField() use_new_textbooks_page = serializers.SerializerMethodField() use_new_group_configurations_page = serializers.SerializerMethodField() + enable_course_optimizer = serializers.SerializerMethodField() + use_react_markdown_editor = serializers.SerializerMethodField() + use_video_gallery_flow = serializers.SerializerMethodField() def get_course_key(self): """ @@ -144,3 +147,23 @@ class CourseWaffleFlagsSerializer(serializers.Serializer): """ course_key = self.get_course_key() return toggles.use_new_group_configurations_page(course_key) + + def get_enable_course_optimizer(self, obj): + """ + Method to get the enable_course_optimizer waffle flag + """ + course_key = self.get_course_key() + return toggles.enable_course_optimizer(course_key) + + def get_use_react_markdown_editor(self, obj): + """ + Method to get the use_react_markdown_editor waffle flag + """ + course_key = self.get_course_key() + return toggles.use_react_markdown_editor(course_key) + + def get_use_video_gallery_flow(self, obj): + """ + Method to get the use_video_gallery_flow waffle flag + """ + return toggles.use_video_gallery_flow() diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py index a81d391b3f..fdc06e9291 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py @@ -50,6 +50,10 @@ class StudioHomeSerializer(serializers.Serializer): child=serializers.CharField(), allow_empty=True ) + allowed_organizations_for_libraries = serializers.ListSerializer( + child=serializers.CharField(), + allow_empty=True + ) archived_courses = CourseCommonSerializer(required=False, many=True) can_access_advanced_settings = serializers.BooleanField() can_create_organizations = serializers.BooleanField() @@ -62,10 +66,10 @@ class StudioHomeSerializer(serializers.Serializer): libraries_v2_enabled = serializers.BooleanField() taxonomies_enabled = serializers.BooleanField() taxonomy_list_mfe_url = serializers.CharField() - optimization_enabled = serializers.BooleanField() request_course_creator_url = serializers.CharField() rerun_creator_status = serializers.BooleanField() show_new_library_button = serializers.BooleanField() + show_new_library_v2_button = serializers.BooleanField() split_studio_home = serializers.BooleanField() studio_name = serializers.CharField() studio_short_name = serializers.CharField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_waffle_flags.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_waffle_flags.py index ba96ff2b4a..69b2898912 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/course_waffle_flags.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_waffle_flags.py @@ -61,7 +61,9 @@ class CourseWaffleFlagsView(APIView): "use_new_course_team_page": true, "use_new_certificates_page": true, "use_new_textbooks_page": true, - "use_new_group_configurations_page": true + "use_new_group_configurations_page": true, + "use_react_markdown_editor": true, + "use_video_gallery_flow": true } ``` """ diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/home.py b/cms/djangoapps/contentstore/rest_api/v1/views/home.py index 06433d9f42..62b5653387 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/home.py @@ -5,6 +5,7 @@ from django.conf import settings from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from organizations import api as org_api from openedx.core.lib.api.view_utils import view_auth_classes from ....utils import get_home_context, get_course_context, get_library_context @@ -51,6 +52,7 @@ class HomePageView(APIView): "allow_to_create_new_org": true, "allow_unicode_course_id": false, "allowed_organizations": [], + "allowed_organizations_for_libraries": [], "archived_courses": [], "can_access_advanced_settings": true, "can_create_organizations": true, @@ -62,10 +64,10 @@ class HomePageView(APIView): "libraries_v1_enabled": true, "libraries_v2_enabled": true, "library_authoring_mfe_url": "//localhost:3001/course/course-v1:edX+P315+2T2023", - "optimization_enabled": true, "request_course_creator_url": "/request_course_creator", "rerun_creator_status": true, "show_new_library_button": true, + "show_new_library_v2_button": true, "split_studio_home": false, "studio_name": "Studio", "studio_short_name": "Studio", @@ -79,7 +81,12 @@ class HomePageView(APIView): home_context = get_home_context(request, True) home_context.update({ - 'allow_to_create_new_org': settings.FEATURES.get('ENABLE_CREATOR_GROUP', True) and request.user.is_staff, + # 'allow_to_create_new_org' is actually about auto-creating organizations + # (e.g. when creating a course or library), so we add an additional test. + 'allow_to_create_new_org': ( + home_context['can_create_organizations'] and + org_api.is_autocreate_enabled() + ), 'studio_name': settings.STUDIO_NAME, 'studio_short_name': settings.STUDIO_SHORT_NAME, 'studio_request_email': settings.FEATURES.get('STUDIO_REQUEST_EMAIL', ''), diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py index 189f2496a4..310d0ba80a 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py @@ -22,6 +22,7 @@ class CourseIndexViewTest(CourseTestCase, PermissionAccessMixin): """ Tests for CourseIndexView. """ + maxDiff = None # Show the entire dictionary in the diff def setUp(self): super().setUp() @@ -74,7 +75,10 @@ class CourseIndexViewTest(CourseTestCase, PermissionAccessMixin): }, "language_code": "en", "lms_link": get_lms_link_for_item(self.course.location), - "mfe_proctored_exam_settings_url": "", + "mfe_proctored_exam_settings_url": ( + f"http://course-authoring-mfe/course/{self.course.id}" + "/pages-and-resources/proctoring/settings" + ), "notification_dismiss_url": None, "proctoring_errors": [], "reindex_link": f"/course/{self.course.id}/search_reindex", @@ -86,6 +90,7 @@ class CourseIndexViewTest(CourseTestCase, PermissionAccessMixin): 'discussion_configuration_url': f'{get_pages_and_resources_url(self.course.id)}/discussion/settings', }, "advance_settings_url": f"/settings/advanced/{self.course.id}", + 'created_on': None, } self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -121,7 +126,10 @@ class CourseIndexViewTest(CourseTestCase, PermissionAccessMixin): }, "language_code": "en", "lms_link": get_lms_link_for_item(self.course.location), - "mfe_proctored_exam_settings_url": "", + "mfe_proctored_exam_settings_url": ( + f"http://course-authoring-mfe/course/{self.course.id}" + "/pages-and-resources/proctoring/settings" + ), "notification_dismiss_url": None, "proctoring_errors": [], "reindex_link": f"/course/{self.course.id}/search_reindex", @@ -133,6 +141,7 @@ class CourseIndexViewTest(CourseTestCase, PermissionAccessMixin): 'discussion_configuration_url': f'{get_pages_and_resources_url(self.course.id)}/discussion/settings', }, "advance_settings_url": f"/settings/advanced/{self.course.id}", + 'created_on': None, } self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -151,6 +160,6 @@ class CourseIndexViewTest(CourseTestCase, PermissionAccessMixin): """ Test to check number of queries made to mysql and mongo """ - with self.assertNumQueries(32, table_ignorelist=WAFFLE_TABLES): + with self.assertNumQueries(34, table_ignorelist=WAFFLE_TABLES): with check_mongo_calls(3): self.client.get(self.url) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py index 0a713fb81c..ad5696834a 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py @@ -1,112 +1,62 @@ """ Unit tests for the course waffle flags view """ - -from django.contrib.auth import get_user_model from django.urls import reverse -from rest_framework import status +from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.utils import CourseTestCase from openedx.core.djangoapps.waffle_utils.models import WaffleFlagCourseOverrideModel -User = get_user_model() - class CourseWaffleFlagsViewTest(CourseTestCase): """ - Tests for the CourseWaffleFlagsView endpoint, which returns waffle flag states + Basic test for the CourseWaffleFlagsView endpoint, which returns waffle flag states for a specific course or globally if no course ID is provided. """ + maxDiff = None # Show the whole dictionary in the diff - course_waffle_flags = [ - "use_new_custom_pages", - "use_new_schedule_details_page", - "use_new_advanced_settings_page", - "use_new_grading_page", - "use_new_updates_page", - "use_new_import_page", - "use_new_export_page", - "use_new_files_uploads_page", - "use_new_video_uploads_page", - "use_new_course_outline_page", - "use_new_unit_page", - "use_new_course_team_page", - "use_new_certificates_page", - "use_new_textbooks_page", - "use_new_group_configurations_page", - ] + defaults = { + 'enable_course_optimizer': False, + 'use_new_advanced_settings_page': True, + 'use_new_certificates_page': True, + 'use_new_course_outline_page': True, + 'use_new_course_team_page': True, + 'use_new_custom_pages': True, + 'use_new_export_page': True, + 'use_new_files_uploads_page': True, + 'use_new_grading_page': True, + 'use_new_group_configurations_page': True, + 'use_new_home_page': True, + 'use_new_import_page': True, + 'use_new_schedule_details_page': True, + 'use_new_textbooks_page': True, + 'use_new_unit_page': True, + 'use_new_updates_page': True, + 'use_new_video_uploads_page': False, + 'use_react_markdown_editor': False, + 'use_video_gallery_flow': False, + } def setUp(self): - """ - Set up test data and state before each test method. - - This method initializes the endpoint URL and creates a set of waffle flags - for the test course, setting each flag's value to `True`. - """ super().setUp() - self.url = reverse("cms.djangoapps.contentstore:v1:course_waffle_flags") - self.create_waffle_flags(self.course_waffle_flags) + WaffleFlagCourseOverrideModel.objects.create( + waffle_flag=toggles.ENABLE_COURSE_OPTIMIZER.name, + course_id=self.course.id, + enabled=True, + ) - def create_waffle_flags(self, flags, enabled=True): - """ - Helper method to create waffle flag entries in the database for the test course. + def test_global_defaults(self): + url = reverse("cms.djangoapps.contentstore:v1:course_waffle_flags") + response = self.client.get(url) + assert response.data == self.defaults - Args: - flags (list): A list of flag names to set up. - enabled (bool): The value to set for each flag's enabled state. - """ - for flag in flags: - WaffleFlagCourseOverrideModel.objects.create( - waffle_flag=f"contentstore.new_studio_mfe.{flag}", - course_id=self.course.id, - enabled=enabled, - ) - - def expected_response(self, enabled=False): - """ - Generate an expected response dictionary based on the enabled flag. - - Args: - enabled (bool): State to assign to each waffle flag in the response. - - Returns: - dict: A dictionary with each flag set to the value of `enabled`. - """ - return {flag: enabled for flag in self.course_waffle_flags} - - def test_get_course_waffle_flags_with_course_id(self): - """ - Test that waffle flags for a specific course are correctly returned when - a valid course ID is provided. - - Expected Behavior: - - The response should return HTTP 200 status. - - Each flag returned should be `True` as set up in the `setUp` method. - """ - course_url = reverse( + def test_course_override(self): + url = reverse( "cms.djangoapps.contentstore:v1:course_waffle_flags", kwargs={"course_id": self.course.id}, ) - - expected_response = self.expected_response(enabled=True) - expected_response["use_new_home_page"] = False - - response = self.client.get(course_url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(expected_response, response.data) - - def test_get_course_waffle_flags_without_course_id(self): - """ - Test that the default waffle flag states are returned when no course ID is provided. - - Expected Behavior: - - The response should return HTTP 200 status. - - Each flag returned should default to `False`, representing the global - default state for each flag. - """ - expected_response = self.expected_response(enabled=False) - expected_response["use_new_home_page"] = False - - response = self.client.get(self.url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertDictEqual(expected_response, response.data) + response = self.client.get(url) + assert response.data == { + **self.defaults, + "enable_course_optimizer": True, + } diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py index 73e3fe5ec3..3e88e04010 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py @@ -2,26 +2,17 @@ Unit tests for home page view. """ import ddt +import pytz from collections import OrderedDict +from datetime import datetime, timedelta from django.conf import settings from django.test import override_settings from django.urls import reverse -from edx_toggles.toggles.testutils import ( - override_waffle_switch, -) from rest_framework import status from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.tests.test_libraries import LibraryTestCase -from cms.djangoapps.contentstore.views.course import ENABLE_GLOBAL_STAFF_OPTIMIZATION from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory -from xmodule.modulestore.tests.factories import CourseFactory - - -FEATURES_WITH_HOME_PAGE_COURSE_V2_API = settings.FEATURES.copy() -FEATURES_WITH_HOME_PAGE_COURSE_V2_API['ENABLE_HOME_PAGE_COURSE_API_V2'] = True -FEATURES_WITHOUT_HOME_PAGE_COURSE_V2_API = settings.FEATURES.copy() -FEATURES_WITHOUT_HOME_PAGE_COURSE_V2_API['ENABLE_HOME_PAGE_COURSE_API_V2'] = False @ddt.ddt @@ -35,9 +26,10 @@ class HomePageViewTest(CourseTestCase): self.url = reverse("cms.djangoapps.contentstore:v1:home") self.expected_response = { "allow_course_reruns": True, - "allow_to_create_new_org": False, + "allow_to_create_new_org": True, "allow_unicode_course_id": False, "allowed_organizations": [], + "allowed_organizations_for_libraries": [], "archived_courses": [], "can_access_advanced_settings": True, "can_create_organizations": True, @@ -50,10 +42,10 @@ class HomePageViewTest(CourseTestCase): "libraries_v2_enabled": False, "taxonomies_enabled": True, "taxonomy_list_mfe_url": 'http://course-authoring-mfe/taxonomies', - "optimization_enabled": False, "request_course_creator_url": "/request_course_creator", "rerun_creator_status": True, "show_new_library_button": True, + "show_new_library_v2_button": True, "split_studio_home": False, "studio_name": settings.STUDIO_NAME, "studio_short_name": settings.STUDIO_SHORT_NAME, @@ -81,6 +73,17 @@ class HomePageViewTest(CourseTestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertDictEqual(expected_response, response.data) + @override_settings(ORGANIZATIONS_AUTOCREATE=False) + def test_home_page_studio_with_org_autocreate_disabled(self): + """Check response content when Organization autocreate is disabled""" + response = self.client.get(self.url) + + expected_response = self.expected_response + expected_response["allow_to_create_new_org"] = False + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(expected_response, response.data) + def test_taxonomy_list_link(self): response = self.client.get(self.url) self.assertTrue(response.data['taxonomies_enabled']) @@ -90,7 +93,6 @@ class HomePageViewTest(CourseTestCase): ) -@override_settings(FEATURES=FEATURES_WITHOUT_HOME_PAGE_COURSE_V2_API) @ddt.ddt class HomePageCoursesViewTest(CourseTestCase): """ @@ -100,12 +102,13 @@ class HomePageCoursesViewTest(CourseTestCase): def setUp(self): super().setUp() self.url = reverse("cms.djangoapps.contentstore:v1:courses") - CourseOverviewFactory.create( + self.course_overview = CourseOverviewFactory.create( id=self.course.id, org=self.course.org, display_name=self.course.display_name, display_number_with_default=self.course.number, ) + self.non_staff_client, _ = self.create_non_staff_authed_user_client() def test_home_page_response(self): """Check successful response content""" @@ -155,31 +158,83 @@ class HomePageCoursesViewTest(CourseTestCase): "in_process_course_actions": [], } - with override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API): - response = self.client.get(self.url) + response = self.client.get(self.url) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertDictEqual(expected_response, response.data) - @override_waffle_switch(ENABLE_GLOBAL_STAFF_OPTIMIZATION, True) - def test_org_query_if_passed(self): - """Test home page when org filter passed as a query param""" - foo_course = self.store.make_course_key('foo-org', 'bar-number', 'baz-run') - test_course = CourseFactory.create( - org=foo_course.org, - number=foo_course.course, - run=foo_course.run + @ddt.data( + ("active_only", "true", 2, 0), + ("archived_only", "true", 0, 1), + ("search", "sample", 1, 0), + ("search", "demo", 0, 1), + ("order", "org", 2, 1), + ("order", "display_name", 2, 1), + ("order", "number", 2, 1), + ("order", "run", 2, 1) + ) + @ddt.unpack + def test_filter_and_ordering_courses( + self, + filter_key, + filter_value, + expected_active_length, + expected_archived_length + ): + """Test home page with org filter and ordering for a staff user. + + The test creates an active/archived course, and then filters/orders them using the query parameters. + """ + archived_course_key = self.store.make_course_key("demo-org", "demo-number", "demo-run") + CourseOverviewFactory.create( + display_name="Course (Demo)", + id=archived_course_key, + org=archived_course_key.org, + end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC), ) - CourseOverviewFactory.create(id=test_course.id, org='foo-org') - response = self.client.get(self.url, {"org": "foo-org"}) - self.assertEqual(len(response.data['courses']), 1) + active_course_key = self.store.make_course_key("sample-org", "sample-number", "sample-run") + CourseOverviewFactory.create( + display_name="Course (Sample)", + id=active_course_key, + org=active_course_key.org, + ) + + response = self.client.get(self.url, {filter_key: filter_value}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["archived_courses"]), expected_archived_length) + self.assertEqual(len(response.data["courses"]), expected_active_length) + + @ddt.data( + ("active_only", "true"), + ("archived_only", "true"), + ("search", "sample"), + ("order", "org"), + ) + @ddt.unpack + def test_filter_and_ordering_no_courses_staff(self, filter_key, filter_value): + """Test home page with org filter and ordering when there are no courses for a staff user.""" + self.course_overview.delete() + + response = self.client.get(self.url, {filter_key: filter_value}) + + self.assertEqual(len(response.data["courses"]), 0) self.assertEqual(response.status_code, status.HTTP_200_OK) - @override_waffle_switch(ENABLE_GLOBAL_STAFF_OPTIMIZATION, True) - def test_org_query_if_empty(self): - """Test home page with an empty org query param""" - response = self.client.get(self.url) - self.assertEqual(len(response.data['courses']), 0) + @ddt.data( + ("active_only", "true"), + ("archived_only", "true"), + ("search", "sample"), + ("order", "org"), + ) + @ddt.unpack + def test_home_page_response_no_courses_non_staff(self, filter_key, filter_value): + """Test home page with org filter and ordering when there are no courses for a non-staff user.""" + self.course_overview.delete() + + response = self.non_staff_client.get(self.url, {filter_key: filter_value}) + + self.assertEqual(len(response.data["courses"]), 0) self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py index 7cac074a43..68ef03ca8d 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py @@ -226,7 +226,7 @@ class ContainerVerticalViewTest(BaseXBlockContainer): "version_synced": 5, "version_available": None, "version_declined": None, - "error_message": "Linked library item was not found in the system", + "error_message": "Linked upstream library block was not found in the system", "ready_to_sync": False, }, "user_partition_info": expected_user_partition_info, @@ -236,7 +236,8 @@ class ContainerVerticalViewTest(BaseXBlockContainer): }, ] self.maxDiff = None - self.assertEqual(response.data["children"], expected_response) + # Using json() shows meaningful diff in case of error + self.assertEqual(response.json()["children"], expected_response) def test_not_valid_usage_key_string(self): """ diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py index 0798c341cc..0a5af1ab89 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py @@ -283,7 +283,7 @@ class VerticalContainerView(APIView, ContainerHandlerMixin): child_info = modulestore().get_item(child) user_partition_info = get_visibility_partition_info(child_info, course=course) user_partitions = get_user_partition_info(child_info, course=course) - upstream_link = UpstreamLink.try_get_for_block(child_info) + upstream_link = UpstreamLink.try_get_for_block(child_info, log_error=False) validation_messages = get_xblock_validation_messages(child_info) render_error = get_xblock_render_error(request, child_info) diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py index 6e102bab44..4a48fd6395 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/__init__.py @@ -1,3 +1,17 @@ """Module for v2 serializers.""" +from cms.djangoapps.contentstore.rest_api.v2.serializers.downstreams import ( + ComponentLinksSerializer, + ContainerLinksSerializer, + PublishableEntityLinksSummarySerializer, + PublishableEntityLinkSerializer +) from cms.djangoapps.contentstore.rest_api.v2.serializers.home import CourseHomeTabSerializerV2 + +__all__ = [ + 'CourseHomeTabSerializerV2', + 'ComponentLinksSerializer', + 'PublishableEntityLinkSerializer', + 'ContainerLinksSerializer', + 'PublishableEntityLinksSummarySerializer', +] diff --git a/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py new file mode 100644 index 0000000000..4024cad8ff --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/serializers/downstreams.py @@ -0,0 +1,68 @@ +""" +Serializers for upstream -> downstream entity links. +""" + +from rest_framework import serializers + +from cms.djangoapps.contentstore.models import ComponentLink, ContainerLink + + +class ComponentLinksSerializer(serializers.ModelSerializer): + """ + Serializer for publishable component entity links. + """ + upstream_context_title = serializers.CharField(read_only=True) + upstream_version = serializers.IntegerField(read_only=True, source="upstream_version_num") + ready_to_sync = serializers.BooleanField() + + class Meta: + model = ComponentLink + exclude = ['upstream_block', 'uuid'] + + +class PublishableEntityLinksSummarySerializer(serializers.Serializer): + """ + Serializer for summary for publishable entity links + """ + upstream_context_title = serializers.CharField(read_only=True) + upstream_context_key = serializers.CharField(read_only=True) + ready_to_sync_count = serializers.IntegerField(read_only=True) + total_count = serializers.IntegerField(read_only=True) + last_published_at = serializers.DateTimeField(read_only=True) + + +class ContainerLinksSerializer(serializers.ModelSerializer): + """ + Serializer for publishable container entity links. + """ + upstream_context_title = serializers.CharField(read_only=True) + upstream_version = serializers.IntegerField(read_only=True, source="upstream_version_num") + ready_to_sync = serializers.BooleanField() + + class Meta: + model = ContainerLink + exclude = ['upstream_container', 'uuid'] + + +class PublishableEntityLinkSerializer(serializers.Serializer): + """ + Serializer for publishable component or container entity links. + """ + upstream_key = serializers.CharField(read_only=True) + upstream_type = serializers.ChoiceField(read_only=True, choices=['component', 'container']) + + def to_representation(self, instance): + if isinstance(instance, ComponentLink): + data = ComponentLinksSerializer(instance).data + data['upstream_key'] = data.get('upstream_usage_key') + data['upstream_type'] = 'component' + del data['upstream_usage_key'] + elif isinstance(instance, ContainerLink): + data = ContainerLinksSerializer(instance).data + data['upstream_key'] = data.get('upstream_container_key') + data['upstream_type'] = 'container' + del data['upstream_container_key'] + else: + raise Exception("Unexpected type") + + return data diff --git a/cms/djangoapps/contentstore/rest_api/v2/urls.py b/cms/djangoapps/contentstore/rest_api/v2/urls.py index 3e653d07fb..ce2a78c0e2 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v2/urls.py @@ -3,7 +3,8 @@ from django.conf import settings from django.urls import path, re_path -from cms.djangoapps.contentstore.rest_api.v2.views import home, downstreams +from cms.djangoapps.contentstore.rest_api.v2.views import downstreams, home + app_name = "v2" urlpatterns = [ @@ -12,17 +13,34 @@ urlpatterns = [ home.HomePageCoursesViewV2.as_view(), name="courses", ), - # TODO: Potential future path. - # re_path( - # fr'^downstreams/$', - # downstreams.DownstreamsListView.as_view(), - # name="downstreams_list", - # ), + # TODO: Rename this to `downstreams/` after full deprecate `DownstreamComponentsListView` + re_path( + r'^downstreams-all/$', + downstreams.DownstreamListView.as_view(), + name="downstreams_list_all", + ), + # [DEPRECATED], use `downstreams-all/` instead. + re_path( + r'^downstreams/$', + downstreams.DownstreamComponentsListView.as_view(), + name="downstreams_list", + ), + # [DEPRECATED], use `downstreams-all/` instead. + re_path( + r'^downstream-containers/$', + downstreams.DownstreamContainerListView.as_view(), + name="container_downstreams_list", + ), re_path( fr'^downstreams/{settings.USAGE_KEY_PATTERN}$', downstreams.DownstreamView.as_view(), name="downstream" ), + re_path( + f'^downstreams/{settings.COURSE_KEY_PATTERN}/summary$', + downstreams.DownstreamSummaryView.as_view(), + name='upstream-summary-list' + ), re_path( fr'^downstreams/{settings.USAGE_KEY_PATTERN}/sync$', downstreams.SyncFromUpstreamView.as_view(), diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py index 8c0d651910..f85b68bd26 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py @@ -40,11 +40,34 @@ https://github.com/openedx/edx-platform/issues/35653): 400: Downstream block is not linked to upstream content. 404: Downstream block not found or user lacks permission to edit it. - # NOT YET IMPLEMENTED -- Will be needed for full Libraries Relaunch in ~Teak. + /api/contentstore/v2/upstream/{usage_key_string}/downstream-links + + GET: List all downstream blocks linked to a library block. + 200: A list of downstream usage_keys linked to the library block. + /api/contentstore/v2/downstreams /api/contentstore/v2/downstreams?course_id=course-v1:A+B+C&ready_to_sync=true GET: List downstream blocks that can be synced, filterable by course or sync-readiness. - 200: A paginated list of applicable & accessible downstream blocks. Entries are UpstreamLinks. + 200: A paginated list of applicable & accessible downstream blocks. Entries are ComponentLinks. + + /api/contentstore/v2/downstreams//summary + GET: List summary of links by course key + 200: A list of summary of links by course key + Example: + [ + { + "upstream_context_title": "CS problems 3", + "upstream_context_key": "lib:OpenedX:CSPROB3", + "ready_to_sync_count": 11, + "total_count": 14 + }, + { + "upstream_context_title": "CS problems 2", + "upstream_context_key": "lib:OpenedX:CSPROB2", + "ready_to_sync_count": 15, + "total_count": 24 + }, + ] UpstreamLink response schema: { @@ -56,29 +79,53 @@ UpstreamLink response schema: "ready_to_sync": Boolean } """ -import logging +import logging +import warnings + +from attrs import asdict as attrs_asdict +from django.db.models import QuerySet from django.contrib.auth.models import User # pylint: disable=imported-auth-user +from edx_rest_framework_extensions.paginators import DefaultPagination from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import UsageKey -from rest_framework.exceptions import NotFound, ValidationError +from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.locator import LibraryUsageLocatorV2, LibraryContainerLocator, LibraryLocatorV2 +from rest_framework.exceptions import NotFound, ValidationError, PermissionDenied +from rest_framework.fields import BooleanField from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView +from itertools import chain from xblock.core import XBlock -from cms.lib.xblock.upstream_sync import ( - UpstreamLink, UpstreamLinkException, NoUpstream, BadUpstream, BadDownstream, - fetch_customizable_fields, sync_from_upstream, decline_sync, sever_upstream_link +from cms.djangoapps.contentstore.models import ComponentLink, ContainerLink, EntityLinkBase +from cms.djangoapps.contentstore.rest_api.v2.serializers import ( + PublishableEntityLinkSerializer, + ComponentLinksSerializer, + ContainerLinksSerializer, + PublishableEntityLinksSummarySerializer, ) -from common.djangoapps.student.auth import has_studio_write_access, has_studio_read_access +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import sync_library_content +from cms.lib.xblock.upstream_sync import ( + BadDownstream, + BadUpstream, + NoUpstream, + UpstreamLink, + UpstreamLinkException, + decline_sync, + sever_upstream_link, +) +from cms.lib.xblock.upstream_sync_block import fetch_customizable_fields_from_block +from cms.lib.xblock.upstream_sync_container import fetch_customizable_fields_from_container +from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access from openedx.core.lib.api.view_utils import ( DeveloperErrorViewMixin, view_auth_classes, ) +from openedx.core.djangoapps.content_libraries import api as lib_api from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError - +from xmodule.video_block.transcripts_utils import clear_transcripts logger = logging.getLogger(__name__) @@ -93,24 +140,212 @@ class _AuthenticatedRequest(Request): user: User -# TODO: Potential future view. -# @view_auth_classes(is_authenticated=True) -# class DownstreamListView(DeveloperErrorViewMixin, APIView): -# """ -# List all blocks which are linked to upstream content, with optional filtering. -# """ -# def get(self, request: _AuthenticatedRequest) -> Response: -# """ -# Handle the request. -# """ -# course_key_string = request.GET['course_id'] -# syncable = request.GET['ready_to_sync'] -# ... +class DownstreamListPaginator(DefaultPagination): + """Custom paginator for downstream entity links""" + page_size = 100 + max_page_size = 1000 + + def paginate_queryset(self, queryset, request, view=None): + if 'no_page' in request.query_params: + return queryset + + return super().paginate_queryset(queryset, request, view) + + def get_paginated_response(self, data, *args, **kwargs): + if 'no_page' in args[0].query_params: + return Response(data) + response = super().get_paginated_response(data) + # replace next and previous links by next and previous page number + response.data.update({ + 'next_page_num': self.page.next_page_number() if self.page.has_next() else None, + 'previous_page_num': self.page.previous_page_number() if self.page.has_previous() else None, + }) + return response + + +@view_auth_classes() +class DownstreamListView(DeveloperErrorViewMixin, APIView): + """ + [ 🛑 UNSTABLE ] + List all items (components and containers) wich are linked to an upstream context, with optional filtering. + """ + + def get(self, request: _AuthenticatedRequest): + """ + Fetches publishable entity links for given course key + """ + course_key_string = request.GET.get('course_id') + ready_to_sync = request.GET.get('ready_to_sync') + upstream_key = request.GET.get('upstream_key') + item_type = request.GET.get('item_type') + link_filter: dict[str, CourseKey | UsageKey | LibraryContainerLocator | bool] = {} + paginator = DownstreamListPaginator() + + if course_key_string is None and upstream_key is None and not request.user.is_superuser: + # This case without course or upstream filter means that the user need permissions to + # multiple courses/libraries, so raise `PermissionDenied` if the user is not superuser. + raise PermissionDenied + + if course_key_string: + try: + course_key = CourseKey.from_string(course_key_string) + link_filter["downstream_context_key"] = course_key + except InvalidKeyError as exc: + raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc + + if not has_studio_read_access(request.user, course_key): + raise PermissionDenied + if ready_to_sync is not None: + link_filter["ready_to_sync"] = BooleanField().to_internal_value(ready_to_sync) + if upstream_key: + try: + upstream_usage_key = UsageKey.from_string(upstream_key) + link_filter["upstream_usage_key"] = upstream_usage_key + + # Verify that the user has permission to view the library that contains + # the upstream component + lib_api.require_permission_for_library_key( + LibraryLocatorV2.from_string(str(upstream_usage_key.context_key)), + request.user, + permission=lib_api.permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, + ) + # At this point we just need to bring components + item_type = 'components' + except InvalidKeyError: + try: + upstream_container_key = LibraryContainerLocator.from_string(upstream_key) + link_filter["upstream_container_key"] = upstream_container_key + # Verify that the user has permission to view the library that contains + # the upstream container + lib_api.require_permission_for_library_key( + upstream_container_key.lib_key, + request.user, + permission=lib_api.permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, + ) + # At this point we just need to bring containers + item_type = 'containers' + except InvalidKeyError as exc: + raise ValidationError(detail=f"Malformed key: {upstream_key}") from exc + links: list[EntityLinkBase] | QuerySet[EntityLinkBase] = [] + if item_type is None or item_type == 'all': + links = list(chain( + ComponentLink.filter_links(**link_filter), + ContainerLink.filter_links(**link_filter) + )) + elif item_type == 'components': + links = ComponentLink.filter_links(**link_filter) + elif item_type == 'containers': + links = ContainerLink.filter_links(**link_filter) + paginated_links = paginator.paginate_queryset(links, self.request, view=self) + serializer = PublishableEntityLinkSerializer(paginated_links, many=True) + return paginator.get_paginated_response(serializer.data, self.request) + + +@view_auth_classes() +class DownstreamComponentsListView(DeveloperErrorViewMixin, APIView): + """ + [DEPRECATED], use DownstreamListView instead. + + List all components which are linked to an upstream context, with optional filtering. + """ + + def get(self, request: _AuthenticatedRequest): + """ + [DEPRECATED], use DownstreamListView.get instead, with `item_type='components'` + + Fetches publishable entity links for given course key + """ + warnings.warn( + '`downstreams/` API is deprecated. Please use `downstreams-all/?item_type=components` instead.', + DeprecationWarning, stacklevel=3, + ) + course_key_string = request.GET.get('course_id') + ready_to_sync = request.GET.get('ready_to_sync') + upstream_usage_key = request.GET.get('upstream_usage_key') + link_filter: dict[str, CourseKey | UsageKey | bool] = {} + paginator = DownstreamListPaginator() + if course_key_string: + try: + link_filter["downstream_context_key"] = CourseKey.from_string(course_key_string) + except InvalidKeyError as exc: + raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc + if ready_to_sync is not None: + link_filter["ready_to_sync"] = BooleanField().to_internal_value(ready_to_sync) + if upstream_usage_key: + try: + link_filter["upstream_usage_key"] = UsageKey.from_string(upstream_usage_key) + except InvalidKeyError as exc: + raise ValidationError(detail=f"Malformed usage key: {upstream_usage_key}") from exc + links = ComponentLink.filter_links(**link_filter) + paginated_links = paginator.paginate_queryset(links, self.request, view=self) + serializer = ComponentLinksSerializer(paginated_links, many=True) + return paginator.get_paginated_response(serializer.data, self.request) + + +@view_auth_classes() +class DownstreamSummaryView(DeveloperErrorViewMixin, APIView): + """ + [ 🛑 UNSTABLE ] + Serves course->library publishable entity links summary + """ + def get(self, request: _AuthenticatedRequest, course_key_string: str): + """ + Fetches publishable entity links summary for given course key + Example: + [ + { + "upstream_context_title": "CS problems 3", + "upstream_context_key": "lib:OpenedX:CSPROB3", + "ready_to_sync_count": 11, + "total_count": 14 + "last_published_at": "2025-05-02T20:20:44.989042Z" + }, + { + "upstream_context_title": "CS problems 2", + "upstream_context_key": "lib:OpenedX:CSPROB2", + "ready_to_sync_count": 15, + "total_count": 24, + "last_published_at": "2025-05-03T21:20:44.989042Z" + }, + ] + """ + try: + course_key = CourseKey.from_string(course_key_string) + except InvalidKeyError as exc: + raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc + component_links = ComponentLink.summarize_by_downstream_context(downstream_context_key=course_key) + container_links = ContainerLink.summarize_by_downstream_context(downstream_context_key=course_key) + + merged = {} + + def process_list(lst): + """ + Process a list to merge it with values in `merged` + """ + for item in lst: + key = item["upstream_context_key"] + if key not in merged: + merged[key] = item.copy() + else: + merged[key]["ready_to_sync_count"] += item["ready_to_sync_count"] + merged[key]["total_count"] += item["total_count"] + if item["last_published_at"] > merged[key]["last_published_at"]: + merged[key]["last_published_at"] = item["last_published_at"] + + # Merge `component_links` and `container_links` by adding the values of + # `ready_to_sync_count` and `total_count` of each library. + process_list(component_links) + process_list(container_links) + + links = list(merged.values()) + serializer = PublishableEntityLinksSummarySerializer(links, many=True) + return Response(serializer.data) @view_auth_classes(is_authenticated=True) class DownstreamView(DeveloperErrorViewMixin, APIView): """ + [ 🛑 UNSTABLE ] Inspect or manage an XBlock's link to upstream content. """ def get(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response: @@ -138,12 +373,21 @@ class DownstreamView(DeveloperErrorViewMixin, APIView): raise ValidationError({"sync": "must be 'true' or 'false'"}) try: if sync_param == "true" or sync_param is True: - sync_from_upstream(downstream=downstream, user=request.user) + sync_library_content( + downstream=downstream, + request=request, + store=modulestore() + ) else: # Even if we're not syncing (i.e., updating the downstream's values with the upstream's), we still need # to fetch the upstream's customizable values and store them as hidden fields on the downstream. This # ensures that downstream authors can restore defaults based on the upstream. - fetch_customizable_fields(downstream=downstream, user=request.user) + link = UpstreamLink.get_for_block(downstream) + if isinstance(link.upstream_key, LibraryUsageLocatorV2): + fetch_customizable_fields_from_block(downstream=downstream, user=request.user) + else: + assert isinstance(link.upstream_key, LibraryContainerLocator) + fetch_customizable_fields_from_container(downstream=downstream) except BadDownstream as exc: logger.exception( "'%s' is an invalid downstream; refusing to set its upstream to '%s'", @@ -172,7 +416,7 @@ class DownstreamView(DeveloperErrorViewMixin, APIView): downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True) try: sever_upstream_link(downstream) - except NoUpstream as exc: + except NoUpstream: logger.exception( "Tried to DELETE upstream link of '%s', but it wasn't linked to anything in the first place. " "Will do nothing. ", @@ -186,6 +430,7 @@ class DownstreamView(DeveloperErrorViewMixin, APIView): @view_auth_classes(is_authenticated=True) class SyncFromUpstreamView(DeveloperErrorViewMixin, APIView): """ + [ 🛑 UNSTABLE ] Accept or decline an opportunity to sync a downstream block from its upstream content. """ @@ -195,7 +440,14 @@ class SyncFromUpstreamView(DeveloperErrorViewMixin, APIView): """ downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True) try: - sync_from_upstream(downstream, request.user) + if downstream.usage_key.block_type == "video": + # Delete all transcripts so we can copy new ones from upstream + clear_transcripts(downstream) + static_file_notices = sync_library_content( + downstream=downstream, + request=request, + store=modulestore() + ) except UpstreamLinkException as exc: logger.exception( "Could not sync from upstream '%s' to downstream '%s'", @@ -203,10 +455,11 @@ class SyncFromUpstreamView(DeveloperErrorViewMixin, APIView): usage_key_string, ) raise ValidationError(detail=str(exc)) from exc - modulestore().update_item(downstream, request.user.id) # Note: We call `get_for_block` (rather than `try_get_for_block`) because if anything is wrong with the # upstream at this point, then that is completely unexpected, so it's appropriate to let the 500 happen. - return Response(UpstreamLink.get_for_block(downstream).to_json()) + response = UpstreamLink.get_for_block(downstream).to_json() + response["static_file_notices"] = attrs_asdict(static_file_notices) + return Response(response) def delete(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response: """ @@ -230,6 +483,47 @@ class SyncFromUpstreamView(DeveloperErrorViewMixin, APIView): return Response(status=204) +@view_auth_classes() +class DownstreamContainerListView(DeveloperErrorViewMixin, APIView): + """ + [DEPRECATED], use DownstreamListView instead. + + List all container blocks which are linked to an upstream context, with optional filtering. + """ + + def get(self, request: _AuthenticatedRequest): + """ + [DEPRECATED], use DownstreamListView.get instead, with `item_type='containers'` + + Fetches publishable container entity links for given course key + """ + warnings.warn( + '`downstreams/` API is deprecated. Please use `downstreams-all/?item_type=components` instead.', + DeprecationWarning, stacklevel=3, + ) + course_key_string = request.GET.get('course_id') + ready_to_sync = request.GET.get('ready_to_sync') + upstream_container_key = request.GET.get('upstream_container_key') + link_filter: dict[str, CourseKey | LibraryContainerLocator | bool] = {} + paginator = DownstreamListPaginator() + if course_key_string: + try: + link_filter["downstream_context_key"] = CourseKey.from_string(course_key_string) + except InvalidKeyError as exc: + raise ValidationError(detail=f"Malformed course key: {course_key_string}") from exc + if ready_to_sync is not None: + link_filter["ready_to_sync"] = BooleanField().to_internal_value(ready_to_sync) + if upstream_container_key: + try: + link_filter["upstream_container_key"] = LibraryContainerLocator.from_string(upstream_container_key) + except InvalidKeyError as exc: + raise ValidationError(detail=f"Malformed usage key: {upstream_container_key}") from exc + links = ContainerLink.filter_links(**link_filter) + paginated_links = paginator.paginate_queryset(links, self.request, view=self) + serializer = ContainerLinksSerializer(paginated_links, many=True) + return paginator.get_paginated_response(serializer.data, self.request) + + def _load_accessible_block(user: User, usage_key_string: str, *, require_write_access: bool) -> XBlock: """ Given a logged in-user and a serialized usage key of an upstream-linked XBlock, load it from the ModuleStore, diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/home.py b/cms/djangoapps/contentstore/rest_api/v2/views/home.py index c327415101..6d2bd1dcc9 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/home.py @@ -1,8 +1,7 @@ """HomePageCoursesViewV2 APIView for getting content available to the logged in user.""" + import edx_api_doc_tools as apidocs from collections import OrderedDict -from django.conf import settings -from django.http import HttpResponseNotFound from rest_framework.response import Response from rest_framework.request import Request from rest_framework.views import APIView @@ -126,13 +125,7 @@ class HomePageCoursesViewV2(APIView): "in_process_course_actions": [], } ``` - - if the `ENABLE_HOME_PAGE_COURSE_API_V2` feature flag is not enabled, an HTTP 404 "Not Found" response - is returned. """ - if not settings.FEATURES.get('ENABLE_HOME_PAGE_COURSE_API_V2', False): - return HttpResponseNotFound() - courses, in_process_course_actions = get_course_context_v2(request) paginator = HomePageCoursesPaginator() courses_page = paginator.paginate_queryset( diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py new file mode 100644 index 0000000000..b7890f821d --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py @@ -0,0 +1,462 @@ +""" +Unit and integration tests to ensure that syncing content from libraries to +courses is working. +""" +from typing import Any +from xml.etree import ElementTree + +import ddt +from opaque_keys.edx.keys import UsageKey + +from openedx.core.djangoapps.content_libraries.tests import ContentLibrariesRestApiTest +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory + + +@ddt.ddt +class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase): + """ + Tests that involve syncing content from libraries to courses. + """ + maxDiff = None # Necessary for debugging OLX differences + + def setUp(self): + super().setUp() + # self.user is set up by ContentLibrariesRestApiTest + + # The source library (contains the upstreams): + self.library = self._create_library(slug="testlib", title="Upstream Library") + lib_id = self.library["id"] # the library ID as a string + self.upstream_problem1 = self._add_block_to_library(lib_id, "problem", "prob1", can_stand_alone=True) + self._set_library_block_olx( + self.upstream_problem1["id"], + 'multiple choice...' + ) + self.upstream_problem2 = self._add_block_to_library(lib_id, "problem", "prob2", can_stand_alone=True) + self._set_library_block_olx( + self.upstream_problem2["id"], + 'multi select...' + ) + self.upstream_html1 = self._add_block_to_library(lib_id, "html", "html1", can_stand_alone=False) + self._set_library_block_olx( + self.upstream_html1["id"], + 'This is the HTML.' + ) + self.upstream_unit = self._create_container(lib_id, "unit", slug="u1", display_name="Unit 1 Title") + self._add_container_children(self.upstream_unit["id"], [ + self.upstream_html1["id"], + self.upstream_problem1["id"], + self.upstream_problem2["id"], + ]) + self._commit_library_changes(lib_id) # publish everything + + # The destination course: + self.course = CourseFactory.create() + self.course_section = BlockFactory.create(category='chapter', parent=self.course) + self.course_subsection = BlockFactory.create(category='sequential', parent=self.course_section) + self.course_unit = BlockFactory.create(category='vertical', parent=self.course_subsection) + + def _get_sync_status(self, usage_key: str): + return self._api('get', f"/api/contentstore/v2/downstreams/{usage_key}", {}, expect_response=200) + + def _sync_downstream(self, usage_key: str): + return self._api('post', f"/api/contentstore/v2/downstreams/{usage_key}/sync", {}, expect_response=200) + + def _get_course_block_olx(self, usage_key: str): + data = self._api('get', f'/api/olx-export/v1/xblock/{usage_key}/', {}, expect_response=200) + return data["blocks"][data["root_block_id"]]["olx"] + + # def _get_course_block_fields(self, usage_key: str): + # return self._api('get', f'/xblock/{usage_key}', {}, expect_response=200) + + def _get_course_block_children(self, usage_key: str) -> list[str]: + """ Get the IDs of the child XBlocks of the given XBlock """ + # TODO: is there really no REST API to get the children of an XBlock in Studio? + # Maybe this one: /api/contentstore/v1/container/vertical/{usage_key_string}/children + return [str(k) for k in modulestore().get_item(UsageKey.from_string(usage_key), depth=0).children] + + def _create_block_from_upstream( + self, + block_category: str, + parent_usage_key: str, + upstream_key: str, + expect_response: int = 200, + ): + """ + Call the CMS API for inserting an XBlock that's cloned from a library + item. i.e. copy a *published* library block into a course, and create an + upstream link. + """ + return self._api('post', "/xblock/", { + "category": block_category, + "parent_locator": parent_usage_key, + "library_content_key": upstream_key, + }, expect_response=expect_response) + + def _update_course_block_fields(self, usage_key: str, fields: dict[str, Any] = None): + """ Update fields of an XBlock """ + return self._api('patch', f"/xblock/{usage_key}", { + "metadata": fields, + }, expect_response=200) + + def assertXmlEqual(self, xml_str_a: str, xml_str_b: str) -> bool: + """ Assert that the given XML strings are equal, ignoring attribute order and some whitespace variations. """ + self.assertEqual( + ElementTree.canonicalize(xml_str_a, strip_text=True), + ElementTree.canonicalize(xml_str_b, strip_text=True), + ) + + # OLX attributes that will appear on capa problems when saved/exported. Excludes "markdown" + standard_capa_attributes = """ + markdown_edited="false" + matlab_api_key="null" + name="null" + rerandomize="never" + source_code="null" + tags="[]" + use_latex_compiler="false" + """ + + #################################################################################################################### + + def test_problem_sync(self): + """ + Test that we can sync a problem from a library into a course. + """ + # 1️⃣ First, create the problem in the course, using the upstream problem as a template: + downstream_problem1 = self._create_block_from_upstream( + block_category="problem", + parent_usage_key=str(self.course_subsection.usage_key), + upstream_key=self.upstream_problem1["id"], + ) + status = self._get_sync_status(downstream_problem1["locator"]) + self.assertDictContainsEntries(status, { + 'upstream_ref': self.upstream_problem1["id"], # e.g. 'lb:CL-TEST:testlib:problem:prob1' + 'version_available': 2, + 'version_synced': 2, + 'version_declined': None, + 'ready_to_sync': False, + 'error_message': None, + # 'upstream_link': 'http://course-authoring-mfe/library/lib:CL-TEST:testlib/components?usageKey=...' + }) + assert status["upstream_link"].startswith("http://course-authoring-mfe/library/") + assert status["upstream_link"].endswith(f"/components?usageKey={self.upstream_problem1['id']}") + + # Check the OLX of the downstream block. Notice that: + # (1) fields display_name and markdown, as well as the 'data' (content/body of the ) are synced. + # (2) per UpstreamSyncMixin.get_customizable_fields(), some fields like weight and max_attempts are + # DROPPED entirely from the upstream version when creating the downstream: + self.assertXmlEqual(self._get_course_block_olx(downstream_problem1["locator"]), f""" + multiple choice... + """) + + # 2️⃣ Now, lets modify the upstream problem AND the downstream problem: + + self._update_course_block_fields(downstream_problem1["locator"], { + "display_name": "Custom Display Name", + "max_attempts": 3, + "markdown": "blow me away, scotty!", # This change will be lost + }) + + self._set_library_block_olx( + self.upstream_problem1["id"], + 'multiple choice v2...' + ) + self._publish_library_block(self.upstream_problem1["id"]) + + # Here's how the downstream OLX looks now, before we sync: + self.assertXmlEqual(self._get_course_block_olx(downstream_problem1["locator"]), f""" + multiple choice... + """) + + status = self._get_sync_status(downstream_problem1["locator"]) + self.assertDictContainsEntries(status, { + 'upstream_ref': self.upstream_problem1["id"], # e.g. 'lb:CL-TEST:testlib:problem:prob1' + 'version_available': 3, # <--- updated + 'version_synced': 2, + 'version_declined': None, + 'ready_to_sync': True, # <--- updated + 'error_message': None, + }) + + # 3️⃣ Now, sync and check the resulting OLX of the downstream + + self._sync_downstream(downstream_problem1["locator"]) + + # Here's how the downstream OLX looks now, after we synced it. + # Notice: + # (1) content like "markdown" and the body XML content are synced + # (2) the "display_name" is left alone (customized downstream), but + # (3) "upstream_display_name" is updated. + # (4) The customized "max_attempts" is also still present. + self.assertXmlEqual(self._get_course_block_olx(downstream_problem1["locator"]), f""" + multiple choice v2... + """) + + def test_unit_sync(self): + """ + Test that we can sync a unit from the library into the course + """ + # 1️⃣ Create a "vertical" block in the course based on a "unit" container: + downstream_unit = self._create_block_from_upstream( + # The API consumer needs to specify "vertical" here, even though upstream is "unit". + # In the future we could create a nicer REST API endpoint for this that's not part of + # the messy '/xblock/' API and which auto-detects the types based on the upstream_key. + block_category="vertical", + parent_usage_key=str(self.course_subsection.usage_key), + upstream_key=self.upstream_unit["id"], + ) + status = self._get_sync_status(downstream_unit["locator"]) + self.assertDictContainsEntries(status, { + 'upstream_ref': self.upstream_unit["id"], # e.g. 'lct:CL-TEST:testlib:unit:u1' + 'version_available': 2, + 'version_synced': 2, + 'version_declined': None, + 'ready_to_sync': False, + 'error_message': None, + # 'upstream_link': 'http://course-authoring-mfe/library/lib:CL-TEST:testlib/units/...' + }) + assert status["upstream_link"].startswith("http://course-authoring-mfe/library/") + assert status["upstream_link"].endswith(f"/units/{self.upstream_unit['id']}") + + # Check that the downstream container matches our expectations. + # Note that: + # (1) Every XBlock has an "upstream" field + # (2) some "downstream only" fields like weight and max_attempts are omitted. + self.assertXmlEqual(self._get_course_block_olx(downstream_unit["locator"]), f""" + + This is the HTML. + multiple choice... + multi select... + + """) + + # 2️⃣ Now, lets modify the upstream problem 1: + + self._set_library_block_olx( + self.upstream_problem1["id"], + 'multiple choice v2...' + ) + self._publish_container(self.upstream_unit["id"]) + + status = self._get_sync_status(downstream_unit["locator"]) + self.assertDictContainsEntries(status, { + 'upstream_ref': self.upstream_unit["id"], # e.g. 'lct:CL-TEST:testlib:unit:u1' + 'version_available': 2, # <--- not updated since we didn't directly modify the unit + 'version_synced': 2, + 'version_declined': None, + # FIXME: ready_to_sync should be true, since a child block needs syncing. + # This may need to be fixed post-Teak, as syncing the children directly is still possible. + 'ready_to_sync': False, + 'error_message': None, + }) + + # Check the upstream/downstream status of [one of] the children + + downstream_problem1 = self._get_course_block_children(downstream_unit["locator"])[1] + assert "type@problem" in downstream_problem1 + self.assertDictContainsEntries(self._get_sync_status(downstream_problem1), { + 'upstream_ref': self.upstream_problem1["id"], + 'version_available': 3, # <--- updated since we modified the problem + 'version_synced': 2, + 'version_declined': None, + 'ready_to_sync': True, # <--- updated + 'error_message': None, + }) + + # 3️⃣ Now, sync and check the resulting OLX of the downstream + + self._sync_downstream(downstream_unit["locator"]) + + self.assertXmlEqual(self._get_course_block_olx(downstream_unit["locator"]), f""" + + This is the HTML. + + multiple choice v2... + multi select... + + """) + + # Now, add and delete a component + upstream_problem3 = self._add_block_to_library( + self.library["id"], + "problem", + "prob3", + can_stand_alone=True + ) + self._set_library_block_olx( + upstream_problem3["id"], + 'single select...' + ) + self._add_container_children(self.upstream_unit["id"], [upstream_problem3["id"]]) + self._remove_container_components(self.upstream_unit["id"], [self.upstream_problem2["id"]]) + self._commit_library_changes(self.library["id"]) # publish everything + + status = self._get_sync_status(downstream_unit["locator"]) + self.assertDictContainsEntries(status, { + 'upstream_ref': self.upstream_unit["id"], # e.g. 'lct:CL-TEST:testlib:unit:u1' + 'version_available': 4, # <--- updated twice, delete and add component + 'version_synced': 2, + 'version_declined': None, + 'ready_to_sync': True, + 'error_message': None, + }) + + # 3️⃣ Now, sync and check the resulting OLX of the downstream + + self._sync_downstream(downstream_unit["locator"]) + self.assertXmlEqual(self._get_course_block_olx(downstream_unit["locator"]), f""" + + This is the HTML. + multiple choice v2... + + + single select... + + """) + + # Now, reorder components + self._patch_container_components(self.upstream_unit["id"], [ + upstream_problem3["id"], + self.upstream_problem1["id"], + self.upstream_html1["id"], + ]) + self._publish_container(self.upstream_unit["id"]) + + # 3️⃣ Now, sync and check the resulting OLX of the downstream + + self._sync_downstream(downstream_unit["locator"]) + self.assertXmlEqual(self._get_course_block_olx(downstream_unit["locator"]), f""" + + + single select... + + multiple choice v2... + + This is the HTML. + + """) diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py index 92da28bde9..0b62b5ba1a 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py @@ -1,31 +1,47 @@ """ Unit tests for /api/contentstore/v2/downstreams/* JSON APIs. """ -from unittest.mock import patch -from django.conf import settings +import json +import ddt +from datetime import datetime, timezone +from unittest.mock import patch, MagicMock -from cms.lib.xblock.upstream_sync import UpstreamLink, BadUpstream +from django.conf import settings +from django.urls import reverse +from freezegun import freeze_time +from organizations.models import Organization + +from cms.djangoapps.contentstore.helpers import StaticFileNotices +from cms.lib.xblock.upstream_sync import BadUpstream, UpstreamLink +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from cms.djangoapps.contentstore.xblock_storage_handlers import view_handlers as xblock_view_handlers +from opaque_keys.edx.keys import ContainerKey, UsageKey +from opaque_keys.edx.locator import LibraryLocatorV2 from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.student.auth import add_users +from common.djangoapps.student.roles import CourseStaffRole from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory +from openedx.core.djangoapps.content_libraries import api as lib_api from .. import downstreams as downstreams_views - -MOCK_LIB_KEY = "lib:OpenedX:CSPROB3" -MOCK_UPSTREAM_REF = "lb:OpenedX:CSPROB3:html:843b4c73-1e2d-4ced-a0ff-24e503cdb3e4" -MOCK_UPSTREAM_LINK = "{mfe_url}/library/{lib_key}/components?usageKey={usage_key}".format( - mfe_url=settings.COURSE_AUTHORING_MICROFRONTEND_URL, - lib_key=MOCK_LIB_KEY, - usage_key=MOCK_UPSTREAM_REF, -) MOCK_UPSTREAM_ERROR = "your LibraryGPT subscription has expired" +URL_PREFIX = '/api/libraries/v2/' +URL_LIB_CREATE = URL_PREFIX +URL_LIB_BLOCKS = URL_PREFIX + '{lib_key}/blocks/' +URL_LIB_BLOCK_PUBLISH = URL_PREFIX + 'blocks/{block_key}/publish/' +URL_LIB_BLOCK_OLX = URL_PREFIX + 'blocks/{block_key}/olx/' +URL_LIB_CONTAINER = URL_PREFIX + 'containers/{container_key}/' # Get a container in this library +URL_LIB_CONTAINERS = URL_PREFIX + '{lib_key}/containers/' # Create a new container in this library +URL_LIB_CONTAINER_PUBLISH = URL_LIB_CONTAINER + 'publish/' # Publish changes to the specified container + children def _get_upstream_link_good_and_syncable(downstream): return UpstreamLink( upstream_ref=downstream.upstream, + upstream_key=UsageKey.from_string(downstream.upstream), version_synced=downstream.upstream_version, version_available=(downstream.upstream_version or 0) + 1, version_declined=downstream.upstream_version_declined, @@ -37,31 +53,161 @@ def _get_upstream_link_bad(_downstream): raise BadUpstream(MOCK_UPSTREAM_ERROR) -class _DownstreamViewTestMixin: +class _BaseDownstreamViewTestMixin: """ Shared data and error test cases. """ - def setUp(self): """ Create a simple course with one unit and two videos, one of which is linked to an "upstream". """ + # pylint: disable=too-many-statements super().setUp() + self.now = datetime.now(timezone.utc) + freezer = freeze_time(self.now) + self.addCleanup(freezer.stop) + freezer.start() + self.maxDiff = 2000 + + self.organization, _ = Organization.objects.get_or_create( + short_name="CL-TEST", + defaults={"name": "Content Libraries Tachyon Exploration & Survey Team"}, + ) + self.superuser = UserFactory(username="superuser", password="password", is_staff=True, is_superuser=True) + self.simple_user = UserFactory(username="simple_user", password="password") + self.course_user = UserFactory(username="course_user", password="password") + self.lib_user = UserFactory(username="lib_user", password="password") + self.client.login(username=self.superuser.username, password="password") + + self.library_title = "Test Library 1" + self.library_id = self._create_library( + slug="testlib1_preview", + title=self.library_title, + description="Testing XBlocks" + )["id"] + self.library_key = LibraryLocatorV2.from_string(self.library_id) + lib_api.set_library_user_permissions(self.library_key, self.lib_user, access_level="read") + self.html_lib_id = self._add_block_to_library(self.library_id, "html", "html-baz")["id"] + self.video_lib_id = self._add_block_to_library(self.library_id, "video", "video-baz")["id"] + self.unit_id = self._create_container(self.library_id, "unit", "unit-1", "Unit 1")["id"] + self.subsection_id = self._create_container(self.library_id, "subsection", "subsection-1", "Subsection 1")["id"] + self.section_id = self._create_container(self.library_id, "section", "section-1", "Section 1")["id"] + self._publish_library_block(self.html_lib_id) + self._publish_library_block(self.video_lib_id) + self._publish_container(self.unit_id) + self._publish_container(self.subsection_id) + self._publish_container(self.section_id) + self.mock_upstream_link = f"{settings.COURSE_AUTHORING_MICROFRONTEND_URL}/library/{self.library_id}/components?usageKey={self.video_lib_id}" # pylint: disable=line-too-long # noqa: E501 self.course = CourseFactory.create() + add_users(self.superuser, CourseStaffRole(self.course.id), self.course_user) chapter = BlockFactory.create(category='chapter', parent=self.course) sequential = BlockFactory.create(category='sequential', parent=chapter) unit = BlockFactory.create(category='vertical', parent=sequential) self.regular_video_key = BlockFactory.create(category='video', parent=unit).usage_key self.downstream_video_key = BlockFactory.create( - category='video', parent=unit, upstream=MOCK_UPSTREAM_REF, upstream_version=123, + category='video', parent=unit, upstream=self.video_lib_id, upstream_version=1, ).usage_key + self.downstream_html_key = BlockFactory.create( + category='html', parent=unit, upstream=self.html_lib_id, upstream_version=1, + ).usage_key + self.downstream_chapter_key = BlockFactory.create( + category='chapter', parent=self.course, upstream=self.section_id, upstream_version=1, + ).usage_key + self.downstream_sequential_key = BlockFactory.create( + category='sequential', parent=chapter, upstream=self.subsection_id, upstream_version=1, + ).usage_key + self.downstream_unit_key = BlockFactory.create( + category='vertical', parent=sequential, upstream=self.unit_id, upstream_version=1, + ).usage_key + + self.another_course = CourseFactory.create(display_name="Another Course") + another_chapter = BlockFactory.create(category="chapter", parent=self.another_course) + another_sequential = BlockFactory.create(category="sequential", parent=another_chapter) + another_unit = BlockFactory.create(category="vertical", parent=another_sequential) + self.another_video_keys = [] + for _ in range(3): + # Adds 3 videos linked to the same upstream + self.another_video_keys.append( + BlockFactory.create( + category="video", + parent=another_unit, + upstream=self.video_lib_id, + upstream_version=1 + ).usage_key + ) + self.fake_video_key = self.course.id.make_usage_key("video", "NoSuchVideo") - self.superuser = UserFactory(username="superuser", password="password", is_staff=True, is_superuser=True) self.learner = UserFactory(username="learner", password="password") + self._update_container(self.unit_id, display_name="Unit 2") + self._publish_container(self.unit_id) + self._set_library_block_olx(self.html_lib_id, "Hello world!") + self._publish_library_block(self.html_lib_id) + self._publish_library_block(self.video_lib_id) + self._publish_library_block(self.html_lib_id) + + def _api(self, method, url, data, expect_response): + """ + Call a REST API + """ + response = getattr(self.client, method)(url, data, format="json", content_type="application/json") + assert response.status_code == expect_response,\ + 'Unexpected response code {}:\n{}'.format(response.status_code, getattr(response, 'data', '(no data)')) + return response.data + + def _create_library( + self, slug, title, description="", org=None, + license_type='', expect_response=200, + ): + """ Create a library """ + if org is None: + org = self.organization.short_name + return self._api('post', URL_LIB_CREATE, { + "org": org, + "slug": slug, + "title": title, + "description": description, + "license": license_type, + }, expect_response) + + def _add_block_to_library(self, lib_key, block_type, slug, parent_block=None, expect_response=200): + """ Add a new XBlock to the library """ + data = {"block_type": block_type, "definition_id": slug} + if parent_block: + data["parent_block"] = parent_block + return self._api('post', URL_LIB_BLOCKS.format(lib_key=lib_key), data, expect_response) + + def _publish_library_block(self, block_key, expect_response=200): + """ Publish changes from a specified XBlock """ + return self._api('post', URL_LIB_BLOCK_PUBLISH.format(block_key=block_key), None, expect_response) + + def _publish_container(self, container_key: ContainerKey | str, expect_response=200): + """ Publish all changes in the specified container + children """ + return self._api('post', URL_LIB_CONTAINER_PUBLISH.format(container_key=container_key), None, expect_response) + + def _update_container(self, container_key: ContainerKey | str, display_name: str, expect_response=200): + """ Update a container (unit etc.) """ + data = {"display_name": display_name} + return self._api('patch', URL_LIB_CONTAINER.format(container_key=container_key), data, expect_response) + + def _set_library_block_olx(self, block_key, new_olx, expect_response=200): + """ Overwrite the OLX of a specific block in the library """ + return self._api('post', URL_LIB_BLOCK_OLX.format(block_key=block_key), {"olx": new_olx}, expect_response) def call_api(self, usage_key_string): raise NotImplementedError + def _create_container(self, lib_key, container_type, slug: str | None, display_name: str, expect_response=200): + """ Create a container (unit etc.) """ + data = {"container_type": container_type, "display_name": display_name} + if slug: + data["slug"] = slug + return self._api('post', URL_LIB_CONTAINERS.format(lib_key=lib_key), data, expect_response) + + +class SharedErrorTestCases(_BaseDownstreamViewTestMixin): + """ + Shared error test cases. + """ def test_404_downstream_not_found(self): """ Do we raise 404 if the specified downstream block could not be loaded? @@ -81,7 +227,7 @@ class _DownstreamViewTestMixin: assert "not found" in response.data["developer_message"] -class GetDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase): +class GetComponentDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase): """ Test that `GET /api/v2/contentstore/downstreams/...` inspects a downstream's link to an upstream. """ @@ -96,10 +242,10 @@ class GetDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase) self.client.login(username="superuser", password="password") response = self.call_api(self.downstream_video_key) assert response.status_code == 200 - assert response.data['upstream_ref'] == MOCK_UPSTREAM_REF + assert response.data['upstream_ref'] == self.video_lib_id assert response.data['error_message'] is None assert response.data['ready_to_sync'] is True - assert response.data['upstream_link'] == MOCK_UPSTREAM_LINK + assert response.data['upstream_link'] == self.mock_upstream_link @patch.object(UpstreamLink, "get_for_block", _get_upstream_link_bad) def test_200_bad_upstream(self): @@ -109,7 +255,7 @@ class GetDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase) self.client.login(username="superuser", password="password") response = self.call_api(self.downstream_video_key) assert response.status_code == 200 - assert response.data['upstream_ref'] == MOCK_UPSTREAM_REF + assert response.data['upstream_ref'] == self.video_lib_id assert response.data['error_message'] == MOCK_UPSTREAM_ERROR assert response.data['ready_to_sync'] is False assert response.data['upstream_link'] is None @@ -127,37 +273,37 @@ class GetDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase) assert response.data['upstream_link'] is None -class PutDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase): +class PutDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase): """ Test that `PUT /api/v2/contentstore/downstreams/...` edits a downstream's link to an upstream. """ def call_api(self, usage_key_string, sync: str | None = None): return self.client.put( f"/api/contentstore/v2/downstreams/{usage_key_string}", - data={ - "upstream_ref": MOCK_UPSTREAM_REF, + data=json.dumps({ + "upstream_ref": str(self.video_lib_id), **({"sync": sync} if sync else {}), - }, + }), content_type="application/json", ) - @patch.object(downstreams_views, "fetch_customizable_fields") - @patch.object(downstreams_views, "sync_from_upstream") + @patch.object(downstreams_views, "fetch_customizable_fields_from_block") + @patch.object(downstreams_views, "sync_library_content") @patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable) def test_200_with_sync(self, mock_sync, mock_fetch): """ Does the happy path work (with sync=True)? """ self.client.login(username="superuser", password="password") - response = self.call_api(self.regular_video_key, sync='true') + response = self.call_api(str(self.regular_video_key), sync='true') assert response.status_code == 200 video_after = modulestore().get_item(self.regular_video_key) assert mock_sync.call_count == 1 assert mock_fetch.call_count == 0 - assert video_after.upstream == MOCK_UPSTREAM_REF + assert video_after.upstream == self.video_lib_id - @patch.object(downstreams_views, "fetch_customizable_fields") - @patch.object(downstreams_views, "sync_from_upstream") + @patch.object(downstreams_views, "fetch_customizable_fields_from_block") + @patch.object(downstreams_views, "sync_library_content") @patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable) def test_200_no_sync(self, mock_sync, mock_fetch): """ @@ -169,9 +315,11 @@ class PutDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase) video_after = modulestore().get_item(self.regular_video_key) assert mock_sync.call_count == 0 assert mock_fetch.call_count == 1 - assert video_after.upstream == MOCK_UPSTREAM_REF + assert video_after.upstream == self.video_lib_id - @patch.object(downstreams_views, "fetch_customizable_fields", side_effect=BadUpstream(MOCK_UPSTREAM_ERROR)) + @patch.object( + downstreams_views, "fetch_customizable_fields_from_block", side_effect=BadUpstream(MOCK_UPSTREAM_ERROR), + ) def test_400(self, sync: str): """ Do we raise a 400 if the provided upstream reference is malformed or not accessible? @@ -184,7 +332,7 @@ class PutDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase) assert video_after.upstream is None -class DeleteDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase): +class DeleteDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase): """ Test that `DELETE /api/v2/contentstore/downstreams/...` severs a downstream's link to an upstream. """ @@ -213,7 +361,7 @@ class DeleteDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCa assert mock_sever.call_count == 1 -class _DownstreamSyncViewTestMixin(_DownstreamViewTestMixin): +class _DownstreamSyncViewTestMixin(SharedErrorTestCases): """ Shared tests between the /api/contentstore/v2/downstreams/.../sync endpoints. """ @@ -238,6 +386,60 @@ class _DownstreamSyncViewTestMixin(_DownstreamViewTestMixin): assert "is not linked" in response.data["developer_message"][0] +class CreateDownstreamViewTest(CourseTestCase, _BaseDownstreamViewTestMixin, SharedModuleStoreTestCase): + """ + Tests create new downstream blocks + """ + def call_api_post(self, library_content_key, category): + """ + Call the api to create a downstream block using + `library_content_key` as upstream + """ + data = { + "parent_locator": str(self.course.location), + "display_name": "Test block", + "library_content_key": library_content_key, + "category": category, + } + return self.client.post( + reverse("xblock_handler"), + data=json.dumps(data), + content_type="application/json", + ) + + def test_200(self): + response = self.call_api_post(self.html_lib_id, "html") + + assert response.status_code == 200 + data = response.json() + assert data["upstreamRef"] == self.html_lib_id + + usage_key = UsageKey.from_string(data["locator"]) + item = modulestore().get_item(usage_key) + assert item.upstream == self.html_lib_id + + @patch("cms.djangoapps.contentstore.helpers._insert_static_files_into_downstream_xblock") + @patch("cms.djangoapps.contentstore.helpers.content_staging_api.stage_xblock_temporarily") + @patch("cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers.sync_from_upstream_block") + def test_200_video(self, mock_sync, mock_stage, mock_insert): + mock_lib_block = MagicMock() + mock_lib_block.runtime.get_block_assets.return_value = ['mocked_asset'] + mock_sync.return_value = mock_lib_block + mock_stage.return_value = MagicMock() + mock_insert.return_value = StaticFileNotices() + + response = self.call_api_post(self.video_lib_id, "video") + + assert response.status_code == 200 + data = response.json() + assert data["upstreamRef"] == self.video_lib_id + + usage_key = UsageKey.from_string(data["locator"]) + item = modulestore().get_item(usage_key) + assert item.upstream == self.video_lib_id + assert item.edx_video_id is not None + + class PostDownstreamSyncViewTest(_DownstreamSyncViewTestMixin, SharedModuleStoreTestCase): """ Test that `POST /api/v2/contentstore/downstreams/.../sync` initiates a sync from the linked upstream. @@ -246,18 +448,23 @@ class PostDownstreamSyncViewTest(_DownstreamSyncViewTestMixin, SharedModuleStore return self.client.post(f"/api/contentstore/v2/downstreams/{usage_key_string}/sync") @patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable) - @patch.object(downstreams_views, "sync_from_upstream") - def test_200(self, mock_sync_from_upstream): + @patch.object(xblock_view_handlers, "import_static_assets_for_library_sync", return_value=StaticFileNotices()) + @patch.object(downstreams_views, "clear_transcripts") + def test_200(self, mock_import_staged_content, mock_clear_transcripts): """ Does the happy path work? """ self.client.login(username="superuser", password="password") response = self.call_api(self.downstream_video_key) assert response.status_code == 200 - assert mock_sync_from_upstream.call_count == 1 + assert mock_import_staged_content.call_count == 1 + assert mock_clear_transcripts.call_count == 1 -class DeleteDownstreamSyncViewtest(_DownstreamSyncViewTestMixin, SharedModuleStoreTestCase): +class DeleteDownstreamSyncViewtest( + _DownstreamSyncViewTestMixin, + SharedModuleStoreTestCase, +): """ Test that `DELETE /api/v2/contentstore/downstreams/.../sync` declines a sync from the linked upstream. """ @@ -274,3 +481,504 @@ class DeleteDownstreamSyncViewtest(_DownstreamSyncViewTestMixin, SharedModuleSto response = self.call_api(self.downstream_video_key) assert response.status_code == 204 assert mock_decline_sync.call_count == 1 + + +@ddt.ddt +class GetUpstreamViewTest( + _BaseDownstreamViewTestMixin, + SharedModuleStoreTestCase, +): + """ + Test that `GET /api/v2/contentstore/downstreams-all?...` returns list of links based on the provided filter. + """ + + def call_api( + self, + course_id: str | None = None, + ready_to_sync: bool | None = None, + upstream_key: str | None = None, + item_type: str | None = None, + ): + data = {} + if course_id is not None: + data["course_id"] = str(course_id) + if ready_to_sync is not None: + data["ready_to_sync"] = str(ready_to_sync) + if upstream_key is not None: + data["upstream_key"] = str(upstream_key) + if item_type is not None: + data["item_type"] = str(item_type) + return self.client.get("/api/contentstore/v2/downstreams-all/", data=data) + + def test_200_all_downstreams_for_a_course(self): + """ + Returns all links for given course + """ + self.client.login(username="course_user", password="password") + response = self.call_api(course_id=self.course.id) + assert response.status_code == 200 + data = response.json() + date_format = self.now.isoformat().split("+")[0] + 'Z' + expected = [ + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.downstream_video_key), + 'id': 1, + 'ready_to_sync': False, + 'updated': date_format, + 'upstream_context_key': self.library_id, + 'upstream_context_title': self.library_title, + 'upstream_key': self.video_lib_id, + 'upstream_type': 'component', + 'upstream_version': 1, + 'version_declined': None, + 'version_synced': 1 + }, + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.downstream_html_key), + 'id': 2, + 'ready_to_sync': True, + 'updated': date_format, + 'upstream_context_key': self.library_id, + 'upstream_context_title': self.library_title, + 'upstream_key': self.html_lib_id, + 'upstream_type': 'component', + 'upstream_version': 2, + 'version_declined': None, + 'version_synced': 1, + }, + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.downstream_chapter_key), + 'id': 1, + 'ready_to_sync': False, + 'updated': date_format, + 'upstream_context_key': self.library_id, + 'upstream_context_title': self.library_title, + 'upstream_key': self.section_id, + 'upstream_type': 'container', + 'upstream_version': 1, + 'version_declined': None, + 'version_synced': 1, + }, + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.downstream_sequential_key), + 'id': 2, + 'ready_to_sync': False, + 'updated': date_format, + 'upstream_context_key': self.library_id, + 'upstream_context_title': self.library_title, + 'upstream_key': self.subsection_id, + 'upstream_type': 'container', + 'upstream_version': 1, + 'version_declined': None, + 'version_synced': 1, + }, + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.downstream_unit_key), + 'id': 3, + 'ready_to_sync': True, + 'updated': date_format, + 'upstream_context_key': self.library_id, + 'upstream_context_title': self.library_title, + 'upstream_key': self.unit_id, + 'upstream_type': 'container', + 'upstream_version': 2, + 'version_declined': None, + 'version_synced': 1 + }, + ] + self.assertListEqual(data["results"], expected) + self.assertEqual(data["count"], 5) + + def test_permission_denied_with_course_filter(self): + self.client.login(username="simple_user", password="password") + response = self.call_api(course_id=self.course.id) + assert response.status_code == 403 + + def test_200_component_downstreams_for_a_course(self): + """ + Returns all component links for given course + """ + self.client.login(username="course_user", password="password") + response = self.call_api( + course_id=self.course.id, + item_type='components', + ) + assert response.status_code == 200 + data = response.json() + date_format = self.now.isoformat().split("+")[0] + 'Z' + expected = [ + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.downstream_video_key), + 'id': 1, + 'ready_to_sync': False, + 'updated': date_format, + 'upstream_context_key': self.library_id, + 'upstream_context_title': self.library_title, + 'upstream_key': self.video_lib_id, + 'upstream_type': 'component', + 'upstream_version': 1, + 'version_declined': None, + 'version_synced': 1 + }, + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.downstream_html_key), + 'id': 2, + 'ready_to_sync': True, + 'updated': date_format, + 'upstream_context_key': self.library_id, + 'upstream_context_title': self.library_title, + 'upstream_key': self.html_lib_id, + 'upstream_type': 'component', + 'upstream_version': 2, + 'version_declined': None, + 'version_synced': 1, + }, + ] + self.assertListEqual(data["results"], expected) + self.assertEqual(data["count"], 2) + + def test_200_container_downstreams_for_a_course(self): + """ + Returns all container links for given course + """ + self.client.login(username="course_user", password="password") + response = self.call_api( + course_id=self.course.id, + item_type='containers', + ) + assert response.status_code == 200 + data = response.json() + date_format = self.now.isoformat().split("+")[0] + 'Z' + expected = [ + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.downstream_chapter_key), + 'id': 1, + 'ready_to_sync': False, + 'updated': date_format, + 'upstream_context_key': self.library_id, + 'upstream_context_title': self.library_title, + 'upstream_key': self.section_id, + 'upstream_type': 'container', + 'upstream_version': 1, + 'version_declined': None, + 'version_synced': 1, + }, + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.downstream_sequential_key), + 'id': 2, + 'ready_to_sync': False, + 'updated': date_format, + 'upstream_context_key': self.library_id, + 'upstream_context_title': self.library_title, + 'upstream_key': self.subsection_id, + 'upstream_type': 'container', + 'upstream_version': 1, + 'version_declined': None, + 'version_synced': 1, + }, + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.downstream_unit_key), + 'id': 3, + 'ready_to_sync': True, + 'updated': date_format, + 'upstream_context_key': self.library_id, + 'upstream_context_title': self.library_title, + 'upstream_key': self.unit_id, + 'upstream_type': 'container', + 'upstream_version': 2, + 'version_declined': None, + 'version_synced': 1 + }, + ] + self.assertListEqual(data["results"], expected) + self.assertEqual(data["count"], 3) + + @ddt.data( + ('all', 2), + ('components', 1), + ('containers', 1), + ) + @ddt.unpack + def test_200_downstreams_ready_to_sync(self, item_type, expected_count): + """ + Returns all links that are syncable + """ + self.client.login(username="superuser", password="password") + response = self.call_api( + ready_to_sync=True, + item_type=item_type, + ) + assert response.status_code == 200 + data = response.json() + self.assertTrue(all(o["ready_to_sync"] for o in data["results"])) + self.assertEqual(data["count"], expected_count) + + def test_permission_denied_without_filter(self): + self.client.login(username="simple_user", password="password") + response = self.call_api() + assert response.status_code == 403 + + def test_200_component_downstream_context_list(self): + """ + Returns all entity downstream links for given component + """ + self.client.login(username="lib_user", password="password") + response = self.call_api(upstream_key=self.video_lib_id) + assert response.status_code == 200 + data = response.json() + expected = [str(self.downstream_video_key)] + [str(key) for key in self.another_video_keys] + got = [str(o["downstream_usage_key"]) for o in data["results"]] + self.assertListEqual(got, expected) + self.assertEqual(data["count"], 4) + + def test_200_container_downstream_context_list(self): + """ + Returns all entity downstream links for given container + """ + self.client.login(username="lib_user", password="password") + response = self.call_api(upstream_key=self.unit_id) + assert response.status_code == 200 + data = response.json() + expected = [str(self.downstream_unit_key)] + got = [str(o["downstream_usage_key"]) for o in data["results"]] + self.assertListEqual(got, expected) + self.assertEqual(data["count"], 1) + + +class GetComponentUpstreamViewTest( + _BaseDownstreamViewTestMixin, + SharedModuleStoreTestCase, +): + """ + Test that `GET /api/v2/contentstore/downstreams?...` returns list of component links based on the provided filter. + """ + def call_api( + self, + course_id: str | None = None, + ready_to_sync: bool | None = None, + upstream_usage_key: str | None = None, + ): + data = {} + if course_id is not None: + data["course_id"] = str(course_id) + if ready_to_sync is not None: + data["ready_to_sync"] = str(ready_to_sync) + if upstream_usage_key is not None: + data["upstream_usage_key"] = str(upstream_usage_key) + return self.client.get("/api/contentstore/v2/downstreams/", data=data) + + def test_200_all_component_downstreams_for_a_course(self): + """ + Returns all component links for given course + """ + self.client.login(username="superuser", password="password") + response = self.call_api(course_id=self.course.id) + assert response.status_code == 200 + data = response.json() + date_format = self.now.isoformat().split("+")[0] + 'Z' + expected = [ + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.downstream_video_key), + 'id': 1, + 'ready_to_sync': False, + 'updated': date_format, + 'upstream_context_key': self.library_id, + 'upstream_context_title': self.library_title, + 'upstream_usage_key': self.video_lib_id, + 'upstream_version': 1, + 'version_declined': None, + 'version_synced': 1 + }, + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.downstream_html_key), + 'id': 2, + 'ready_to_sync': True, + 'updated': date_format, + 'upstream_context_key': self.library_id, + 'upstream_context_title': self.library_title, + 'upstream_usage_key': self.html_lib_id, + 'upstream_version': 2, + 'version_declined': None, + 'version_synced': 1, + }, + ] + self.assertListEqual(data["results"], expected) + self.assertEqual(data["count"], 2) + + def test_200_all_component_downstreams_ready_to_sync(self): + """ + Returns all component links that are syncable + """ + self.client.login(username="superuser", password="password") + response = self.call_api(ready_to_sync=True) + assert response.status_code == 200 + data = response.json() + self.assertTrue(all(o["ready_to_sync"] for o in data["results"])) + self.assertEqual(data["count"], 1) + + def test_200_component_downstream_context_list(self): + """ + Returns all component downstream courses for given library block + """ + self.client.login(username="superuser", password="password") + response = self.call_api(upstream_usage_key=self.video_lib_id) + assert response.status_code == 200 + data = response.json() + expected = [str(self.downstream_video_key)] + [str(key) for key in self.another_video_keys] + got = [str(o["downstream_usage_key"]) for o in data["results"]] + self.assertListEqual(got, expected) + self.assertEqual(data["count"], 4) + + +class GetDownstreamSummaryViewTest( + _BaseDownstreamViewTestMixin, + SharedModuleStoreTestCase, +): + """ + Test that `GET /api/v2/contentstore/downstreams//summary` returns summary of links in course. + """ + def call_api(self, course_id): + return self.client.get(f"/api/contentstore/v2/downstreams/{course_id}/summary") + + @patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable) + def test_200_summary(self): + """ + Does the happy path work? + """ + self.client.login(username="superuser", password="password") + response = self.call_api(str(self.another_course.id)) + assert response.status_code == 200 + data = response.json() + expected = [{ + 'upstream_context_title': 'Test Library 1', + 'upstream_context_key': self.library_id, + 'ready_to_sync_count': 0, + 'total_count': 3, + 'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + }] + self.assertListEqual(data, expected) + response = self.call_api(str(self.course.id)) + assert response.status_code == 200 + data = response.json() + expected = [{ + 'upstream_context_title': 'Test Library 1', + 'upstream_context_key': self.library_id, + 'ready_to_sync_count': 2, + 'total_count': 5, + 'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'), + }] + self.assertListEqual(data, expected) + + +class GetContainerUpstreamViewTest( + _BaseDownstreamViewTestMixin, + SharedModuleStoreTestCase, +): + """ + Test that `GET /api/v2/contentstore/downstream-containers?...` returns list of links based on the provided filter. + """ + def call_api( + self, + course_id: str | None = None, + ready_to_sync: bool | None = None, + upstream_container_key: str | None = None, + ): + data = {} + if course_id is not None: + data["course_id"] = str(course_id) + if ready_to_sync is not None: + data["ready_to_sync"] = str(ready_to_sync) + if upstream_container_key is not None: + data["upstream_container_key"] = str(upstream_container_key) + return self.client.get("/api/contentstore/v2/downstream-containers/", data=data) + + def test_200_all_container_downstreams_for_a_course(self): + """ + Returns all container links for given course + """ + self.client.login(username="superuser", password="password") + response = self.call_api(course_id=self.course.id) + assert response.status_code == 200 + data = response.json() + date_format = self.now.isoformat().split("+")[0] + 'Z' + expected = [ + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.downstream_chapter_key), + 'id': 1, + 'ready_to_sync': False, + 'updated': date_format, + 'upstream_context_key': self.library_id, + 'upstream_context_title': self.library_title, + 'upstream_container_key': self.section_id, + 'upstream_version': 1, + 'version_declined': None, + 'version_synced': 1, + }, + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.downstream_sequential_key), + 'id': 2, + 'ready_to_sync': False, + 'updated': date_format, + 'upstream_context_key': self.library_id, + 'upstream_context_title': self.library_title, + 'upstream_container_key': self.subsection_id, + 'upstream_version': 1, + 'version_declined': None, + 'version_synced': 1, + }, + { + 'created': date_format, + 'downstream_context_key': str(self.course.id), + 'downstream_usage_key': str(self.downstream_unit_key), + 'id': 3, + 'ready_to_sync': True, + 'updated': date_format, + 'upstream_context_key': self.library_id, + 'upstream_context_title': self.library_title, + 'upstream_container_key': self.unit_id, + 'upstream_version': 2, + 'version_declined': None, + 'version_synced': 1 + }, + ] + self.assertListEqual(data["results"], expected) + self.assertEqual(data["count"], 3) + + def test_200_all_downstreams_ready_to_sync(self): + """ + Returns all links that are syncable + """ + self.client.login(username="superuser", password="password") + response = self.call_api(ready_to_sync=True) + assert response.status_code == 200 + data = response.json() + self.assertTrue(all(o["ready_to_sync"] for o in data["results"])) + self.assertEqual(data["count"], 1) diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_home.py index 6905de254f..6a51610ac9 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_home.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_home.py @@ -1,28 +1,21 @@ """ Unit tests for home page view. """ + from collections import OrderedDict from datetime import datetime, timedelta -from unittest.mock import patch import ddt import pytz from django.conf import settings -from django.test import override_settings from django.urls import reverse -from edx_toggles.toggles.testutils import override_waffle_switch from rest_framework import status from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import reverse_course_url -from cms.djangoapps.contentstore.views.course import ENABLE_GLOBAL_STAFF_OPTIMIZATION from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory -FEATURES_WITH_HOME_PAGE_COURSE_V2_API = settings.FEATURES.copy() -FEATURES_WITH_HOME_PAGE_COURSE_V2_API['ENABLE_HOME_PAGE_COURSE_API_V2'] = True - -@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API) @ddt.ddt class HomePageCoursesViewV2Test(CourseTestCase): """ @@ -45,6 +38,7 @@ class HomePageCoursesViewV2Test(CourseTestCase): org=archived_course_key.org, end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC), ) + self.non_staff_client, _ = self.create_non_staff_authed_user_client() def test_home_page_response(self): """Get list of courses available to the logged in user. @@ -103,30 +97,6 @@ class HomePageCoursesViewV2Test(CourseTestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertDictEqual(expected_response, response.data) - @override_waffle_switch(ENABLE_GLOBAL_STAFF_OPTIMIZATION, True) - def test_org_query_if_passed(self): - """Get list of courses when org filter passed as a query param. - - Expected result: - - A list of courses available to the logged in user for the specified org. - """ - response = self.client.get(self.api_v2_url, {"org": "demo-org"}) - - self.assertEqual(len(response.data['results']['courses']), 1) - self.assertEqual(response.status_code, status.HTTP_200_OK) - - @override_waffle_switch(ENABLE_GLOBAL_STAFF_OPTIMIZATION, True) - def test_org_query_if_empty(self): - """Get home page with an empty org query param. - - Expected result: - - An empty list of courses available to the logged in user. - """ - response = self.client.get(self.api_v2_url) - - self.assertEqual(len(response.data['results']['courses']), 0) - self.assertEqual(response.status_code, status.HTTP_200_OK) - def test_active_only_query_if_passed(self): """Get list of active courses only. @@ -233,17 +203,98 @@ class HomePageCoursesViewV2Test(CourseTestCase): self.assertEqual(response.data["count"], 2) self.assertEqual(response.status_code, status.HTTP_200_OK) - @patch("cms.djangoapps.contentstore.views.course.CourseOverview") - @patch("cms.djangoapps.contentstore.views.course.modulestore") - def test_api_v2_is_disabled(self, mock_modulestore, mock_course_overview): - """Get list of courses when home page course v2 API is disabled. + @ddt.data( + ("active_only", "true"), + ("archived_only", "true"), + ("search", "sample"), + ("order", "org"), + ("page", 1), + ) + @ddt.unpack + def test_if_empty_list_of_courses(self, query_param, value): + """Get list of courses when no courses are available. Expected result: - - Courses are read from the modulestore. + - An empty list of courses available to the logged in user. """ - with override_settings(FEATURES={'ENABLE_HOME_PAGE_COURSE_API_V2': False}): - response = self.client.get(self.api_v1_url) + self.active_course.delete() + self.archived_course.delete() + + response = self.client.get(self.api_v2_url, {query_param: value}) + + self.assertEqual(len(response.data['results']['courses']), 0) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + @ddt.data( + ("active_only", "true", 2, 0), + ("archived_only", "true", 0, 1), + ("search", "foo", 1, 0), + ("search", "demo", 0, 1), + ("order", "org", 2, 1), + ("order", "display_name", 2, 1), + ("order", "number", 2, 1), + ("order", "run", 2, 1) + ) + @ddt.unpack + def test_filter_and_ordering_courses( + self, + filter_key, + filter_value, + expected_active_length, + expected_archived_length + ): + """Get list of courses when filter and ordering are applied. + + This test creates two courses besides the default courses created in the setUp method. + Then filters and orders them based on the filter_key and filter_value passed as query parameters. + + Expected result: + - A list of courses available to the logged in user for the specified filter and order. + """ + archived_course_key = self.store.make_course_key("demo-org", "demo-number", "demo-run") + CourseOverviewFactory.create( + display_name="Course (Demo)", + id=archived_course_key, + org=archived_course_key.org, + end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC), + ) + active_course_key = self.store.make_course_key("foo-org", "foo-number", "foo-run") + CourseOverviewFactory.create( + display_name="Course (Foo)", + id=active_course_key, + org=active_course_key.org, + ) + + response = self.client.get(self.api_v2_url, {filter_key: filter_value}) self.assertEqual(response.status_code, status.HTTP_200_OK) - mock_modulestore().get_course_summaries.assert_called_once() - mock_course_overview.get_all_courses.assert_not_called() + self.assertEqual( + len([course for course in response.data["results"]["courses"] if course["is_active"]]), + expected_active_length + ) + self.assertEqual( + len([course for course in response.data["results"]["courses"] if not course["is_active"]]), + expected_archived_length + ) + + @ddt.data( + ("active_only", "true"), + ("archived_only", "true"), + ("search", "sample"), + ("order", "org"), + ("page", 1), + ) + @ddt.unpack + def test_if_empty_list_of_courses_non_staff(self, query_param, value): + """Get list of courses when no courses are available for non-staff users. + + Expected result: + - An empty list of courses available to the logged in user. + """ + self.active_course.delete() + self.archived_course.delete() + + response = self.non_staff_client.get(self.api_v2_url, {query_param: value}) + + self.assertEqual(len(response.data["results"]["courses"]), 0) + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/cms/djangoapps/contentstore/signals/handlers.py b/cms/djangoapps/contentstore/signals/handlers.py index d756424bcc..263bf632f5 100644 --- a/cms/djangoapps/contentstore/signals/handlers.py +++ b/cms/djangoapps/contentstore/signals/handlers.py @@ -12,8 +12,23 @@ from django.db import transaction from django.dispatch import receiver from edx_toggles.toggles import SettingToggle from opaque_keys.edx.keys import CourseKey -from openedx_events.content_authoring.data import CourseCatalogData, CourseScheduleData -from openedx_events.content_authoring.signals import COURSE_CATALOG_INFO_CHANGED +from openedx_events.content_authoring.data import ( + CourseCatalogData, + CourseData, + CourseScheduleData, + LibraryBlockData, + LibraryContainerData, + XBlockData, +) +from openedx_events.content_authoring.signals import ( + COURSE_CATALOG_INFO_CHANGED, + COURSE_IMPORT_COMPLETED, + LIBRARY_BLOCK_DELETED, + LIBRARY_CONTAINER_DELETED, + XBLOCK_CREATED, + XBLOCK_DELETED, + XBLOCK_UPDATED, +) from pytz import UTC from cms.djangoapps.contentstore.courseware_index import ( @@ -29,6 +44,15 @@ from openedx.core.djangoapps.discussions.tasks import update_discussions_setting from openedx.core.lib.gating import api as gating_api from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import SignalHandler, modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError + +from ..models import ComponentLink, ContainerLink +from ..tasks import ( + create_or_update_upstream_links, + handle_create_or_update_xblock_upstream_link, + handle_unlink_upstream_block, + handle_unlink_upstream_container, +) from .signals import GRADING_POLICY_CHANGED log = logging.getLogger(__name__) @@ -107,6 +131,8 @@ def emit_catalog_info_changed_signal(course_key: CourseKey): if SEND_CATALOG_INFO_SIGNAL.is_enabled(): timestamp, catalog_info = _create_catalog_data_for_signal(course_key) if catalog_info is not None: + # .. event_implemented_name: COURSE_CATALOG_INFO_CHANGED + # .. event_type: org.openedx.content_authoring.course.catalog_info.changed.v1 COURSE_CATALOG_INFO_CHANGED.send_event(time=timestamp, catalog_info=catalog_info) @@ -197,12 +223,19 @@ def handle_item_deleted(**kwargs): # Strip branch info usage_key = usage_key.for_branch(None) course_key = usage_key.course_key - deleted_block = modulestore().get_item(usage_key) + try: + deleted_block = modulestore().get_item(usage_key) + except ItemNotFoundError: + return + id_list = {deleted_block.location} for block in yield_dynamic_block_descendants(deleted_block, kwargs.get('user_id')): # Remove prerequisite milestone data gating_api.remove_prerequisite(block.location) # Remove any 'requires' course content milestone relationships gating_api.set_required_content(course_key, block.location, None, None, None) + id_list.add(block.location) + + ComponentLink.objects.filter(downstream_usage_key__in=id_list).delete() @receiver(GRADING_POLICY_CHANGED) @@ -224,3 +257,78 @@ def handle_grading_policy_changed(sender, **kwargs): task_id=result.task_id, kwargs=kwargs, )) + + +@receiver(XBLOCK_CREATED) +@receiver(XBLOCK_UPDATED) +def create_or_update_upstream_downstream_link_handler(**kwargs): + """ + Automatically create or update upstream->downstream link in database. + """ + xblock_info = kwargs.get("xblock_info", None) + if not xblock_info or not isinstance(xblock_info, XBlockData): + log.error("Received null or incorrect data for event") + return + + handle_create_or_update_xblock_upstream_link.delay(str(xblock_info.usage_key)) + + +@receiver(XBLOCK_DELETED) +def delete_upstream_downstream_link_handler(**kwargs): + """ + Delete upstream->downstream link from database on xblock delete. + """ + xblock_info = kwargs.get("xblock_info", None) + if not xblock_info or not isinstance(xblock_info, XBlockData): + log.error("Received null or incorrect data for event") + return + + ComponentLink.objects.filter( + downstream_usage_key=xblock_info.usage_key + ).delete() + ContainerLink.objects.filter( + downstream_usage_key=xblock_info.usage_key + ).delete() + + +@receiver(COURSE_IMPORT_COMPLETED) +def handle_new_course_import(**kwargs): + """ + Automatically create upstream->downstream links for course in database on new import. + """ + course_data = kwargs.get("course", None) + if not course_data or not isinstance(course_data, CourseData): + log.error("Received null or incorrect data for event") + return + + create_or_update_upstream_links.delay( + str(course_data.course_key), + force=True, + replace=True + ) + + +@receiver(LIBRARY_BLOCK_DELETED) +def unlink_upstream_block_handler(**kwargs): + """ + Handle unlinking the upstream (library) block from any downstream (course) blocks. + """ + library_block = kwargs.get("library_block", None) + if not library_block or not isinstance(library_block, LibraryBlockData): + log.error("Received null or incorrect data for event") + return + + handle_unlink_upstream_block.delay(str(library_block.usage_key)) + + +@receiver(LIBRARY_CONTAINER_DELETED) +def unlink_upstream_container_handler(**kwargs): + """ + Handle unlinking the upstream (library) container from any downstream (course) blocks. + """ + library_container = kwargs.get("library_container", None) + if not library_container or not isinstance(library_container, LibraryContainerData): # pragma: no cover + log.error("Received null or incorrect data for event") + return + + handle_unlink_upstream_container.delay(str(library_container.container_key)) diff --git a/cms/djangoapps/contentstore/storage.py b/cms/djangoapps/contentstore/storage.py index 77efa4130e..83308ebd86 100644 --- a/cms/djangoapps/contentstore/storage.py +++ b/cms/djangoapps/contentstore/storage.py @@ -4,7 +4,7 @@ Storage backend for course import and export. from django.conf import settings -from django.core.files.storage import get_storage_class +from common.djangoapps.util.storage import resolve_storage_backend from storages.backends.s3boto3 import S3Boto3Storage from storages.utils import setting @@ -19,4 +19,7 @@ class ImportExportS3Storage(S3Boto3Storage): # pylint: disable=abstract-method super().__init__(bucket_name=bucket, custom_domain=None, querystring_auth=True) # pylint: disable=invalid-name -course_import_export_storage = get_storage_class(settings.COURSE_IMPORT_EXPORT_STORAGE)() +course_import_export_storage = resolve_storage_backend( + storage_key="course_import_export", + legacy_setting_key="COURSE_IMPORT_EXPORT_STORAGE" +) diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index bb220c3717..239fb89840 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -2,16 +2,19 @@ This file contains celery tasks for contentstore views """ +import asyncio import base64 import json import os +import re import shutil import tarfile -from datetime import datetime +from datetime import datetime, timezone +from importlib.metadata import entry_points from tempfile import NamedTemporaryFile, mkdtemp +import aiohttp import olxcleaner -import pkg_resources from ccx_keys.locator import CCXLocator from celery import shared_task from celery.utils.log import get_task_logger @@ -25,15 +28,16 @@ from edx_django_utils.monitoring import ( set_code_owner_attribute, set_code_owner_attribute_from_module, set_custom_attribute, - set_custom_attributes_for_course_key + set_custom_attributes_for_course_key, ) from olxcleaner.exceptions import ErrorLevel from olxcleaner.reporting import report_error_summary, report_errors -from opaque_keys.edx.keys import CourseKey -from opaque_keys.edx.locator import LibraryLocator +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.locator import LibraryLocator, LibraryContainerLocator from organizations.api import add_organization_course, ensure_organization from organizations.exceptions import InvalidOrganizationException -from organizations.models import Organization, OrganizationCourse +from organizations.models import Organization from path import Path as path from pytz import UTC from user_tasks.models import UserTaskArtifact, UserTaskStatus @@ -43,28 +47,33 @@ import cms.djangoapps.contentstore.errors as UserErrors from cms.djangoapps.contentstore.courseware_index import ( CoursewareSearchIndexer, LibrarySearchIndexer, - SearchIndexingError + SearchIndexingError, ) from cms.djangoapps.contentstore.storage import course_import_export_storage from cms.djangoapps.contentstore.utils import ( IMPORTABLE_FILE_TYPES, + create_or_update_xblock_upstream_link, + delete_course, initialize_permissions, reverse_usage_url, translation_language, - delete_course ) +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import get_block_info from cms.djangoapps.models.settings.course_metadata import CourseMetadata from common.djangoapps.course_action_state.models import CourseRerunState +from common.djangoapps.static_replace import replace_static_urls from common.djangoapps.student.auth import has_course_author_access from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, LibraryUserRole from common.djangoapps.util.monitoring import monitor_import_failure from openedx.core.djangoapps.content.learning_sequences.api import key_supports_outlines from openedx.core.djangoapps.content_libraries import api as v2contentlib_api +from openedx.core.djangoapps.content_tagging.api import make_copied_tags_editable from openedx.core.djangoapps.course_apps.toggles import exams_ida_enabled from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider from openedx.core.djangoapps.discussions.tasks import update_unit_discussion_state_from_discussion_blocks from openedx.core.djangoapps.embargo.models import CountryAccessRule, RestrictedCourse +from openedx.core.lib import ensure_cms from openedx.core.lib.extract_archive import safe_extractall from xmodule.contentstore.django import contentstore from xmodule.course_block import CourseFields @@ -74,6 +83,8 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import DuplicateCourseError, InvalidProctoringProvider, ItemNotFoundError from xmodule.modulestore.xml_exporter import export_course_to_xml, export_library_to_xml from xmodule.modulestore.xml_importer import CourseImportException, import_course_from_xml, import_library_from_xml + +from .models import ContainerLink, LearningContextLinksStatus, LearningContextLinksStatusChoices, ComponentLink from .outlines import update_outline_from_modulestore from .outlines_regenerate import CourseOutlineRegenerate from .toggles import bypass_olx_failure_enabled @@ -85,8 +96,26 @@ LOGGER = get_task_logger(__name__) FILE_READ_CHUNK = 1024 # bytes FULL_COURSE_REINDEX_THRESHOLD = 1 ALL_ALLOWED_XBLOCKS = frozenset( - [entry_point.name for entry_point in pkg_resources.iter_entry_points("xblock.v1")] + [entry_point.name for entry_point in entry_points(group="xblock.v1")] ) +DEFAULT_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/115.0.0.0 Safari/537.36" + ), + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Connection": "keep-alive", +} + + +class LinkState: + """ + Links State Enumeration + """ + BROKEN = 'broken' + LOCKED = 'locked' + EXTERNAL_FORBIDDEN = 'external-forbidden' def clone_instance(instance, field_values): @@ -143,12 +172,6 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i # call edxval to attach videos to the rerun copy_course_videos(source_course_key, destination_course_key) - # Copy OrganizationCourse - organization_course = OrganizationCourse.objects.filter(course_id=source_course_key_string).first() - - if organization_course: - clone_instance(organization_course, {'course_id': destination_course_key_string}) - # Copy RestrictedCourse restricted_course = RestrictedCourse.objects.filter(course_key=source_course_key).first() @@ -158,7 +181,7 @@ def rerun_course(source_course_key_string, destination_course_key_string, user_i for country_access_rule in country_access_rules: clone_instance(country_access_rule, {'restricted_course': new_restricted_course}) - org_data = ensure_organization(source_course_key.org) + org_data = ensure_organization(destination_course_key.org) add_organization_course(org_data, destination_course_key) return "succeeded" @@ -453,12 +476,12 @@ def sync_discussion_settings(course_key, user): if ( ENABLE_NEW_STRUCTURE_DISCUSSIONS.is_enabled() - and not course.discussions_settings['provider_type'] == Provider.OPEN_EDX + and not course.discussions_settings.get('provider_type', None) == Provider.OPEN_EDX + and not course.discussions_settings.get('provider', None) == Provider.OPEN_EDX ): LOGGER.info(f"New structure is enabled, also updating {course_key} to use new provider") course.discussions_settings['enable_graded_units'] = False course.discussions_settings['unit_level_visibility'] = True - course.discussions_settings['provider'] = Provider.OPEN_EDX course.discussions_settings['provider_type'] = Provider.OPEN_EDX modulestore().update_item(course, user.id) @@ -890,7 +913,7 @@ def copy_v1_user_roles_into_v2_library(v2_library_key, v1_library_key): def _create_copy_content_task(v2_library_key, v1_library_key): """ spin up a celery task to import the V1 Library's content into the V2 library. - This utalizes the fact that course and v1 library content is stored almost identically. + This utilizes the fact that course and v1 library content is stored almost identically. """ return v2contentlib_api.import_blocks_create_task( v2_library_key, v1_library_key, @@ -1066,3 +1089,444 @@ def undo_all_library_source_blocks_ids_for_course(course_key_string, v1_to_v2_li store.update_item(draft_library_source_block, None) # return success return + + +class CourseLinkCheckTask(UserTask): # pylint: disable=abstract-method + """ + Base class for course link check tasks. + """ + + @staticmethod + def calculate_total_steps(arguments_dict): + """ + Get the number of in-progress steps in the link check process, as shown in the UI. + + For reference, these are: + 1. Scanning + """ + return 1 + + @classmethod + def generate_name(cls, arguments_dict): + """ + Create a name for this particular task instance. + + Arguments: + arguments_dict (dict): The arguments given to the task function + + Returns: + str: The generated name + """ + key = arguments_dict['course_key_string'] + return f'Broken link check of {key}' + + +# -------------- Course optimizer functions ------------------ + + +@shared_task(base=CourseLinkCheckTask, bind=True) +# Note: The decorator @set_code_owner_attribute cannot be used here because the UserTaskMixin +# does stack inspection and can't handle additional decorators. +def check_broken_links(self, user_id, course_key_string, language): + """ + Checks for broken links in a course and store the results in a file. + """ + set_code_owner_attribute_from_module(__name__) + return _check_broken_links(self, user_id, course_key_string, language) + + +def _check_broken_links(task_instance, user_id, course_key_string, language): + """ + Checks for broken links in a course and store the results in a file. + """ + user = _validate_user(task_instance, user_id, language) + + task_instance.status.set_state(UserTaskStatus.IN_PROGRESS) + course_key = CourseKey.from_string(course_key_string) + + url_list = _scan_course_for_links(course_key) + validated_url_list = asyncio.run(_validate_urls_access_in_batches(url_list, course_key, batch_size=100)) + broken_or_locked_urls, retry_list = _filter_by_status(validated_url_list) + + if retry_list: + retry_results = _retry_validation(retry_list, course_key, retry_count=3) + broken_or_locked_urls.extend(retry_results) + + try: + task_instance.status.increment_completed_steps() + + file_name = str(course_key) + broken_links_file = NamedTemporaryFile(prefix=file_name + '.', suffix='.json') + LOGGER.debug(f'[Link Check] json file being generated at {broken_links_file.name}') + + with open(broken_links_file.name, 'w') as file: + json.dump(broken_or_locked_urls, file, indent=4) + + _write_broken_links_to_file(broken_or_locked_urls, broken_links_file) + + artifact = UserTaskArtifact(status=task_instance.status, name='BrokenLinks') + _save_broken_links_file(artifact, broken_links_file) + + # catch all exceptions so we can record useful error messages + except Exception as e: # pylint: disable=broad-except + LOGGER.exception('Error checking links for course %s', course_key, exc_info=True) + if task_instance.status.state != UserTaskStatus.FAILED: + task_instance.status.fail({'raw_error_msg': str(e)}) + + +def _validate_user(task, user_id, language): + """Validate if the user exists. Otherwise log an unknown user id error.""" + try: + return User.objects.get(pk=user_id) + except User.DoesNotExist as exc: + with translation_language(language): + task.status.fail(UserErrors.UNKNOWN_USER_ID.format(user_id)) + return + + +def _scan_course_for_links(course_key): + """ + Scans a course for links found in the data contents of blocks. + + Returns: + list: block id and URL pairs + + Example return: + [ + [block_id1, url1], + [block_id2, url2], + ... + ] + """ + verticals = modulestore().get_items( + course_key, + qualifiers={'category': 'vertical'}, + revision=ModuleStoreEnum.RevisionOption.published_only + ) + blocks = [] + urls_to_validate = [] + + for vertical in verticals: + blocks.extend(vertical.get_children()) + + for block in blocks: + # Excluding 'drag-and-drop-v2' as it contains data of object type instead of string, causing errors, + # and it doesn't contain user-facing links to scan. + if block.category == 'drag-and-drop-v2': + continue + block_id = str(block.usage_key) + block_info = get_block_info(block) + block_data = block_info['data'] + url_list = _get_urls(block_data) + urls_to_validate += [[block_id, url] for url in url_list] + + return urls_to_validate + + +def _get_urls(content): + """ + Finds and returns a list of URLs in the given content. + Includes strings following 'href=' and 'src='. + Excludes strings that are only '#' or start with 'data:'. + + Arguments: + content (str): entire content of a block + + Returns: + list: urls + """ + regex = r'\s+(?:href|src)=["\'](?!#|data:)([^"\']*)["\']' + url_list = re.findall(regex, content) + return url_list + + +async def _validate_urls_access_in_batches(url_list, course_key, batch_size=100): + """ + Returns the statuses of a list of URL requests. + + Arguments: + url_list (list): block id and URL pairs + + Returns: + list: dictionary containing URL, associated block id, and request status + """ + responses = [] + url_count = len(url_list) + + for i in range(0, url_count, batch_size): + batch = url_list[i:i + batch_size] + batch_results = await _validate_batch(batch, course_key) + responses.extend(batch_results) + LOGGER.debug(f'[Link Check] request batch {i // batch_size + 1} of {url_count // batch_size + 1}') + + return responses + + +async def _validate_batch(batch, course_key): + """Validate a batch of URLs""" + async with aiohttp.ClientSession(headers=DEFAULT_HEADERS) as session: + tasks = [_validate_url_access(session, url_data, course_key) for url_data in batch] + batch_results = await asyncio.gather(*tasks) + return batch_results + + +async def _validate_url_access(session, url_data, course_key): + """ + Validates a URL. + + Arguments: + url_data (list): block id and URL pairs + course_key (str): locator id for a course + + Returns: + dict: URL, associated block id, and request status + + Example return: + { + 'block_id': block_id1, + 'url': url1, + 'status': status + } + """ + block_id, url = url_data + url = url.strip() # Trim leading/trailing whitespace + result = {'block_id': block_id, 'url': url} + standardized_url = _convert_to_standard_url(url, course_key) + try: + async with session.get(standardized_url, timeout=5) as response: + result.update({'status': response.status}) + except Exception as e: # lint-amnesty, pylint: disable=broad-except + result.update({'status': None}) + LOGGER.debug(f'[Link Check] Request error when validating {url}: {str(e)}') + return result + + +def _convert_to_standard_url(url, course_key): + """ + Returns standard URLs when given studio URLs. Otherwise returns the URL as is. + + Example URLs: + /assets/courseware/v1/506da5d6f866e8f0be44c5df8b6e6b2a/... + ...asset-v1:edX+DemoX+Demo_Course+type@asset+block/getting-started_x250.png + /static/getting-started_x250.png + /container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7 + /jump_to_id/2152d4a4aadc4cb0af5256394a3d1fc7 + """ + if _is_studio_url_without_base(url): + if url.startswith('/static/'): + processed_url = replace_static_urls(f'\"{url}\"', course_id=course_key)[1:-1] + return 'https://' + settings.CMS_BASE + processed_url + elif url.startswith('/jump_to_id/'): + return f'https://{settings.LMS_BASE}/courses/{course_key}{url}' + elif url.startswith('/'): + return 'https://' + settings.CMS_BASE + url + else: + return 'https://' + settings.CMS_BASE + '/container/' + url + else: + return url + + +def _is_studio_url(url): + """Returns True if url is a studio url.""" + return _is_studio_url_with_base(url) or _is_studio_url_without_base(url) + + +def _is_studio_url_with_base(url): + """Returns True if url is a studio url with cms base.""" + return url.startswith('http://' + settings.CMS_BASE) or url.startswith('https://' + settings.CMS_BASE) + + +def _is_studio_url_without_base(url): + """Returns True if url is a studio url without cms base.""" + return not url.startswith('http://') and not url.startswith('https://') + + +def _filter_by_status(results): + """ + Filter results by status. + + Statuses: + 200: OK. No need to do more + 403: Forbidden. Record as locked link if it is studio link. + 403: Forbidden. Record as external-forbidden link if it is external link + None: Error. Retry up to 3 times. + Other: Failure. Record as broken link. + + Arguments: + results (list): URL, associated block id, and request status + + Returns: + filtered_results (list): list of block id, URL and if URL is locked + retry_list (list): block id and url pairs + + Example return: + [ + [block_id1, filtered_results_url1, link_state], + ... + ], + [ + [block_id1, retry_url1], + ... + ] + """ + filtered_results = [] + retry_list = [] + for result in results: + status, block_id, url = result['status'], result['block_id'], result['url'] + if status is None and _is_studio_url(url): + retry_list.append([block_id, url]) + elif status == 200: + continue + elif status == 403 and _is_studio_url(url): + filtered_results.append([block_id, url, LinkState.LOCKED]) + elif status in [403, 500, None] and not _is_studio_url(url): + filtered_results.append([block_id, url, LinkState.EXTERNAL_FORBIDDEN]) + else: + filtered_results.append([block_id, url, LinkState.BROKEN]) + + return filtered_results, retry_list + + +def _retry_validation(url_list, course_key, retry_count=3): + """ + Retry validation for URLs that failed due to connection error. + + Returns: + list: URLs that could not be validated due to being locked or due to persistent connection problems + """ + results = [] + retry_list = url_list + for i in range(retry_count): + if retry_list: + LOGGER.debug(f'[Link Check] retry attempt #{i + 1}') + retry_list = _retry_validation_and_filter_results(course_key, results, retry_list) + results.extend(retry_list) + + return results + + +def _retry_validation_and_filter_results(course_key, results, retry_list): + """ + Validates URLs and then filter them by status. + + Arguments: + retry_list: list of urls to retry + + Returns: + list: URLs that did not pass validation and should be retried + """ + validated_url_list = asyncio.run( + _validate_urls_access_in_batches(retry_list, course_key, batch_size=100) + ) + filtered_url_list, retry_list = _filter_by_status(validated_url_list) + results.extend(filtered_url_list) + return retry_list + + +def _save_broken_links_file(artifact, file_to_save): + artifact.file.save(name=os.path.basename(file_to_save.name), content=File(file_to_save)) + artifact.save() + return True + + +def _write_broken_links_to_file(broken_or_locked_urls, broken_links_file): + with open(broken_links_file.name, 'w') as file: + json.dump(broken_or_locked_urls, file, indent=4) + + +@shared_task +@set_code_owner_attribute +def handle_create_or_update_xblock_upstream_link(usage_key): + """ + Create or update upstream link for a single xblock. + """ + ensure_cms("handle_create_or_update_xblock_upstream_link may only be executed in a CMS context") + try: + xblock = modulestore().get_item(UsageKey.from_string(usage_key)) + except (ItemNotFoundError, InvalidKeyError): + LOGGER.exception(f'Could not find item for given usage_key: {usage_key}') + return + if not xblock.upstream or not xblock.upstream_version: + return + create_or_update_xblock_upstream_link(xblock, xblock.course_id) + + +@shared_task +@set_code_owner_attribute +def create_or_update_upstream_links( + course_key_str: str, + force: bool = False, + replace: bool = False, + created: datetime | None = None, +): + """ + A Celery task to create or update upstream downstream links in database from course xblock content. + """ + ensure_cms("create_or_update_upstream_links may only be executed in a CMS context") + + if not created: + created = datetime.now(timezone.utc) + course_status = LearningContextLinksStatus.get_or_create(course_key_str, created) + if course_status.status in [ + LearningContextLinksStatusChoices.COMPLETED, + LearningContextLinksStatusChoices.PROCESSING + ] and not force: + return + store = modulestore() + course_key = CourseKey.from_string(course_key_str) + course_status.update_status( + LearningContextLinksStatusChoices.PROCESSING, + updated=created, + ) + if replace: + ComponentLink.objects.filter(downstream_context_key=course_key).delete() + ContainerLink.objects.filter(downstream_context_key=course_key).delete() + try: + xblocks = store.get_items(course_key, settings={"upstream": lambda x: x is not None}) + except ItemNotFoundError: + LOGGER.exception(f'Could not find items for given course: {course_key}') + course_status.update_status(LearningContextLinksStatusChoices.FAILED) + return + for xblock in xblocks: + create_or_update_xblock_upstream_link(xblock, course_key, created) + course_status.update_status(LearningContextLinksStatusChoices.COMPLETED) + + +@shared_task +@set_code_owner_attribute +def handle_unlink_upstream_block(upstream_usage_key_string: str) -> None: + """ + Handle updates needed to downstream blocks when the upstream link is severed. + """ + ensure_cms("handle_unlink_upstream_block may only be executed in a CMS context") + + try: + upstream_usage_key = UsageKey.from_string(upstream_usage_key_string) + except (InvalidKeyError): + LOGGER.exception(f'Invalid upstream usage_key: {upstream_usage_key_string}') + return + + for link in ComponentLink.objects.filter( + upstream_usage_key=upstream_usage_key, + ): + make_copied_tags_editable(str(link.downstream_usage_key)) + + +@shared_task +@set_code_owner_attribute +def handle_unlink_upstream_container(upstream_container_key_string: str) -> None: + """ + Handle updates needed to downstream blocks when the upstream link is severed. + """ + ensure_cms("handle_unlink_upstream_container may only be executed in a CMS context") + + try: + upstream_container_key = LibraryContainerLocator.from_string(upstream_container_key_string) + except (InvalidKeyError): + LOGGER.exception(f'Invalid upstream container_key: {upstream_container_key_string}') + return + + for link in ContainerLink.objects.filter( + upstream_container_key=upstream_container_key, + ): + make_copied_tags_editable(str(link.downstream_usage_key)) diff --git a/cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py b/cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py new file mode 100644 index 0000000000..149a7a5318 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_bulk_enabledisable_discussions.py @@ -0,0 +1,117 @@ +""" +Test the enable/disable discussions for all units API endpoint. +""" +import json + +from django.urls import reverse +from opaque_keys.edx.keys import CourseKey +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory + +from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient +from common.djangoapps.student.tests.factories import UserFactory + + +class BulkEnableDisableDiscussionsTestCase(ModuleStoreTestCase): + """ + Test the enable/disable discussions for all units API endpoint. + """ + + def setUp(self): + super().setUp() + self.user = UserFactory(is_staff=True, is_superuser=True) + self.user.set_password(self.user_password) + self.user.save() + + self.course_key = CourseKey.from_string("course-v1:edx+TestX+2025") + + self.url = reverse('bulk_enable_disable_discussions', args=[str(self.course_key)]) + self.client = AjaxEnabledTestClient() + self.client.login(username=self.user.username, password=self.user_password) + + # Create a test course + self.course = CourseFactory.create( + org=self.course_key.org, + course=self.course_key.course, + run=self.course_key.run, + default_store=ModuleStoreEnum.Type.split, + display_name="EnableDisableDiscussionsTestCase Course", + ) + with self.store.bulk_operations(self.course_key): + section = BlockFactory.create( + parent=self.course, + category='chapter', + display_name="Generated Section", + ) + sequence = BlockFactory.create( + parent=section, + category='sequential', + display_name="Generated Sequence", + ) + unit1 = BlockFactory.create( + parent=sequence, + category='vertical', + display_name="Unit in Section1", + discussion_enabled=True, + ) + unit2 = BlockFactory.create( + parent=sequence, + category='vertical', + display_name="Unit in Section2", + discussion_enabled=True, + ) + + def test_disable_discussions_for_all_units(self): + """ + Test that the API successfully disables discussions for all units. + """ + self.enable_disable_discussions_for_all_units(False) + + def test_enable_discussions_for_all_units(self): + """ + Test that the API successfully enables discussions for all units. + """ + self.enable_disable_discussions_for_all_units(True) + + def enable_disable_discussions_for_all_units(self, is_enabled): + """ + Test that the API successfully enables/disables discussions for all units. + """ + data = { + "discussion_enabled": is_enabled + } + response = self.client.put(self.url, data=json.dumps(data), content_type='application/json') + self.assertEqual(response.status_code, 200) + response_data = response.json() + print(response_data) + self.assertEqual(response_data['units_updated_and_republished'], 0 if is_enabled else 2) + + # Check that all verticals now have discussion_enabled set to the expected value + with self.store.bulk_operations(self.course_key): + verticals = self.store.get_items(self.course_key, qualifiers={'block_type': 'vertical'}) + for vertical in verticals: + self.assertEqual(vertical.discussion_enabled, is_enabled) + + def test_permission_denied_for_non_staff(self): + """ + Test that non-staff users are denied access to the API. + """ + # Create a non-staff user + non_staff_user = UserFactory(is_staff=False, is_superuser=False) + non_staff_user.set_password(self.user_password) + non_staff_user.save() + + # Create a new client for the non-staff user + non_staff_client = AjaxEnabledTestClient() + non_staff_client.login(username=non_staff_user.username, password=self.user_password) + + response = non_staff_client.put(self.url, content_type='application/json') + self.assertEqual(response.status_code, 403) + + def test_badrequest_for_empty_request_body(self): + """ + Test that the API returns a 400 for an empty request body. + """ + response = self.client.put(self.url, data=json.dumps({}), content_type='application/json') + self.assertEqual(response.status_code, 400) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 39e826b3e4..8b6aa6d2bb 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1,5 +1,9 @@ # lint-amnesty, pylint: disable=missing-module-docstring +# TODO: Rewrite several of these assertions so that they check the output of the REST or Python +# APIs rather than parsing HTML from the deprecated legacy frontend pages. In particular, any +# test case using override_waffle_flag(toggles.LEGACY_STUDIO_*, True) will need to be fixed. +# Part of https://github.com/openedx/edx-platform/issues/36275. import copy import re @@ -17,7 +21,7 @@ from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.test import TestCase from django.test.utils import override_settings -from edx_toggles.toggles.testutils import override_waffle_switch +from edx_toggles.toggles.testutils import override_waffle_switch, override_waffle_flag from edxval.api import create_video, get_videos_for_course from fs.osfs import OSFS from lxml import etree @@ -43,6 +47,7 @@ from xmodule.modulestore.xml_importer import import_course_from_xml, perform_xli from xmodule.seq_block import SequenceBlock from xmodule.video_block import VideoBlock +from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.config import waffle from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient, CourseTestCase, get_url, parse_json from cms.djangoapps.contentstore.utils import ( @@ -587,6 +592,7 @@ class MiscCourseTests(ContentStoreTestCase): for expected in expected_types: self.assertContains(resp, expected) + @override_waffle_flag(toggles.LEGACY_STUDIO_UNIT_EDITOR, True) @ddt.data("", "alert('hi')", "") def test_container_handler_xss_prevent(self, malicious_code): """ @@ -596,6 +602,7 @@ class MiscCourseTests(ContentStoreTestCase): # Test that malicious code does not appear in html self.assertNotContains(resp, malicious_code) + @override_waffle_flag(toggles.LEGACY_STUDIO_UNIT_EDITOR, True) def test_advanced_components_in_edit_unit(self): # This could be made better, but for now let's just assert that we see the advanced modules mentioned in the # page response HTML @@ -697,9 +704,11 @@ class MiscCourseTests(ContentStoreTestCase): # Remove tempdir shutil.rmtree(root_dir) + @override_waffle_flag(toggles.LEGACY_STUDIO_UNIT_EDITOR, True) def test_advanced_components_require_two_clicks(self): self.check_components_on_page(['word_cloud'], ['Word cloud']) + @override_waffle_flag(toggles.LEGACY_STUDIO_UNIT_EDITOR, True) def test_edit_unit(self): """Verifies rendering the editor in all the verticals in the given test course""" self._check_verticals([self.vert_loc]) @@ -1379,6 +1388,7 @@ class ContentStoreTest(ContentStoreTestCase): resp = self.client.ajax_post('/course/', self.course_data) self.assertEqual(resp.status_code, 403) + @override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True) def test_course_index_view_with_no_courses(self): """Test viewing the index page with no courses""" resp = self.client.get_html('/home/') @@ -1400,6 +1410,7 @@ class ContentStoreTest(ContentStoreTestCase): item = BlockFactory.create(parent_location=course.location) self.assertIsInstance(item, SequenceBlock) + @override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True) def test_course_overview_view_with_course(self): """Test viewing the course overview page with an existing course""" course = CourseFactory.create() @@ -1499,7 +1510,8 @@ class ContentStoreTest(ContentStoreTestCase): ) course_key = course_items[0].id - resp = self._show_course_overview(course_key) + with override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True): + resp = self._show_course_overview(course_key) # course_handler raise 404 for old mongo course if course_key.deprecated: @@ -1510,20 +1522,31 @@ class ContentStoreTest(ContentStoreTestCase): self.assertContains(resp, 'Chapter 2') # go to various pages - test_get_html('import_handler') - test_get_html('export_handler') - test_get_html('course_team_handler') - test_get_html('course_info_handler') - test_get_html('assets_handler') - test_get_html('tabs_handler') - test_get_html('settings_handler') - test_get_html('grading_handler') - test_get_html('advanced_settings_handler') - test_get_html('textbooks_list_handler') + with override_waffle_flag(toggles.LEGACY_STUDIO_IMPORT, True): + test_get_html('import_handler') + with override_waffle_flag(toggles.LEGACY_STUDIO_EXPORT, True): + test_get_html('export_handler') + with override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_TEAM, True): + test_get_html('course_team_handler') + with override_waffle_flag(toggles.LEGACY_STUDIO_UPDATES, True): + test_get_html('course_info_handler') + with override_waffle_flag(toggles.LEGACY_STUDIO_FILES_UPLOADS, True): + test_get_html('assets_handler') + with override_waffle_flag(toggles.LEGACY_STUDIO_CUSTOM_PAGES, True): + test_get_html('tabs_handler') + with override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True): + test_get_html('settings_handler') + with override_waffle_flag(toggles.LEGACY_STUDIO_GRADING, True): + test_get_html('grading_handler') + with override_waffle_flag(toggles.LEGACY_STUDIO_ADVANCED_SETTINGS, True): + test_get_html('advanced_settings_handler') + with override_waffle_flag(toggles.LEGACY_STUDIO_TEXTBOOKS, True): + test_get_html('textbooks_list_handler') # go look at the Edit page unit_key = course_key.make_usage_key('vertical', 'test_vertical') - resp = self.client.get_html(get_url('container_handler', unit_key)) + with override_waffle_flag(toggles.LEGACY_STUDIO_UNIT_EDITOR, True): + resp = self.client.get_html(get_url('container_handler', unit_key)) self.assertEqual(resp.status_code, 200) def delete_item(category, name): @@ -1856,20 +1879,23 @@ class RerunCourseTest(ContentStoreTestCase): """ Asserts that the given course key is NOT in the unsucceeded course action section of the html. """ - course_listing = lxml.html.fromstring(self.client.get_html('/home/').content) + with override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True): + course_listing = lxml.html.fromstring(self.client.get_html('/home/').content) self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 0) def assertInUnsucceededCourseActions(self, course_key): """ Asserts that the given course key is in the unsucceeded course action section of the html. """ - course_listing = lxml.html.fromstring(self.client.get_html('/home/').content) + with override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True): + course_listing = lxml.html.fromstring(self.client.get_html('/home/').content) self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 1) def verify_rerun_course(self, source_course_key, destination_course_key, destination_display_name): """ Verify the contents of the course rerun action """ + rerun_state = CourseRerunState.objects.find_first(course_key=destination_course_key) expected_states = { 'state': CourseRerunUIStateManager.State.SUCCEEDED, @@ -2138,9 +2164,13 @@ class EntryPageTestCase(TestCase): resp = self.client.get_html(page) self.assertEqual(resp.status_code, status_code) - def test_how_it_works(self): + @override_waffle_flag(toggles.LEGACY_STUDIO_LOGGED_OUT_HOME, True) + def test_how_it_works_legacy(self): self._test_page("/howitworks") + def test_how_it_works_redirect_to_signin(self): + self._test_page("/howitworks", 302) + def test_signup(self): # deprecated signup url redirects to LMS register. self._test_page("/signup", 301) diff --git a/cms/djangoapps/contentstore/tests/test_course_listing.py b/cms/djangoapps/contentstore/tests/test_course_listing.py index 44084e3595..e46b493b7b 100644 --- a/cms/djangoapps/contentstore/tests/test_course_listing.py +++ b/cms/djangoapps/contentstore/tests/test_course_listing.py @@ -3,16 +3,17 @@ Unit tests for getting the list of courses for a user through iterating all cour by reversing group name formats. """ - import random from unittest.mock import Mock, patch import ddt from ccx_keys.locator import CCXLocator from django.conf import settings -from django.test import RequestFactory, override_settings +from django.test import RequestFactory +from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.locations import CourseLocator +from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient from cms.djangoapps.contentstore.utils import delete_course from cms.djangoapps.contentstore.views.course import ( @@ -35,17 +36,12 @@ from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES -from xmodule.course_block import CourseSummary # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order TOTAL_COURSES_COUNT = 10 USER_COURSES_COUNT = 1 -FEATURES_WITH_HOME_PAGE_COURSE_V2_API = settings.FEATURES.copy() -FEATURES_WITH_HOME_PAGE_COURSE_V2_API['ENABLE_HOME_PAGE_COURSE_API_V2'] = True -FEATURES_WITHOUT_HOME_PAGE_COURSE_V2_API = settings.FEATURES.copy() -FEATURES_WITHOUT_HOME_PAGE_COURSE_V2_API['ENABLE_HOME_PAGE_COURSE_API_V2'] = False @ddt.ddt @@ -93,6 +89,7 @@ class TestCourseListing(ModuleStoreTestCase): self.client.logout() ModuleStoreTestCase.tearDown(self) # pylint: disable=non-parent-method-called + @override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True) def test_empty_course_listing(self): """ Test on empty course listing, studio name is properly displayed @@ -185,19 +182,11 @@ class TestCourseListing(ModuleStoreTestCase): courses_list_by_staff, __ = get_courses_accessible_to_user(self.request) self.assertEqual(len(list(courses_list_by_staff)), TOTAL_COURSES_COUNT) + self.assertTrue(all(isinstance(course, CourseOverview) for course in courses_list_by_staff)) - with override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API): - # Verify fetched accessible courses list is a list of CourseOverview instances when home page course v2 - # api is enabled. - self.assertTrue(all(isinstance(course, CourseOverview) for course in courses_list_by_staff)) - - with override_settings(FEATURES=FEATURES_WITHOUT_HOME_PAGE_COURSE_V2_API): - # Verify fetched accessible courses list is a list of CourseSummery instances - self.assertTrue(all(isinstance(course, CourseSummary) for course in courses_list_by_staff)) - - # Now count the db queries for staff - with check_mongo_calls(2): - list(_accessible_courses_summary_iter(self.request)) + # Now count the db queries for staff + with self.assertNumQueries(2): + list(_accessible_courses_summary_iter(self.request)) def test_get_course_list_with_invalid_course_location(self): """ @@ -212,21 +201,10 @@ class TestCourseListing(ModuleStoreTestCase): courses_list = list(courses_iter) self.assertEqual(len(courses_list), 1) - with override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API): - # Verify fetched accessible courses list is a list of CourseOverview instances when home page course v2 - # api is enabled. - courses_summary_iter, __ = _accessible_courses_summary_iter(self.request) - courses_summary_list = list(courses_summary_iter) - self.assertTrue(all(isinstance(course, CourseOverview) for course in courses_summary_list)) - self.assertEqual(len(courses_summary_list), 1) - - with override_settings(FEATURES=FEATURES_WITHOUT_HOME_PAGE_COURSE_V2_API): - # Verify fetched accessible courses list is a list of CourseSummery instances and only one course - # is returned - courses_summary_iter, __ = _accessible_courses_summary_iter(self.request) - courses_summary_list = list(courses_summary_iter) - self.assertTrue(all(isinstance(course, CourseSummary) for course in courses_summary_list)) - self.assertEqual(len(courses_summary_list), 1) + courses_summary_iter, __ = _accessible_courses_summary_iter(self.request) + courses_summary_list = list(courses_summary_iter) + self.assertTrue(all(isinstance(course, CourseOverview) for course in courses_summary_list)) + self.assertEqual(len(courses_summary_list), 1) # get courses by reversing group name formats courses_list_by_groups, __ = _accessible_courses_list_from_groups(self.request) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 77baf73d90..a72d14e931 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -1,7 +1,15 @@ """ Tests for Studio Course Settings. -""" +# TODO: Remove each `override_waffle_flag(toggles.LEGACY_STUDIO_*)` by Ulmo. For each occurance: +# * If the test case is just testing the legacy frontend, and we've got the underlying +# functionality tested elsewhere, then just delete the whole test case. +# * Otherwise (i.e., the test is using the legacy UI test a unique backend behavior), then +# rewrite the test to make assertions about the output of the Python API (preferred) or +# REST API (if necessary) so that we can delete the legacy UI without sacrificing the test +# coverage. +# Part of https://github.com/openedx/edx-platform/issues/36275. +""" import copy import datetime @@ -20,6 +28,7 @@ from milestones.models import MilestoneRelationshipType from milestones.tests.utils import MilestonesTestCaseMixin from pytz import UTC +from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.utils import reverse_course_url, reverse_usage_url from cms.djangoapps.models.settings.course_grading import ( GRADING_POLICY_CHANGED_EVENT_TYPE, @@ -115,6 +124,7 @@ class CourseAdvanceSettingViewTest(CourseTestCase, MilestonesTestCaseMixin): CourseStaffRole(self.course.id).add_users(self.nonstaff) @override_settings(FEATURES={'DISABLE_MOBILE_COURSE_AVAILABLE': True}) + @override_waffle_flag(toggles.LEGACY_STUDIO_ADVANCED_SETTINGS, True) def test_mobile_field_available(self): """ @@ -137,6 +147,7 @@ class CourseAdvanceSettingViewTest(CourseTestCase, MilestonesTestCaseMixin): (False, True, True) ) @ddt.unpack + @override_waffle_flag(toggles.LEGACY_STUDIO_ADVANCED_SETTINGS, True) def test_discussion_fields_available(self, is_pages_and_resources_enabled, is_legacy_discussion_setting_enabled, fields_visible): """ @@ -152,6 +163,16 @@ class CourseAdvanceSettingViewTest(CourseTestCase, MilestonesTestCaseMixin): self.assertEqual('discussion_topics' in response, fields_visible) @ddt.data(False, True) + @override_waffle_flag(toggles.LEGACY_STUDIO_ADVANCED_SETTINGS, True) + @override_waffle_flag(toggles.LEGACY_STUDIO_IMPORT, True) + @override_waffle_flag(toggles.LEGACY_STUDIO_EXPORT, True) + @override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_TEAM, True) + @override_waffle_flag(toggles.LEGACY_STUDIO_UPDATES, True) + @override_waffle_flag(toggles.LEGACY_STUDIO_FILES_UPLOADS, True) + @override_waffle_flag(toggles.LEGACY_STUDIO_CUSTOM_PAGES, True) + @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) + @override_waffle_flag(toggles.LEGACY_STUDIO_GRADING, True) + @override_waffle_flag(toggles.LEGACY_STUDIO_TEXTBOOKS, True) def test_disable_advanced_settings_feature(self, disable_advanced_settings): """ If this feature is enabled, only Django Staff/Superuser should be able to access the "Advanced Settings" page. @@ -292,6 +313,7 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): (True, True), ) @ddt.unpack + @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) def test_upgrade_deadline(self, has_verified_mode, has_expiration_date): if has_verified_mode: deadline = None @@ -310,6 +332,7 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): self.assertEqual(b"Upgrade Deadline Date" in response.content, has_expiration_date and has_verified_mode) @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PREREQUISITE_COURSES': True}) + @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) def test_pre_requisite_course_list_present(self): settings_details_url = get_url(self.course.id) response = self.client.get_html(settings_details_url) @@ -370,6 +393,7 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): (False, True, False), (True, True, True), ) + @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) def test_visibility_of_entrance_exam_section(self, feature_flags): """ Tests entrance exam section is available if ENTRANCE_EXAMS feature is enabled no matter any other @@ -386,6 +410,7 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): b'

' in resp.content ) + @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) def test_marketing_site_fetch(self): settings_details_url = get_url(self.course.id) @@ -593,6 +618,7 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): assert milestones_helpers.any_unfulfilled_milestones(self.course.id, self.user.id), \ 'The entrance exam should be required.' + @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) def test_editable_short_description_fetch(self): settings_details_url = get_url(self.course.id) @@ -631,6 +657,7 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): self.assertEqual(response.status_code, 200) self.assertEqual(course_details.overview, '

 

') + @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) def test_regular_site_fetch(self): settings_details_url = get_url(self.course.id) @@ -1504,7 +1531,6 @@ class CourseMetadataEditingTest(CourseTestCase): 'test_proctoring_provider': {}, 'proctortrack': {} }, - FEATURES={'ENABLE_EXAM_SETTINGS_HTML_VIEW': True}, ) def test_validate_update_requires_escalation_email_for_proctortrack(self, include_blank_email): json_data = { @@ -1552,7 +1578,6 @@ class CourseMetadataEditingTest(CourseTestCase): 'DEFAULT': 'proctortrack', 'proctortrack': {} }, - FEATURES={'ENABLE_EXAM_SETTINGS_HTML_VIEW': True}, ) def test_validate_update_cannot_unset_escalation_email_when_proctortrack_is_provider(self): course = CourseFactory.create() @@ -1982,6 +2007,7 @@ id=\"course-enrollment-end-time\" value=\"\" placeholder=\"HH:MM\" autocomplete= self.assertNotContains(response, element) @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PUBLISHER': False}) + @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) def test_course_details_with_disabled_setting_global_staff(self): """ Test that user enrollment end date is editable in response. @@ -1992,6 +2018,7 @@ id=\"course-enrollment-end-time\" value=\"\" placeholder=\"HH:MM\" autocomplete= self._verify_editable(self._get_course_details_response(True)) @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PUBLISHER': False}) + @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) def test_course_details_with_disabled_setting_non_global_staff(self): """ Test that user enrollment end date is editable in response. @@ -2002,6 +2029,7 @@ id=\"course-enrollment-end-time\" value=\"\" placeholder=\"HH:MM\" autocomplete= self._verify_editable(self._get_course_details_response(False)) @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PUBLISHER': True}) + @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) def test_course_details_with_enabled_setting_global_staff(self): """ Test that user enrollment end date is editable in response. @@ -2013,6 +2041,7 @@ id=\"course-enrollment-end-time\" value=\"\" placeholder=\"HH:MM\" autocomplete= @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_PUBLISHER': True}) @override_settings(PLATFORM_NAME='edX') + @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) def test_course_details_with_enabled_setting_non_global_staff(self): """ Test that user enrollment end date is not editable in response. diff --git a/cms/djangoapps/contentstore/tests/test_exams.py b/cms/djangoapps/contentstore/tests/test_exams.py index c3c996a696..798b5e51fd 100644 --- a/cms/djangoapps/contentstore/tests/test_exams.py +++ b/cms/djangoapps/contentstore/tests/test_exams.py @@ -1,13 +1,15 @@ """ Test the exams service integration into Studio """ +import itertools from datetime import datetime, timedelta, timezone from unittest.mock import patch, Mock import ddt from django.conf import settings from edx_toggles.toggles.testutils import override_waffle_flag -from pytz import UTC +from freezegun import freeze_time +from pytz import utc from cms.djangoapps.contentstore.signals.handlers import listen_for_course_publish from openedx.core.djangoapps.course_apps.toggles import EXAMS_IDA @@ -17,6 +19,7 @@ from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory @ddt.ddt @override_waffle_flag(EXAMS_IDA, active=True) +@patch.dict('django.conf.settings.FEATURES', {'ENABLE_PROCTORED_EXAMS': True}) @patch.dict('django.conf.settings.FEATURES', {'ENABLE_SPECIAL_EXAMS': True}) @patch('cms.djangoapps.contentstore.exams._patch_course_exams') @patch('cms.djangoapps.contentstore.signals.handlers.transaction.on_commit', @@ -51,27 +54,46 @@ class TestExamService(ModuleStoreTestCase): display_name='Homework 1', graded=True, is_time_limited=False, - due=datetime.now(UTC) + timedelta(minutes=60), + due=datetime.now(utc) + timedelta(minutes=60), ) def _get_exams_url(self, course_id): return f'{settings.EXAMS_SERVICE_URL}/exams/course_id/{course_id}/' - @ddt.data( - (False, False, False, 'timed'), - (True, False, False, 'proctored'), - (True, True, False, 'practice'), - (True, True, True, 'onboarding'), - ) + def _get_exam_due_date(self, course, sequential): + """ + Return the expected exam due date for the exam, based on the selected course proctoring provider and the + exam due date or the course end date. + + Arguments: + * course: the course that the exam subsection is in; may have a course.end attribute + * sequential: the exam subsection; may have a sequential.due attribute + """ + if course.proctoring_provider == 'lti_external': + return sequential.due.isoformat() if sequential.due else (course.end.isoformat() if course.end else None) + elif course.self_paced: + return None + else: + return sequential.due + + @ddt.data(*(tuple(base) + (extra,) for base, extra in itertools.product( + [ + (False, False, False, 'timed'), + (True, False, False, 'proctored'), + (True, True, False, 'practice'), + (True, True, True, 'onboarding'), + ], + ('null', 'lti_external') + ))) @ddt.unpack + @freeze_time('2024-01-01') def test_publishing_exam(self, is_proctored_exam, is_practice_exam, - is_onboarding_exam, expected_type, mock_patch_course_exams): + is_onboarding_exam, expected_type, proctoring_provider, mock_patch_course_exams): """ When a course is published it will register all exams sections with the exams service """ default_time_limit_minutes = 10 - due_date = datetime.now(UTC) + timedelta(minutes=default_time_limit_minutes + 1) - + due_date = datetime.now(utc) + timedelta(minutes=default_time_limit_minutes + 1) sequence = BlockFactory.create( parent=self.chapter, category='sequential', @@ -86,17 +108,22 @@ class TestExamService(ModuleStoreTestCase): is_onboarding_exam=is_onboarding_exam, ) + self.course.proctoring_provider = proctoring_provider + self.course = self.update_course(self.course, 1) + + expected_due_date = self._get_exam_due_date(self.course, sequence) + expected_exams = [{ 'course_id': self.course_key, 'content_id': str(sequence.location), 'exam_name': sequence.display_name, 'time_limit_mins': sequence.default_time_limit_minutes, - 'due_date': due_date.isoformat(), + 'due_date': expected_due_date, 'exam_type': expected_type, 'is_active': True, 'hide_after_due': True, # backend is only required for edx-proctoring support edx-exams will maintain LTI backends - 'backend': 'null', + 'backend': proctoring_provider, }] listen_for_course_publish(self, self.course.id) mock_patch_course_exams.assert_called_once_with(expected_exams, self.course_key) @@ -147,23 +174,31 @@ class TestExamService(ModuleStoreTestCase): listen_for_course_publish(self, self.course.id) mock_patch_course_exams.assert_not_called() - # MODIFY DUE DATE HERE @ddt.data( - (True, datetime(2035, 1, 1, 0, 0, tzinfo=timezone.utc)), - (False, datetime(2035, 1, 1, 0, 0, tzinfo=timezone.utc)), - (True, None), - (False, None), + *itertools.product( + (True, False), + (datetime(2035, 1, 1, 0, 0, tzinfo=timezone.utc), None), + ('null', 'lti_external'), + ) ) @ddt.unpack - def test_no_due_dates(self, is_self_paced, course_end_date, mock_patch_course_exams): + def test_no_due_dates(self, is_self_paced, course_end_date, proctoring_provider, mock_patch_course_exams): """ - Test that the coures end date is registered as the due date when the subsection does not have a due date for - both self-paced and instructor-paced exams. + Test that the the correct due date is registered for the exam when the subsection does not have a due date, + depending on the proctoring provider. + + * lti_external + * The course end date is registered as the due date when the subsection does not have a due date for both + self-paced and instructor-paced exams. + * not lti_external + * None is registered as the due date when the subsection does not have a due date for both + self-paced and instructor-paced exams. """ self.course.self_paced = is_self_paced self.course.end = course_end_date + self.course.proctoring_provider = proctoring_provider self.course = self.update_course(self.course, 1) - BlockFactory.create( + sequence = BlockFactory.create( parent=self.chapter, category='sequential', display_name='Test Proctored Exam', @@ -179,20 +214,38 @@ class TestExamService(ModuleStoreTestCase): listen_for_course_publish(self, self.course.id) called_exams, called_course = mock_patch_course_exams.call_args[0] - assert called_exams[0]['due_date'] == (course_end_date.isoformat() if course_end_date else None) - @ddt.data(True, False) - def test_subsection_due_date_prioritized(self, is_self_paced, mock_patch_course_exams): + expected_due_date = self._get_exam_due_date(self.course, sequence) + + assert called_exams[0]['due_date'] == expected_due_date + + @ddt.data(*itertools.product((True, False), ('lti_external', 'null'))) + @ddt.unpack + @freeze_time('2024-01-01') + def test_subsection_due_date_prioritized(self, is_self_paced, proctoring_provider, mock_patch_course_exams): """ Test that the subsection due date is registered as the due date when both the subsection has a due date and the course has an end date for both self-paced and instructor-paced exams. + + Test that the the correct due date is registered for the exam when the subsection has a due date, depending on + the proctoring provider. + + * lti_external + * The subsection due date is registered as the due date when both the subsection has a due date and the + course has an end date for both self-paced and instructor-paced exams + * not lti_external + * None is registered as the due date when both the subsection has a due date and the course has an end date + for self-paced exams. + * The subsection due date is registered as the due date when both the subsection has a due date and the + course has an end date for instructor-paced exams. """ self.course.self_paced = is_self_paced self.course.end = datetime(2035, 1, 1, 0, 0) + self.course.proctoring_provider = proctoring_provider self.course = self.update_course(self.course, 1) - sequential_due_date = datetime.now(UTC) + timedelta(minutes=60) - BlockFactory.create( + sequential_due_date = datetime.now(utc) + timedelta(minutes=60) + sequence = BlockFactory.create( parent=self.chapter, category='sequential', display_name='Test Proctored Exam', @@ -208,4 +261,7 @@ class TestExamService(ModuleStoreTestCase): listen_for_course_publish(self, self.course.id) called_exams, called_course = mock_patch_course_exams.call_args[0] - assert called_exams[0]['due_date'] == sequential_due_date.isoformat() + + expected_due_date = self._get_exam_due_date(self.course, sequence) + + assert called_exams[0]['due_date'] == expected_due_date diff --git a/cms/djangoapps/contentstore/tests/test_filters.py b/cms/djangoapps/contentstore/tests/test_filters.py index 13bdfa0747..4011ae728b 100644 --- a/cms/djangoapps/contentstore/tests/test_filters.py +++ b/cms/djangoapps/contentstore/tests/test_filters.py @@ -48,7 +48,7 @@ class LMSPageURLRequestedFiltersTest(ModuleStoreTestCase): @override_settings( OPEN_EDX_FILTERS_CONFIG={ - "org.openedx.course_authoring.lms.page.url.requested.v1": { + "org.openedx.content_authoring.lms.page.url.requested.v1": { "pipeline": [ "common.djangoapps.util.tests.test_filters.TestPageURLRequestedPipelineStep", ], diff --git a/cms/djangoapps/contentstore/tests/test_i18n.py b/cms/djangoapps/contentstore/tests/test_i18n.py index fdab4aef83..6201253bab 100644 --- a/cms/djangoapps/contentstore/tests/test_i18n.py +++ b/cms/djangoapps/contentstore/tests/test_i18n.py @@ -5,6 +5,8 @@ import gettext from unittest import mock, skip from django.utils import translation +from edx_toggles.toggles.testutils import override_waffle_flag + from django.utils.translation import get_language from xblock.core import XBlock from xmodule.modulestore.django import XBlockI18nService @@ -12,6 +14,7 @@ from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory from xmodule.tests.test_export import PureXBlock +from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient from cms.djangoapps.contentstore.views.preview import _prepare_runtime_for_preview from common.djangoapps.student.tests.factories import UserFactory @@ -202,6 +205,7 @@ class InternationalizationTest(ModuleStoreTestCase): 'display_name': 'Robot Super Course', } + @override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True) def test_course_plain_english(self): """Test viewing the index page with no courses""" self.client = AjaxEnabledTestClient() # lint-amnesty, pylint: disable=attribute-defined-outside-init @@ -213,6 +217,7 @@ class InternationalizationTest(ModuleStoreTestCase): status_code=200, html=True) + @override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True) def test_course_explicit_english(self): """Test viewing the index page with no courses""" self.client = AjaxEnabledTestClient() # lint-amnesty, pylint: disable=attribute-defined-outside-init diff --git a/cms/djangoapps/contentstore/tests/test_import.py b/cms/djangoapps/contentstore/tests/test_import.py index 73b65197da..697d829e54 100644 --- a/cms/djangoapps/contentstore/tests/test_import.py +++ b/cms/djangoapps/contentstore/tests/test_import.py @@ -21,6 +21,9 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.xml_importer import import_course_from_xml +from common.djangoapps.util.storage import resolve_storage_backend +from storages.backends.s3boto3 import S3Boto3Storage + TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex @@ -146,6 +149,7 @@ class ContentStoreImportTest(ModuleStoreTestCase): import_course_from_xml( module_store, self.user.id, TEST_DATA_DIR, ['toy'], static_content_store=content_store, do_import_static=False, + do_import_python_lib=False, # python_lib.zip is special-cased -- exclude it too create_if_not_present=True, verbose=True ) @@ -153,7 +157,7 @@ class ContentStoreImportTest(ModuleStoreTestCase): # make sure we have NO assets in our contentstore all_assets, count = content_store.get_all_content_for_course(course.id) - self.assertEqual(len(all_assets), 0) + self.assertEqual(all_assets, []) self.assertEqual(count, 0) def test_no_static_link_rewrites_on_import(self): @@ -274,3 +278,81 @@ class ContentStoreImportTest(ModuleStoreTestCase): video = module_store.get_item(vertical.children[1]) self.assertEqual(video.display_name, 'default') + + @override_settings( + COURSE_IMPORT_EXPORT_STORAGE="cms.djangoapps.contentstore.storage.ImportExportS3Storage", + DEFAULT_FILE_STORAGE="django.core.files.storage.FileSystemStorage" + ) + def test_resolve_default_storage(self): + """ Ensure the default storage is invoked, even if course export storage is configured """ + storage = resolve_storage_backend( + storage_key="default", + legacy_setting_key="DEFAULT_FILE_STORAGE" + ) + self.assertEqual(storage.__class__.__name__, "FileSystemStorage") + + @override_settings( + COURSE_IMPORT_EXPORT_STORAGE="cms.djangoapps.contentstore.storage.ImportExportS3Storage", + DEFAULT_FILE_STORAGE="django.core.files.storage.FileSystemStorage", + COURSE_IMPORT_EXPORT_BUCKET="bucket_name_test" + ) + def test_resolve_happy_path_storage(self): + """ Make sure that the correct course export storage is being used """ + storage = resolve_storage_backend( + storage_key="course_import_export", + legacy_setting_key="COURSE_IMPORT_EXPORT_STORAGE" + ) + self.assertEqual(storage.__class__.__name__, "ImportExportS3Storage") + self.assertEqual(storage.bucket_name, "bucket_name_test") + + @override_settings() + def test_resolve_storage_with_no_config(self): + """ If no storage setup is defined, we get FileSystemStorage by default """ + del settings.DEFAULT_FILE_STORAGE + del settings.COURSE_IMPORT_EXPORT_STORAGE + del settings.COURSE_IMPORT_EXPORT_BUCKET + storage = resolve_storage_backend( + storage_key="course_import_export", + legacy_setting_key="COURSE_IMPORT_EXPORT_STORAGE" + ) + self.assertEqual(storage.__class__.__name__, "FileSystemStorage") + + @override_settings( + COURSE_IMPORT_EXPORT_STORAGE=None, + COURSE_IMPORT_EXPORT_BUCKET="bucket_name_test", + STORAGES={ + 'course_import_export': { + 'BACKEND': 'cms.djangoapps.contentstore.storage.ImportExportS3Storage', + 'OPTIONS': {} + } + } + ) + def test_resolve_storage_using_django5_settings(self): + """ Simulating a Django 4 environment using Django 5 Storages configuration """ + storage = resolve_storage_backend( + storage_key="course_import_export", + legacy_setting_key="COURSE_IMPORT_EXPORT_STORAGE" + ) + self.assertEqual(storage.__class__.__name__, "ImportExportS3Storage") + self.assertEqual(storage.bucket_name, "bucket_name_test") + + @override_settings( + STORAGES={ + 'course_import_export': { + 'BACKEND': 'storages.backends.s3boto3.S3Boto3Storage', + 'OPTIONS': { + 'bucket_name': 'bucket_name_test' + } + } + } + ) + def test_resolve_storage_using_django5_settings_with_options(self): + """ Ensure we call the storage class with the correct parameters and Django 5 setup """ + del settings.COURSE_IMPORT_EXPORT_STORAGE + del settings.COURSE_IMPORT_EXPORT_BUCKET + storage = resolve_storage_backend( + storage_key="course_import_export", + legacy_setting_key="COURSE_IMPORT_EXPORT_STORAGE" + ) + self.assertEqual(storage.__class__.__name__, S3Boto3Storage.__name__) + self.assertEqual(storage.bucket_name, "bucket_name_test") diff --git a/cms/djangoapps/contentstore/tests/test_permissions.py b/cms/djangoapps/contentstore/tests/test_permissions.py index 7f0a7079dd..d8fad57165 100644 --- a/cms/djangoapps/contentstore/tests/test_permissions.py +++ b/cms/djangoapps/contentstore/tests/test_permissions.py @@ -2,9 +2,11 @@ Test CRUD for authorization. """ - import copy +from edx_toggles.toggles.testutils import override_waffle_flag + +from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient from cms.djangoapps.contentstore.utils import reverse_course_url, reverse_url from common.djangoapps.student import auth @@ -64,10 +66,15 @@ class TestCourseAccess(ModuleStoreTestCase): self.client.logout() ModuleStoreTestCase.tearDown(self) # pylint: disable=non-parent-method-called + @override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_TEAM, True) def test_get_all_users(self): """ Test getting all authors for a course where their permissions run the gamut of allowed group types. + + TODO: Replace the call to the legacy course_team_handler with a call to the course team REST API. + The legacy page will be removed, but we still want to the test these behaviors. + Part of https://github.com/openedx/edx-platform/issues/36275. """ # first check the course creator.has explicit access (don't use has_access as is_staff # will trump the actual test) diff --git a/cms/djangoapps/contentstore/tests/test_tasks.py b/cms/djangoapps/contentstore/tests/test_tasks.py index cf82a6d165..8634e7c6e5 100644 --- a/cms/djangoapps/contentstore/tests/test_tasks.py +++ b/cms/djangoapps/contentstore/tests/test_tasks.py @@ -1,31 +1,50 @@ """ Unit tests for course import and export Celery tasks """ - - import copy import json +import logging from unittest import mock +from unittest.mock import AsyncMock, patch, MagicMock from uuid import uuid4 +from celery import Task +import pytest from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.test.utils import override_settings from edx_toggles.toggles.testutils import override_waffle_flag +from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import CourseLocator from organizations.models import OrganizationCourse from organizations.tests.factories import OrganizationFactory from user_tasks.models import UserTaskArtifact, UserTaskStatus -from cms.djangoapps.contentstore.tasks import export_olx, update_special_exams_and_publish, rerun_course from cms.djangoapps.contentstore.tests.test_libraries import LibraryTestCase from cms.djangoapps.contentstore.tests.utils import CourseTestCase from common.djangoapps.course_action_state.models import CourseRerunState from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.course_apps.toggles import EXAMS_IDA from openedx.core.djangoapps.embargo.models import Country, CountryAccessRule, RestrictedCourse +from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE +from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order +from ..tasks import ( + LinkState, + export_olx, + update_special_exams_and_publish, + rerun_course, + _validate_urls_access_in_batches, + _filter_by_status, + _get_urls, + _check_broken_links, + _is_studio_url, + _scan_course_for_links, + _convert_to_standard_url +) + +logging = logging.getLogger(__name__) TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex @@ -170,6 +189,26 @@ class RerunCourseTaskTestCase(CourseTestCase): # lint-amnesty, pylint: disable= country=restricted_country ) + def test_success_different_org(self): + """ + The task should clone the OrganizationCourse with a different org. + """ + old_course_key = self.course.id + new_course_key = CourseLocator(org='neworg', course=old_course_key.course, run='rerun') + + old_course_id = str(old_course_key) + new_course_id = str(new_course_key) + + organization = OrganizationFactory(short_name=old_course_key.org) + OrganizationCourse.objects.create(course_id=old_course_id, organization=organization) + + # Run the task! + self._rerun_course(old_course_key, new_course_key) + + # Verify the OrganizationCourse is cloned with a different org + self.assertEqual(OrganizationCourse.objects.count(), 2) + OrganizationCourse.objects.get(course_id=new_course_id, organization__short_name='neworg') + @override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) class RegisterExamsTaskTestCase(CourseTestCase): # pylint: disable=missing-class-docstring @@ -199,3 +238,433 @@ class RegisterExamsTaskTestCase(CourseTestCase): # pylint: disable=missing-clas _mock_register_exams_proctoring.side_effect = Exception('boom!') update_special_exams_and_publish(str(self.course.id)) course_publish.assert_called() + + +class MockCourseLinkCheckTask(Task): + def __init__(self): + self.status = mock.Mock() + + +############## Course Optimizer tests ############## + + +class CheckBrokenLinksTaskTest(ModuleStoreTestCase): + """Tests for CheckBrokenLinksTask""" + def setUp(self): + super().setUp() + self.store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo) # lint-amnesty, pylint: disable=protected-access + self.test_course = CourseFactory.create( + org="test", course="course1", display_name="run1" + ) + self.mock_urls = [ + ["block-v1:edX+DemoX+Demo_Course+type@vertical+block@1", "http://example.com/valid"], + ["block-v1:edX+DemoX+Demo_Course+type@vertical+block@2", "http://example.com/invalid"], + ["block-v1:edX+DemoX+Demo_Course+type@vertical+block@3", f'http://{settings.CMS_BASE}/locked'], + ["block-v1:edX+DemoX+Demo_Course+type@vertical+block@3", 'https://outsider.com/about'], + ] + self.expected_file_contents = [ + ["block-v1:edX+DemoX+Demo_Course+type@vertical+block@2", "http://example.com/invalid", LinkState.BROKEN], + ["block-v1:edX+DemoX+Demo_Course+type@vertical+block@3", + f"http://{settings.CMS_BASE}/locked", + LinkState.LOCKED + ], + ["block-v1:edX+DemoX+Demo_Course+type@vertical+block@3", + 'https://outsider.com/about', + LinkState.EXTERNAL_FORBIDDEN + ], + ] + + @mock.patch('cms.djangoapps.contentstore.tasks.UserTaskArtifact', autospec=True) + @mock.patch('cms.djangoapps.contentstore.tasks._scan_course_for_links') + @mock.patch('cms.djangoapps.contentstore.tasks._save_broken_links_file', autospec=True) + @mock.patch('cms.djangoapps.contentstore.tasks._write_broken_links_to_file', autospec=True) + @mock.patch('cms.djangoapps.contentstore.tasks._validate_urls_access_in_batches', autospec=True) + def test_check_broken_links_stores_broken_locked_and_forbidden_urls( + self, + mock_validate_urls, + mock_write_broken_links_to_file, + mock_save_broken_links_file, + mock_scan_course_for_links, + mock_user_task_artifact + ): + ''' + The test verifies that the check_broken_links task correctly + stores broken or locked URLs in the course. + The expected behavior is that the after scanning the course, + validating the URLs, and filtering the results, the task stores the results in a + JSON file. + Note that this test mocks all validation functions and therefore + does not test link validation or any of its support functions. + ''' + mock_user = UserFactory.create(username='student', password='password') + mock_course_key_string = "course-v1:edX+DemoX+Demo_Course" + mock_task = MockCourseLinkCheckTask() + mock_scan_course_for_links.return_value = self.mock_urls + mock_validate_urls.return_value = [ + { + "block_id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@1", + "url": "http://example.com/valid", + "status": 200, + }, + { + "block_id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@2", + "url": "http://example.com/invalid", + "status": 400, + }, + { + "block_id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@3", + "url": f"http://{settings.CMS_BASE}/locked", + "status": 403, + }, + { + "block_id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@3", + "url": "https://outsider.com/about", + "status": 403, + } + ] + + _check_broken_links(mock_task, mock_user.id, mock_course_key_string, 'en') # pylint: disable=no-value-for-parameter + + # Check that UserTaskArtifact was called with the correct arguments + mock_user_task_artifact.assert_called_once_with(status=mock.ANY, name='BrokenLinks') + + # Check that the correct links are written to the file + mock_write_broken_links_to_file.assert_called_once_with(self.expected_file_contents, mock.ANY) + + # Check that _save_broken_links_file was called with the correct arguments + mock_save_broken_links_file.assert_called_once_with(mock_user_task_artifact.return_value, mock.ANY) + + def test_hash_tags_stripped_from_url_lists(self): + NUM_HASH_TAG_LINES = 2 + url_list = ''' + href='#' # 1 of 2 lines that will be stripped + href='http://google.com' + src='#' # 2 of 2 lines that will be stripped + href='https://microsoft.com' + src="/static/resource_name" + ''' + + # Correct for the two carriage returns surrounding the ''' marks + original_lines = len(url_list.splitlines()) - 2 + + processed_url_list = _get_urls(url_list) + processed_lines = len(processed_url_list) + + assert processed_lines == original_lines - NUM_HASH_TAG_LINES, \ + f'Processed URL list lines = {processed_lines}; expected {original_lines - 2}' + + def test_http_url_not_recognized_as_studio_url_scheme(self): + self.assertFalse(_is_studio_url('http://www.google.com')) + + def test_https_url_not_recognized_as_studio_url_scheme(self): + self.assertFalse(_is_studio_url('https://www.google.com')) + + def test_http_with_studio_base_url_recognized_as_studio_url_scheme(self): + self.assertTrue(_is_studio_url(f'http://{settings.CMS_BASE}/testurl')) + + def test_https_with_studio_base_url_recognized_as_studio_url_scheme(self): + self.assertTrue(_is_studio_url(f'https://{settings.CMS_BASE}/testurl')) + + def test_container_url_without_url_base_is_recognized_as_studio_url_scheme(self): + self.assertTrue(_is_studio_url('container/test')) + + def test_slash_url_without_url_base_is_recognized_as_studio_url_scheme(self): + self.assertTrue(_is_studio_url('/static/test')) + + @mock.patch('cms.djangoapps.contentstore.tasks.ModuleStoreEnum', autospec=True) + @mock.patch('cms.djangoapps.contentstore.tasks.modulestore', autospec=True) + def test_course_scan_occurs_on_published_version(self, mock_modulestore, mock_module_store_enum): + """_scan_course_for_links should only scan published courses""" + mock_modulestore_instance = mock.Mock() + mock_modulestore.return_value = mock_modulestore_instance + mock_modulestore_instance.get_items.return_value = [] + + mock_course_key_string = CourseKey.from_string("course-v1:edX+DemoX+Demo_Course") + mock_module_store_enum.RevisionOption.published_only = "mock_published_only" + + _scan_course_for_links(mock_course_key_string) + + mock_modulestore_instance.get_items.assert_called_once_with( + mock_course_key_string, + qualifiers={'category': 'vertical'}, + revision=mock_module_store_enum.RevisionOption.published_only + ) + + @mock.patch('cms.djangoapps.contentstore.tasks._get_urls', autospec=True) + def test_number_of_scanned_blocks_equals_blocks_in_course(self, mock_get_urls): + """ + _scan_course_for_links should call _get_urls once per block in course. + """ + expected_blocks = self.store.get_items(self.test_course.id) + + _scan_course_for_links(self.test_course.id) + self.assertEqual(len(expected_blocks), mock_get_urls.call_count) + + @mock.patch('cms.djangoapps.contentstore.tasks.get_block_info', autospec=True) + @mock.patch('cms.djangoapps.contentstore.tasks.modulestore', autospec=True) + def test_scan_course_excludes_drag_and_drop(self, mock_modulestore, mock_get_block_info): + """ + Test that `_scan_course_for_links` excludes blocks of category 'drag-and-drop-v2'. + """ + vertical = BlockFactory.create( + category='vertical', + parent_location=self.test_course.location + ) + drag_and_drop_block = BlockFactory.create( + category='drag-and-drop-v2', + parent_location=vertical.location, + ) + text_block = BlockFactory.create( + category='html', + parent_location=vertical.location, + data='Test Link -> Example.com' + ) + + mock_modulestore_instance = mock.Mock() + mock_modulestore.return_value = mock_modulestore_instance + mock_modulestore_instance.get_items.return_value = [vertical] + vertical.get_children = mock.Mock(return_value=[drag_and_drop_block, text_block]) + + def get_block_side_effect(block): + block_data = getattr(block, 'data', '') + if isinstance(block_data, str): + return {'data': block_data} + raise TypeError("expected string or bytes-like object, got 'dict'") + mock_get_block_info.side_effect = get_block_side_effect + + urls = _scan_course_for_links(self.test_course.id) + # The drag-and-drop block should not appear in the results + self.assertFalse( + any(block_id == str(drag_and_drop_block.usage_key) for block_id, _ in urls), + "Drag and Drop blocks should be excluded" + ) + self.assertTrue( + any(block_id == str(text_block.usage_key) for block_id, _ in urls), + "Text block should be included" + ) + + @pytest.mark.asyncio + async def test_every_detected_link_is_validated(self): + ''' + The call to _validate_urls_access_in_batches() should call _validate_batch() three times, once for each + of the three batches of length 2 in url_list. The lambda function supplied for _validate_batch will + simply return the set of urls fed to _validate_batch(), and _validate_urls_access_in_batches() will + aggregate these into a list identical to the original url_list. + + What this shows is that each url submitted to _validate_urls_access_in_batches() is ending up as an argument + to one of the generated _validate_batch() calls, and that no input URL is left unprocessed. + ''' + url_list = ['1', '2', '3', '4', '5'] + course_key = 'course-v1:edX+DemoX+Demo_Course' + batch_size = 2 + with patch("cms.djangoapps.contentstore.tasks._validate_batch", new_callable=AsyncMock) as mock_validate_batch: + mock_validate_batch.side_effect = lambda x, y: x + validated_urls = await _validate_urls_access_in_batches(url_list, course_key, batch_size) + mock_validate_batch.assert_called() + assert mock_validate_batch.call_count == 3 # two full batches and one partial batch + assert validated_urls == url_list, \ + f"List of validated urls {validated_urls} is not identical to sourced urls {url_list}" + + @pytest.mark.asyncio + async def test_all_links_are_validated_with_batch_validation(self): + ''' + Here the focus is not on batching, but rather that when validation occurs it does so on the intended + URL strings + ''' + with patch("cms.djangoapps.contentstore.tasks._validate_url_access", new_callable=AsyncMock) as mock_validate: + mock_validate.return_value = {"status": 200} + + url_list = ['1', '2', '3', '4', '5'] + course_key = 'course-v1:edX+DemoX+Demo_Course' + batch_size = 2 + await _validate_urls_access_in_batches(url_list, course_key, batch_size) + args_list = mock_validate.call_args_list + urls = [call_args.args[1] for call_args in args_list] # The middle argument in each of the function calls + for i in range(1, len(url_list) + 1): + assert str(i) in urls, f'{i} not supplied as a url for validation in batches function' + + def test_no_retries_on_403_access_denied_links(self): + ''' + No mocking required here. Will populate "filtering_input" with simulated results for link checks where + some links time out, some links receive 403 errors, and some receive 200 success. This test then + ensures that "_filter_by_status()" tallies the three categories as expected, and formats the result + as expected. + ''' + url_list = ['1', '2', '3', '4', '5'] + filtering_input = [] + for i in range(1, len(url_list) + 1): # Notch out one of the URLs, having it return a '403' status code + filtering_input.append({ + 'block_id': f'block_{i}', + 'url': str(i), + 'status': 200 + }) + filtering_input[2]['status'] = 403 + filtering_input[3]['status'] = 500 + filtering_input[4]['status'] = None + + broken_or_locked_urls, retry_list = _filter_by_status(filtering_input) + assert len(broken_or_locked_urls) == 2 # The inputs with status = 403 and 500 + assert len(retry_list) == 1 # The input with status = None + assert retry_list[0][1] == '5' # The only URL fit for a retry operation (status == None) + + def test_filter_by_status(self): + """ + Test the _filter_by_status function to ensure it correctly categorize links + based on the given status codes and returns appropriate lists of filtered + results and retry attempts. + """ + # Test data + results = [ + {'status': 200, 'block_id': 'block1', 'url': 'https://example.com'}, + {'status': None, 'block_id': 'block2', 'url': 'https://retry.com'}, + {'status': 403, 'block_id': 'block3', 'url': 'https://' + settings.CMS_BASE}, + {'status': None, 'block_id': 'block3', 'url': 'https://' + settings.CMS_BASE}, + {'status': 403, 'block_id': 'block4', 'url': 'https://external.com'}, + {'status': 404, 'block_id': 'block5', 'url': 'https://broken.com'} + ] + + expected_filtered_results = [ + ['block2', 'https://retry.com', LinkState.EXTERNAL_FORBIDDEN], + ['block3', 'https://' + settings.CMS_BASE, LinkState.LOCKED], + ['block4', 'https://external.com', LinkState.EXTERNAL_FORBIDDEN], + ['block5', 'https://broken.com', LinkState.BROKEN], + ] + + expected_retry_list = [ + ['block3', 'https://' + settings.CMS_BASE] + ] + + filtered_results, retry_list = _filter_by_status(results) + + self.assertEqual(filtered_results, expected_filtered_results) + self.assertEqual(retry_list, expected_retry_list) + + @patch("cms.djangoapps.contentstore.tasks._validate_user", return_value=MagicMock()) + @patch("cms.djangoapps.contentstore.tasks._scan_course_for_links", return_value=["url1", "url2"]) + @patch( + "cms.djangoapps.contentstore.tasks._validate_urls_access_in_batches", + return_value=[{"url": "url1", "status": "ok"}] + ) + @patch( + "cms.djangoapps.contentstore.tasks._filter_by_status", + return_value=(["block_1", "url1", True], ["block_2", "url2"]) + ) + @patch("cms.djangoapps.contentstore.tasks._retry_validation", return_value=['block_2', 'url2']) + def test_check_broken_links_calls_expected_support_functions( + self, + mock_retry_validation, + mock_filter, + mock_validate_urls, + mock_scan_course, + mock_validate_user + ): + # Parameters for the function + user_id = 1234 + language = "en" + course_key_string = "course-v1:edX+DemoX+2025" + + # Mocking self and status attributes for the test + class MockStatus: + """Mock for status attributes""" + def __init__(self): + self.state = "READY" + + def set_state(self, state): + self.state = state + + def increment_completed_steps(self): + pass + + def fail(self, error_details): + self.state = "FAILED" + + class MockSelf: + def __init__(self): + self.status = MockStatus() + + mock_self = MockSelf() + + _check_broken_links(mock_self, user_id, course_key_string, language) + + # Prepare expected results based on mock settings + url_list = mock_scan_course.return_value + validated_url_list = mock_validate_urls.return_value + broken_or_locked_urls, retry_list = mock_filter.return_value + course_key = CourseKey.from_string(course_key_string) + + if retry_list: + retry_results = mock_retry_validation.return_value + broken_or_locked_urls.extend(retry_results) + + # Perform verifications + try: + mock_self.status.increment_completed_steps() + mock_retry_validation.assert_called_once_with( + mock_filter.return_value[1], course_key, retry_count=3 + ) + except Exception as e: # pylint: disable=broad-except + logging.exception("Error checking links for course %s", course_key_string, exc_info=True) + if mock_self.status.state != "FAILED": + mock_self.status.fail({"raw_error_msg": str(e)}) + assert False, "Exception should not occur" + + # Assertions to confirm patched calls were invoked + mock_validate_user.assert_called_once_with(mock_self, user_id, language) + mock_scan_course.assert_called_once_with(course_key) + mock_validate_urls.assert_called_once_with(url_list, course_key, batch_size=100) + mock_filter.assert_called_once_with(validated_url_list) + if retry_list: + mock_retry_validation.assert_called_once_with(retry_list, course_key, retry_count=3) + + def test_convert_to_standard_url(self): + """Test _convert_to_standard_url function with expected URLs.""" + course_key = CourseKey.from_string("course-v1:test+course1+run1") + test_cases = [ + ( + "/static/getting-started_x250.png", + f"https://{settings.CMS_BASE}/asset-v1:test+course1+run1+type@asset+block/getting-started_x250.png", + ), + ( + "/jump_to_id/123abc", + f"https://{settings.LMS_BASE}/courses/{course_key}/jump_to_id/123abc", + ), + ( + "/container/block-v1:test+course1+type@vertical+block@123", + f"https://{settings.CMS_BASE}/container/block-v1:test+course1+type@vertical+block@123", + ), + ("/unknown/path", f"https://{settings.CMS_BASE}/unknown/path"), + ("https://external.com/some/path", "https://external.com/some/path"), + ("studio-url", "https://localhost:8001/container/studio-url"), + ] + + for url, expected in test_cases: + self.assertEqual( + _convert_to_standard_url(url, course_key), + expected, + f"Failed for URL: {url}", + ) + + def test_get_urls(self): + """Test _get_urls function for correct URL extraction.""" + + content = ''' + Link + + + Home + Valid + + + Another +

No links here!

+ Just an image without src + ''' + + expected = [ + "https://example.com", + "https://images.com/pic.jpg", + "https://fonts.googleapis.com/css?family=Roboto", + "https://validsite.com", + "https://another-valid.com" + ] + self.assertEqual(_get_urls(content), expected) diff --git a/cms/djangoapps/contentstore/tests/test_transcripts_utils.py b/cms/djangoapps/contentstore/tests/test_transcripts_utils.py index 15a17faa0f..ea56394bd6 100644 --- a/cms/djangoapps/contentstore/tests/test_transcripts_utils.py +++ b/cms/djangoapps/contentstore/tests/test_transcripts_utils.py @@ -1,5 +1,6 @@ """ Tests for transcripts_utils. """ +from contextlib import contextmanager import copy import json import re @@ -1000,3 +1001,116 @@ class TestGetTranscript(SharedModuleStoreTestCase): output_format=transcripts_utils.Transcript.SRT, transcripts_info=transcripts_info ) + + +@ddt.ddt +class TestResolveLanguageCodeToTranscriptCode(unittest.TestCase): + """ Tests for resolve_language_code_to_transcript_code """ + TEST_OTHER_LANGS = {'ab': 1, 'ab-cd': 1, 'ab-EF': 1, 'cd': 1, 'cd-jk': 1} + TEST_TRANSCRIPTS = {'transcripts': TEST_OTHER_LANGS, 'sub': False} + + @ddt.unpack + @ddt.data( + ('ab', 'ab'), + ('ab-CD', 'ab-cd'), + ('ab-ef', 'ab-EF'), + ('zx', None), + ('cd-lmao', 'cd'), + ) + def test_resolve_lang(self, lang, expected): + """ + Test that resolve_language_code_to_transcript_code will successfully match + language codes of different cases, and return None if it isn't found + """ + self.assertEqual( + transcripts_utils.resolve_language_code_to_transcript_code(self.TEST_TRANSCRIPTS, lang), + expected + ) + + +class TestGetEndonymOrLabel(unittest.TestCase): + """ + tests for the get_endonym_or_label function + """ + LANG_CODE = 'ab-cd' + GENERIC_CODE = 'ab' + LANG_ENTONYM = 'ab language entonym (cd)' + LANG_LABEL = 'ab-cd language english label' + GENERIC_LABEL = 'ab language english label' + + TEST_LANGUAGE_DICT = {LANG_CODE: LANG_ENTONYM} + TEST_ALL_LANGUAGES = ( + ["aa", "Afar"], + [GENERIC_CODE, GENERIC_LABEL], + [LANG_CODE, LANG_LABEL], + ["ur", "Urdu"], + ) + + @contextmanager + def mock_django_get_language_info(self, side_effect=None): + """ + Helper for cleaner mocking + """ + with patch('xmodule.video_block.transcripts_utils.get_language_info') as mock_get: + if side_effect: + mock_get.side_effect = side_effect + yield mock_get + + def test_language_in_languages(self): + """ If language is found in LANGUAGE_DICT that value should be returned """ + with override_settings(LANGUAGE_DICT=self.TEST_LANGUAGE_DICT): + self.assertEqual( + transcripts_utils.get_endonym_or_label(self.LANG_CODE), + self.LANG_ENTONYM + ) + + def test_language_in_django_lang_info(self): + """ + If language is not found in LANGUAGE_DICT, check get_language_info and return that + local name if found + """ + with override_settings(LANGUAGE_DICT={}): + with self.mock_django_get_language_info() as mock_get_language_info: + self.assertEqual( + transcripts_utils.get_endonym_or_label(self.LANG_CODE), + mock_get_language_info.return_value['name_local'] + ) + + def test_language_exact_in_all_languages(self): + """ + If not found in LANGUAGE_DICT or get_language_info, check in + ALL_LANGUAGES for the English language name + """ + with override_settings(LANGUAGE_DICT={}): + with self.mock_django_get_language_info(side_effect=KeyError): + with override_settings(ALL_LANGUAGES=self.TEST_ALL_LANGUAGES): + label = transcripts_utils.get_endonym_or_label(self.LANG_CODE) + self.assertEqual(label, self.LANG_LABEL) + + def test_language_generic_in_all_languages(self): + """ + If not found in LANGUAGE_DICT or get_language_info, and the exact code + wasn't found in ALL_LANGUAGES, use the generic code if it is found in ALL_LANGUAGES. + """ + all_languages = ( + self.TEST_ALL_LANGUAGES[0], + self.TEST_ALL_LANGUAGES[1], + self.TEST_ALL_LANGUAGES[3] + ) + + with override_settings(LANGUAGE_DICT={}): + with self.mock_django_get_language_info(side_effect=KeyError): + with override_settings(ALL_LANGUAGES=all_languages): + label = transcripts_utils.get_endonym_or_label(self.LANG_CODE) + self.assertEqual(label, self.GENERIC_LABEL) + + def test_language_not_found_anywhere(self): + """ + Raise a NotFoundError if the language isn't found anywhere + """ + all_languages = (self.TEST_ALL_LANGUAGES[0], self.TEST_ALL_LANGUAGES[3]) + with override_settings(LANGUAGE_DICT={}): + with self.mock_django_get_language_info(side_effect=KeyError): + with override_settings(ALL_LANGUAGES=all_languages): + with self.assertRaises(NotFoundError): + transcripts_utils.get_endonym_or_label(self.LANG_CODE) diff --git a/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py b/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py new file mode 100644 index 0000000000..90fec84716 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py @@ -0,0 +1,328 @@ +""" +Tests for upstream downstream tracking links. +""" + +from io import StringIO +from uuid import uuid4 + +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import LibraryContainerLocator, LibraryUsageLocatorV2 +from openedx_events.tests.utils import OpenEdxEventsTestMixin + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangolib.testing.utils import skip_unless_cms +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory + +from ..models import ContainerLink, LearningContextLinksStatus, LearningContextLinksStatusChoices, ComponentLink + + +class BaseUpstreamLinksHelpers(TestCase): + """ + Base class with helpers to create xblocks. + """ + def _set_course_data(self, course): + self.section = BlockFactory.create(parent=course, category="chapter", display_name="Section") # pylint: disable=attribute-defined-outside-init + self.sequence = BlockFactory.create(parent=self.section, category="sequential", display_name="Sequence") # pylint: disable=attribute-defined-outside-init + self.unit = BlockFactory.create(parent=self.sequence, category="vertical", display_name="Unit") # pylint: disable=attribute-defined-outside-init + + def _create_block(self, num: int, category="html"): + """ + Create xblock with random upstream key and version number. + """ + random_upstream = LibraryUsageLocatorV2.from_string( + f"lb:OpenedX:CSPROB2:{category}:{uuid4()}" + ) + return random_upstream, BlockFactory.create( + parent=self.unit, # pylint: disable=attribute-defined-outside-init + category=category, + display_name=f"An {category} Block - {num}", + upstream=str(random_upstream), + upstream_version=num, + ) + + def _create_unit(self, num: int): + """ + Create xblock with random upstream key and version number. + """ + random_upstream = LibraryContainerLocator.from_string( + f"lct:OpenedX:CSPROB2:unit:{uuid4()}" + ) + return random_upstream, BlockFactory.create( + parent=self.sequence, # pylint: disable=attribute-defined-outside-init + category='vertical', + display_name=f"An unit Block - {num}", + upstream=str(random_upstream), + upstream_version=num, + ) + + def _create_unit_and_expected_container_link(self, course_key: str | CourseKey, num_blocks: int = 3): + """ + Create unit xblock with random upstream key and version number. + """ + data = [] + for i in range(num_blocks): + upstream, block = self._create_unit(i + 1) + data.append({ + "upstream_container": None, + "downstream_context_key": course_key, + "downstream_usage_key": block.usage_key, + "upstream_container_key": upstream, + "upstream_context_key": str(upstream.context_key), + "version_synced": i + 1, + "version_declined": None, + }) + return data + + def _create_block_and_expected_links_data(self, course_key: str | CourseKey, num_blocks: int = 3): + """ + Creates xblocks and its expected links data for given course_key + """ + data = [] + for i in range(num_blocks): + upstream, block = self._create_block(i + 1) + data.append({ + "upstream_block": None, + "downstream_context_key": course_key, + "downstream_usage_key": block.usage_key, + "upstream_usage_key": upstream, + "upstream_context_key": str(upstream.context_key), + "version_synced": i + 1, + "version_declined": None, + }) + return data + + def _compare_links(self, course_key, expected_component_links, expected_container_links): + """ + Compares links for given course with passed expected list of dicts. + """ + links = list(ComponentLink.objects.filter(downstream_context_key=course_key).values( + 'upstream_block', + 'upstream_usage_key', + 'upstream_context_key', + 'downstream_usage_key', + 'downstream_context_key', + 'version_synced', + 'version_declined', + )) + self.assertListEqual(links, expected_component_links) + container_links = list(ContainerLink.objects.filter(downstream_context_key=course_key).values( + 'upstream_container', + 'upstream_container_key', + 'upstream_context_key', + 'downstream_usage_key', + 'downstream_context_key', + 'version_synced', + 'version_declined', + )) + self.assertListEqual(container_links, expected_container_links) + + +@skip_unless_cms +class TestRecreateUpstreamLinks(ModuleStoreTestCase, OpenEdxEventsTestMixin, BaseUpstreamLinksHelpers): + """ + Test recreate_upstream_links management command. + """ + + ENABLED_SIGNALS = ['course_deleted', 'course_published'] + ENABLED_OPENEDX_EVENTS = [] + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.course_1 = course_1 = CourseFactory.create(emit_signals=True) + self.course_key_1 = course_key_1 = self.course_1.id + with self.store.bulk_operations(course_key_1): + self._set_course_data(course_1) + self.expected_links_1 = self._create_block_and_expected_links_data(course_key_1) + self.expected_container_links_1 = self._create_unit_and_expected_container_link(course_key_1) + self.course_2 = course_2 = CourseFactory.create(emit_signals=True) + self.course_key_2 = course_key_2 = self.course_2.id + with self.store.bulk_operations(course_key_2): + self._set_course_data(course_2) + self.expected_links_2 = self._create_block_and_expected_links_data(course_key_2) + self.expected_container_links_2 = self._create_unit_and_expected_container_link(course_key_2) + self.course_3 = course_3 = CourseFactory.create(emit_signals=True) + self.course_key_3 = course_key_3 = self.course_3.id + with self.store.bulk_operations(course_key_3): + self._set_course_data(course_3) + self.expected_links_3 = self._create_block_and_expected_links_data(course_key_3) + self.expected_container_links_3 = self._create_unit_and_expected_container_link(course_key_3) + + def call_command(self, *args, **kwargs): + """ + call command with pass args. + """ + out = StringIO() + kwargs['stdout'] = out + err = StringIO() + kwargs['stderr'] = err + call_command('recreate_upstream_links', *args, **kwargs) + return out, err + + def test_call_with_invalid_args(self): + """ + Test command with invalid args. + """ + with self.assertRaisesRegex(CommandError, 'Either --course or --all argument'): + self.call_command() + with self.assertRaisesRegex(CommandError, 'Only one of --course or --all argument'): + self.call_command('--all', '--course', str(self.course_key_1)) + + def test_call_for_single_course(self): + """ + Test command with single course argument + """ + # Pre-checks + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_1)).exists() + assert not ComponentLink.objects.filter(downstream_context_key=self.course_key_1).exists() + # Run command + self.call_command('--course', str(self.course_key_1)) + # Post verfication + assert LearningContextLinksStatus.objects.filter( + context_key=str(self.course_key_1) + ).first().status == LearningContextLinksStatusChoices.COMPLETED + self._compare_links(self.course_key_1, self.expected_links_1, self.expected_container_links_1) + + def test_call_for_multiple_course(self): + """ + Test command with multiple course arguments + """ + # Pre-checks + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_2)).exists() + assert not ComponentLink.objects.filter(downstream_context_key=self.course_key_2).exists() + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_3)).exists() + assert not ComponentLink.objects.filter(downstream_context_key=self.course_key_3).exists() + + # Run command + self.call_command('--course', str(self.course_key_2), '--course', str(self.course_key_3)) + + # Post verfication + assert LearningContextLinksStatus.objects.filter( + context_key=str(self.course_key_2) + ).first().status == LearningContextLinksStatusChoices.COMPLETED + assert LearningContextLinksStatus.objects.filter( + context_key=str(self.course_key_3) + ).first().status == LearningContextLinksStatusChoices.COMPLETED + self._compare_links(self.course_key_2, self.expected_links_2, self.expected_container_links_2) + self._compare_links(self.course_key_3, self.expected_links_3, self.expected_container_links_3) + + def test_call_for_all_courses(self): + """ + Test command with multiple course arguments + """ + # Delete all links and status just to make sure --all option works + LearningContextLinksStatus.objects.all().delete() + ComponentLink.objects.all().delete() + # Pre-checks + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_1)).exists() + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_2)).exists() + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_3)).exists() + + # Run command + self.call_command('--all') + + # Post verfication + assert LearningContextLinksStatus.objects.filter( + context_key=str(self.course_key_1) + ).first().status == LearningContextLinksStatusChoices.COMPLETED + assert LearningContextLinksStatus.objects.filter( + context_key=str(self.course_key_2) + ).first().status == LearningContextLinksStatusChoices.COMPLETED + assert LearningContextLinksStatus.objects.filter( + context_key=str(self.course_key_3) + ).first().status == LearningContextLinksStatusChoices.COMPLETED + self._compare_links(self.course_key_1, self.expected_links_1, self.expected_container_links_1) + self._compare_links(self.course_key_2, self.expected_links_2, self.expected_container_links_2) + self._compare_links(self.course_key_3, self.expected_links_3, self.expected_container_links_3) + + def test_call_for_invalid_course(self): + """ + Test recreate_upstream_links with nonexistent course + """ + course_key = "invalid-course" + with self.assertLogs(level="ERROR") as ctx: + self.call_command('--course', course_key) + self.assertEqual( + f'Invalid course key: {course_key}, skipping..', + ctx.records[0].getMessage() + ) + + def test_call_for_nonexistent_course(self): + """ + Test recreate_upstream_links with nonexistent course + """ + course_key = "course-v1:unix+ux1+2024_T2" + with self.assertLogs(level="ERROR") as ctx: + self.call_command('--course', course_key) + self.assertIn( + f'Could not find items for given course: {course_key}', + ctx.records[0].getMessage() + ) + + +@skip_unless_cms +class TestUpstreamLinksEvents(ModuleStoreTestCase, OpenEdxEventsTestMixin, BaseUpstreamLinksHelpers): + """ + Test signals related to managing upstream->downstream links. + """ + + ENABLED_SIGNALS = ['course_deleted', 'course_published'] + ENABLED_OPENEDX_EVENTS = [ + "org.openedx.content_authoring.xblock.created.v1", + "org.openedx.content_authoring.xblock.updated.v1", + "org.openedx.content_authoring.xblock.deleted.v1", + ] + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.course_1 = course_1 = CourseFactory.create(emit_signals=True) + self.course_key_1 = course_key_1 = self.course_1.id + with self.store.bulk_operations(course_key_1): + self._set_course_data(course_1) + self.expected_links_1 = self._create_block_and_expected_links_data(course_key_1) + self.expected_container_links_1 = self._create_unit_and_expected_container_link(course_key_1) + self.course_2 = course_2 = CourseFactory.create(emit_signals=True) + self.course_key_2 = course_key_2 = self.course_2.id + with self.store.bulk_operations(course_key_2): + self._set_course_data(course_2) + self.expected_links_2 = self._create_block_and_expected_links_data(course_key_2) + self.expected_container_links_2 = self._create_unit_and_expected_container_link(course_key_2) + self.course_3 = course_3 = CourseFactory.create(emit_signals=True) + self.course_key_3 = course_key_3 = self.course_3.id + with self.store.bulk_operations(course_key_3): + self._set_course_data(course_3) + self.expected_links_3 = self._create_block_and_expected_links_data(course_key_3) + self.expected_container_links_3 = self._create_unit_and_expected_container_link(course_key_3) + + def test_create_or_update_events(self): + """ + Test task create_or_update_upstream_links for a course + """ + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_1)).exists() + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_2)).exists() + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_3)).exists() + assert ComponentLink.objects.filter(downstream_context_key=self.course_key_1).count() == 3 + assert ComponentLink.objects.filter(downstream_context_key=self.course_key_2).count() == 3 + assert ComponentLink.objects.filter(downstream_context_key=self.course_key_3).count() == 3 + self._compare_links(self.course_key_1, self.expected_links_1, self.expected_container_links_1) + self._compare_links(self.course_key_2, self.expected_links_2, self.expected_container_links_2) + self._compare_links(self.course_key_3, self.expected_links_3, self.expected_container_links_3) + + def test_delete_handler(self): + """ + Test whether links are deleted on deletion of xblock. + """ + usage_key = self.expected_links_1[0]["downstream_usage_key"] + assert ComponentLink.objects.filter(downstream_usage_key=usage_key).exists() + self.store.delete_item(usage_key, self.user.id) + assert not ComponentLink.objects.filter(downstream_usage_key=usage_key).exists() + + usage_key = self.expected_container_links_1[0]["downstream_usage_key"] + assert ContainerLink.objects.filter(downstream_usage_key=usage_key).exists() + self.store.delete_item(usage_key, self.user.id) + assert not ContainerLink.objects.filter(downstream_usage_key=usage_key).exists() diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index a913797970..a46d9831d4 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -12,6 +12,7 @@ from django.test.utils import override_settings from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import CourseLocator, LibraryLocator +from openedx_events.tests.utils import OpenEdxEventsTestMixin from path import Path as path from pytz import UTC from rest_framework import status @@ -31,10 +32,13 @@ from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disa from xmodule.modulestore.tests.django_utils import ( # lint-amnesty, pylint: disable=wrong-import-order TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase, - SharedModuleStoreTestCase + SharedModuleStoreTestCase, ) -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.partitions.partitions import Group, UserPartition # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import ( + BlockFactory, + CourseFactory, +) +from xmodule.partitions.partitions import Group, UserPartition class LMSLinksTestCase(TestCase): @@ -935,10 +939,13 @@ class UpdateCourseDetailsTests(ModuleStoreTestCase): @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) -class CourseUpdateNotificationTests(ModuleStoreTestCase): +class CourseUpdateNotificationTests(OpenEdxEventsTestMixin, ModuleStoreTestCase): """ Unit tests for the course_update notification. """ + ENABLED_OPENEDX_EVENTS = [ + "org.openedx.learning.course.notification.requested.v1", + ] def setUp(self): """ diff --git a/cms/djangoapps/contentstore/tests/test_video_utils.py b/cms/djangoapps/contentstore/tests/test_video_utils.py index c81761a283..e65e4f6638 100644 --- a/cms/djangoapps/contentstore/tests/test_video_utils.py +++ b/cms/djangoapps/contentstore/tests/test_video_utils.py @@ -12,9 +12,9 @@ import pytz import requests from django.conf import settings from django.core.files.base import ContentFile -from django.core.files.storage import get_storage_class from django.core.files.uploadedfile import UploadedFile from django.test.utils import override_settings +from django.utils.module_loading import import_string from edxval.api import create_profile, create_video, get_course_video_image_url, update_video_image from storages.backends.s3boto3 import S3Boto3Storage @@ -390,7 +390,7 @@ class S3Boto3TestCase(TestCase): def test_video_backend(self): self.assertEqual( S3Boto3Storage, - get_storage_class( + import_string( 'storages.backends.s3boto3.S3Boto3Storage', )(**settings.VIDEO_IMAGE_SETTINGS.get('STORAGE_KWARGS', {})).__class__ ) @@ -401,7 +401,7 @@ class S3Boto3TestCase(TestCase): {'bucket_name': 'test', 'default_acl': None, 'location': 'abc/def'}} ) def test_boto3_backend_with_params(self): - storage = get_storage_class( + storage = import_string( settings.VIDEO_IMAGE_SETTINGS.get('STORAGE_CLASS', {}) )(**settings.VIDEO_IMAGE_SETTINGS.get('STORAGE_KWARGS', {})) diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index bd2c3dbfd3..7efe298ad3 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -1,5 +1,10 @@ """ This test file will test registration, login, activation, and session activity timeouts + +TODO: Rewrite several of these assertions so that they check the output of the REST or Python +APIs rather than parsing HTML from the deprecated legacy frontend pages. In particular, any +test case using override_waffle_flag(toggles.LEGACY_STUDIO_*, True) will need to be fixed. +Part of https://github.com/openedx/edx-platform/issues/36275. """ @@ -13,8 +18,10 @@ from django.conf import settings from django.core.cache import cache from django.test.utils import override_settings from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_flag from pytz import UTC +from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.test_course_settings import CourseTestCase from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient, parse_json, registration, user from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order @@ -106,6 +113,7 @@ class AuthTestCase(ContentStoreTestCase): self.assertEqual(resp.status_code, expected) return resp + @override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True) def test_private_pages_auth(self): """Make sure pages that do require login work.""" auth_pages = ( @@ -140,6 +148,7 @@ class AuthTestCase(ContentStoreTestCase): self.check_page_get(page, expected=200) @override_settings(SESSION_INACTIVITY_TIMEOUT_IN_SECONDS=1) + @override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True) def test_inactive_session_timeout(self): """ Verify that an inactive session times out and redirects to the @@ -167,6 +176,7 @@ class AuthTestCase(ContentStoreTestCase): (True, 'assertContains'), (False, 'assertNotContains')) @unpack + @override_waffle_flag(toggles.LEGACY_STUDIO_LOGGED_OUT_HOME, True) def test_signin_and_signup_buttons_index_page(self, allow_account_creation, assertion_method_name): """ Navigate to the home page and check the Sign Up button is hidden when ALLOW_PUBLIC_ACCOUNT_CREATION flag @@ -249,6 +259,7 @@ class CourseKeyVerificationTestCase(CourseTestCase): @data(('edX/test_course_key/Test_Course', 200), ('garbage:edX+test_course_key+Test_Course', 404)) @unpack + @override_waffle_flag(toggles.LEGACY_STUDIO_IMPORT, True) def test_course_key_decorator(self, course_key, status_code): """ Tests for the ensure_valid_course_key decorator. diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index 79c722e24d..232bfc45d2 100644 --- a/cms/djangoapps/contentstore/toggles.py +++ b/cms/djangoapps/contentstore/toggles.py @@ -5,6 +5,7 @@ from edx_toggles.toggles import SettingDictToggle, WaffleFlag from openedx.core.djangoapps.content.search import api as search_api from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag + # .. toggle_name: FEATURES['ENABLE_EXPORT_GIT'] # .. toggle_implementation: SettingDictToggle # .. toggle_default: False @@ -66,61 +67,61 @@ def bypass_olx_failure_enabled(): return BYPASS_OLX_FAILURE.is_enabled() -# .. toggle_name: FEATURES['ENABLE_EXAM_SETTINGS_HTML_VIEW'] -# .. toggle_use_cases: open_edx -# .. toggle_implementation: SettingDictToggle +# .. toggle_name: legacy_studio.exam_settings +# .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: When enabled, users can access the new course authoring view for proctoring exams -# .. toggle_warning: None -# .. toggle_creation_date: 2020-07-23 -ENABLE_EXAM_SETTINGS_HTML_VIEW = SettingDictToggle( - "FEATURES", "ENABLE_EXAM_SETTINGS_HTML_VIEW", default=False, module_name=__name__ -) +# .. toggle_description: Temporarily fall back to the old proctored exam settings view. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_EXAM_SETTINGS = CourseWaffleFlag("legacy_studio.exam_settings", __name__) -def exam_setting_view_enabled(): +def exam_setting_view_enabled(course_key): """ Returns a boolean if proctoring exam setting mfe view is enabled. """ - return ENABLE_EXAM_SETTINGS_HTML_VIEW.is_enabled() + return not LEGACY_STUDIO_EXAM_SETTINGS.is_enabled(course_key) -# .. toggle_name: new_core_editors.use_new_text_editor +# .. toggle_name: legacy_studio.text_editor # .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new core text xblock editor +# .. toggle_description: Temporarily fall back to the old Text component (a.k.a. html block) editor. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2021-12-1 -# .. toggle_target_removal_date: 2022-1-30 -# .. toggle_tickets: TNL-9306 -# .. toggle_warning: -ENABLE_NEW_TEXT_EDITOR_FLAG = WaffleFlag('new_core_editors.use_new_text_editor', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_TEXT_EDITOR = CourseWaffleFlag("legacy_studio.text_editor", __name__) -def use_new_text_editor(): +def use_new_text_editor(course_key): """ Returns a boolean = true if new text editor is enabled """ - return ENABLE_NEW_TEXT_EDITOR_FLAG.is_enabled() + return not LEGACY_STUDIO_TEXT_EDITOR.is_enabled(course_key) -# .. toggle_name: new_core_editors.use_new_video_editor +# .. toggle_name: legacy_studio.video_editor # .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new core video xblock editor +# .. toggle_description: Temporarily fall back to the old Video component (a.k.a. video block) editor. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2021-12-1 -# .. toggle_target_removal_date: 2022-1-30 -# .. toggle_tickets: TNL-9306 -# .. toggle_warning: -ENABLE_NEW_VIDEO_EDITOR_FLAG = WaffleFlag('new_core_editors.use_new_video_editor', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_VIDEO_EDITOR = CourseWaffleFlag('legacy_studio.video_editor', __name__) -def use_new_video_editor(): +def use_new_video_editor(course_key): """ Returns a boolean = true if new video editor is enabled """ - return ENABLE_NEW_VIDEO_EDITOR_FLAG.is_enabled() + return not LEGACY_STUDIO_VIDEO_EDITOR.is_enabled(course_key) # .. toggle_name: new_core_editors.use_video_gallery_flow @@ -141,54 +142,23 @@ def use_video_gallery_flow(): return ENABLE_VIDEO_GALLERY_FLOW_FLAG.is_enabled() -# .. toggle_name: new_core_editors.use_new_problem_editor +# .. toggle_name: legacy_studio.problem_editor # .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new core problem xblock editor +# .. toggle_description: Temporarily fall back to the old Problem component (a.k.a. CAPA/problem block) editor. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2021-12-1 -# .. toggle_target_removal_date: 2022-1-30 -# .. toggle_tickets: TNL-9306 -# .. toggle_warning: -ENABLE_NEW_PROBLEM_EDITOR_FLAG = WaffleFlag('new_core_editors.use_new_problem_editor', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_PROBLEM_EDITOR = CourseWaffleFlag('legacy_studio.problem_editor', __name__) -def use_new_problem_editor(): +def use_new_problem_editor(course_key): """ Returns a boolean if new problem editor is enabled """ - return ENABLE_NEW_PROBLEM_EDITOR_FLAG.is_enabled() - - -# .. toggle_name: new_core_editors.use_advanced_problem_editor -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: This flag enables the use of the new core problem xblock advanced editor as the default -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2024-07-25 -# .. toggle_target_removal_date: 2024-08-31 -# .. toggle_tickets: TNL-11694 -# .. toggle_warning: -ENABLE_DEFAULT_ADVANCED_PROBLEM_EDITOR_FLAG = WaffleFlag('new_core_editors.use_advanced_problem_editor', __name__) - - -# .. toggle_name: new_editors.add_game_block_button -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: This flag enables the creation of the new games block -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2023-07-26 -# .. toggle_target_removal_date: 2023-09-31 -# .. toggle_tickets: TNL-10924 -# .. toggle_warning: -ENABLE_ADD_GAME_BLOCK_FLAG = WaffleFlag('new_editors.add_game_block_button', __name__) - - -def use_add_game_block(): - """ - Returns a boolean if add game block button is enabled - """ - return ENABLE_ADD_GAME_BLOCK_FLAG.is_enabled() + return not LEGACY_STUDIO_PROBLEM_EDITOR.is_enabled(course_key) # .. toggle_name: contentstore.individualize_anonymous_user_id @@ -211,213 +181,199 @@ def individualize_anonymous_user_id(course_id): return INDIVIDUALIZE_ANONYMOUS_USER_ID.is_enabled(course_id) -# .. toggle_name: contentstore.enable_studio_content_api +# .. toggle_name: legacy_studio.home # .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: Enables the new (experimental and unsafe!) Studio Content REST API for course authors, -# .. which provides CRUD capabilities for course content and xblock editing. -# .. Use at your own peril - you can easily delete learner data when editing running courses. -# .. This can be triggered by deleting blocks, editing subsections, problems, assignments, discussions, -# .. creating new problems or graded sections, and by other things you do. -# .. toggle_use_cases: open_edx -# .. toggle_creation_date: 2023-05-26 -# .. toggle_tickets: TNL-10208 -ENABLE_STUDIO_CONTENT_API = WaffleFlag( - f'{CONTENTSTORE_NAMESPACE}.enable_studio_content_api', - __name__, -) - - -def use_studio_content_api(): - """ - Returns a boolean if studio editing API is enabled - """ - return ENABLE_STUDIO_CONTENT_API.is_enabled() - - -# .. toggle_name: new_studio_mfe.use_new_home_page -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: This flag enables the use of the new studio home page mfe +# .. toggle_description: Temporarily fall back to the old Studio logged-in landing page. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2023-5-15 -# .. toggle_target_removal_date: 2023-8-31 -# .. toggle_tickets: TNL-9306 -# .. toggle_warning: -ENABLE_NEW_STUDIO_HOME_PAGE = WaffleFlag('new_studio_mfe.use_new_home_page', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_HOME = WaffleFlag('legacy_studio.home', __name__) def use_new_home_page(): """ Returns a boolean if new studio home page mfe is enabled """ - return ENABLE_NEW_STUDIO_HOME_PAGE.is_enabled() + return not LEGACY_STUDIO_HOME.is_enabled() -# .. toggle_name: contentstore.new_studio_mfe.use_new_custom_pages -# .. toggle_implementation: CourseWaffleFlag +# .. toggle_name: legacy_studio.custom_pages +# .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new studio custom pages mfe +# .. toggle_description: Temporarily fall back to the old Studio custom pages tab. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2023-5-15 -# .. toggle_target_removal_date: 2023-8-31 -# .. toggle_tickets: TNL-10619 -# .. toggle_warning: -ENABLE_NEW_STUDIO_CUSTOM_PAGES = CourseWaffleFlag( - f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_custom_pages', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_CUSTOM_PAGES = CourseWaffleFlag("legacy_studio.custom_pages", __name__) def use_new_custom_pages(course_key): """ Returns a boolean if new studio custom pages mfe is enabled """ - return ENABLE_NEW_STUDIO_CUSTOM_PAGES.is_enabled(course_key) + return not LEGACY_STUDIO_CUSTOM_PAGES.is_enabled(course_key) -# .. toggle_name: contentstore.new_studio_mfe.use_new_schedule_details_page +# .. toggle_name: contentstore.use_react_markdown_editor # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new studio schedule and details mfe +# .. toggle_description: This flag enables the use of the Markdown editor when creating or editing problems in the authoring MFE +# .. toggle_use_cases: opt_in +# .. toggle_creation_date: 2025-4-11 +# .. toggle_tickets: https://openedx.atlassian.net/wiki/spaces/OEPM/pages/4517232656/Re-enable+Markdown+editing+of+CAPA+problems+to+meet+various+use+cases +ENABLE_REACT_MARKDOWN_EDITOR = CourseWaffleFlag( + f'{CONTENTSTORE_NAMESPACE}.use_react_markdown_editor', __name__) + + +def use_react_markdown_editor(course_key): + """ + Returns a boolean if new studio custom pages mfe is enabled + """ + return ENABLE_REACT_MARKDOWN_EDITOR.is_enabled(course_key) + + +# .. toggle_name: legacy_studio.schedule_details +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Temporarily fall back to the old Studio Schedule & Details page. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2023-5-15 -# .. toggle_target_removal_date: 2023-8-31 -# .. toggle_tickets: TNL-10619 -# .. toggle_warning: -ENABLE_NEW_STUDIO_SCHEDULE_DETAILS_PAGE = CourseWaffleFlag( - f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_schedule_details_page', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_SCHEDULE_DETAILS = CourseWaffleFlag('legacy_studio.schedule_details', __name__) def use_new_schedule_details_page(course_key): """ Returns a boolean if new studio schedule and details mfe is enabled """ - return ENABLE_NEW_STUDIO_SCHEDULE_DETAILS_PAGE.is_enabled(course_key) + return not LEGACY_STUDIO_SCHEDULE_DETAILS.is_enabled(course_key) -# .. toggle_name: contentstore.new_studio_mfe.use_new_advanced_settings_page -# .. toggle_implementation: CourseWaffleFlag +# .. toggle_name: legacy_studio.advanced_settings +# .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new studio advanced settings page mfe +# .. toggle_description: Temporarily fall back to the old Studio Advanced Settings page. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2023-5-15 -# .. toggle_target_removal_date: 2023-8-31 -# .. toggle_tickets: TNL-10619 -# .. toggle_warning: -ENABLE_NEW_STUDIO_ADVANCED_SETTINGS_PAGE = CourseWaffleFlag( - f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_advanced_settings_page', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_ADVANCED_SETTINGS = CourseWaffleFlag('legacy_studio.advanced_settings', __name__) def use_new_advanced_settings_page(course_key): """ Returns a boolean if new studio advanced settings pafe mfe is enabled """ - return ENABLE_NEW_STUDIO_ADVANCED_SETTINGS_PAGE.is_enabled(course_key) + return not LEGACY_STUDIO_ADVANCED_SETTINGS.is_enabled(course_key) -# .. toggle_name: contentstore.new_studio_mfe.use_new_grading_page -# .. toggle_implementation: CourseWaffleFlag +# .. toggle_name: legacy_studio.grading +# .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new studio grading page mfe +# .. toggle_description: Temporarily fall back to the old Studio Course Grading page. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2023-5-15 -# .. toggle_target_removal_date: 2023-8-31 -# .. toggle_tickets: TNL-10619 -# .. toggle_warning: -ENABLE_NEW_STUDIO_GRADING_PAGE = CourseWaffleFlag( - f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_grading_page', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_GRADING = CourseWaffleFlag('legacy_studio.grading', __name__) def use_new_grading_page(course_key): """ Returns a boolean if new studio grading mfe is enabled """ - return ENABLE_NEW_STUDIO_GRADING_PAGE.is_enabled(course_key) + return not LEGACY_STUDIO_GRADING.is_enabled(course_key) -# .. toggle_name: contentstore.new_studio_mfe.use_new_updates_page -# .. toggle_implementation: CourseWaffleFlag +# .. toggle_name: legacy_studio.updates +# .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new studio updates page mfe +# .. toggle_description: Temporarily fall back to the old Studio Course Updates page. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2023-5-15 -# .. toggle_target_removal_date: 2023-8-31 -# .. toggle_tickets: TNL-10619 -# .. toggle_warning: -ENABLE_NEW_STUDIO_UPDATES_PAGE = CourseWaffleFlag( - f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_updates_page', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_UPDATES = CourseWaffleFlag('legacy_studio.updates', __name__) def use_new_updates_page(course_key): """ Returns a boolean if new studio updates mfe is enabled """ - return ENABLE_NEW_STUDIO_UPDATES_PAGE.is_enabled(course_key) + return not LEGACY_STUDIO_UPDATES.is_enabled(course_key) -# .. toggle_name: contentstore.new_studio_mfe.use_new_import_page -# .. toggle_implementation: CourseWaffleFlag +# .. toggle_name: legacy_studio.import +# .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new studio import page mfe +# .. toggle_description: Temporarily fall back to the old Course Import page. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2023-5-15 -# .. toggle_target_removal_date: 2023-8-31 -# .. toggle_tickets: TNL-10619 -# .. toggle_warning: -ENABLE_NEW_STUDIO_IMPORT_PAGE = CourseWaffleFlag( - f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_import_page', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_IMPORT = CourseWaffleFlag('legacy_studio.import', __name__) def use_new_import_page(course_key): """ Returns a boolean if new studio import mfe is enabled """ - return ENABLE_NEW_STUDIO_IMPORT_PAGE.is_enabled(course_key) + return not LEGACY_STUDIO_IMPORT.is_enabled(course_key) -# .. toggle_name: contentstore.new_studio_mfe.use_new_export_page -# .. toggle_implementation: CourseWaffleFlag +# .. toggle_name: legacy_studio.export +# .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new studio export page mfe +# .. toggle_description: Temporarily fall back to the old Course Export page. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2023-5-15 -# .. toggle_target_removal_date: 2023-8-31 -# .. toggle_tickets: TNL-10619 -# .. toggle_warning: -ENABLE_NEW_STUDIO_EXPORT_PAGE = CourseWaffleFlag( - f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_export_page', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_EXPORT = CourseWaffleFlag('legacy_studio.export', __name__) def use_new_export_page(course_key): """ Returns a boolean if new studio export mfe is enabled """ - return ENABLE_NEW_STUDIO_EXPORT_PAGE.is_enabled(course_key) + return not LEGACY_STUDIO_EXPORT.is_enabled(course_key) -# .. toggle_name: contentstore.new_studio_mfe.use_new_files_uploads_page -# .. toggle_implementation: CourseWaffleFlag +# .. toggle_name: legacy_studio.files_uploads +# .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new studio files and uploads page mfe +# .. toggle_description: Temporarily fall back to the old Studio Files & Uploads page. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2023-5-15 -# .. toggle_target_removal_date: 2023-8-31 -# .. toggle_tickets: TNL-10619 -# .. toggle_warning: -ENABLE_NEW_STUDIO_FILES_UPLOADS_PAGE = CourseWaffleFlag( - f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_files_uploads_page', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_FILES_UPLOADS = CourseWaffleFlag('legacy_studio.files_uploads', __name__) def use_new_files_uploads_page(course_key): """ Returns a boolean if new studio files and uploads mfe is enabled """ - return ENABLE_NEW_STUDIO_FILES_UPLOADS_PAGE.is_enabled(course_key) + return not LEGACY_STUDIO_FILES_UPLOADS.is_enabled(course_key) # .. toggle_name: contentstore.new_studio_mfe.use_new_video_uploads_page # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new video uploads page mfe +# .. toggle_description: This flag enables the use of the new studio video uploads page mfe # .. toggle_use_cases: temporary # .. toggle_creation_date: 2023-5-15 # .. toggle_target_removal_date: 2023-8-31 @@ -434,124 +390,118 @@ def use_new_video_uploads_page(course_key): return ENABLE_NEW_STUDIO_VIDEO_UPLOADS_PAGE.is_enabled(course_key) -# .. toggle_name: contentstore.new_studio_mfe.use_new_course_outline_page -# .. toggle_implementation: CourseWaffleFlag +# .. toggle_name: legacy_studio.course_outline +# .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new studio course outline page mfe +# .. toggle_description: Temporarily fall back to the old Studio Course Outline editor. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2023-5-15 -# .. toggle_target_removal_date: 2023-8-31 -# .. toggle_tickets: TNL-10619 -# .. toggle_warning: -ENABLE_NEW_STUDIO_COURSE_OUTLINE_PAGE = CourseWaffleFlag( - f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_course_outline_page', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_COURSE_OUTLINE = CourseWaffleFlag('legacy_studio.course_outline', __name__) def use_new_course_outline_page(course_key): """ Returns a boolean if new studio course outline mfe is enabled """ - return ENABLE_NEW_STUDIO_COURSE_OUTLINE_PAGE.is_enabled(course_key) + return not LEGACY_STUDIO_COURSE_OUTLINE.is_enabled(course_key) -# .. toggle_name: contentstore.new_studio_mfe.use_new_unit_page -# .. toggle_implementation: CourseWaffleFlag +# .. toggle_name: legacy_studio.unit_editor +# .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new studio course outline page mfe +# .. toggle_description: Temporarily fall back to the old Studio unit editing page. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2023-5-15 -# .. toggle_target_removal_date: 2023-8-31 -# .. toggle_tickets: TNL-10619 -# .. toggle_warning: -ENABLE_NEW_STUDIO_UNIT_PAGE = CourseWaffleFlag( - f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_unit_page', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_UNIT_EDITOR = CourseWaffleFlag('legacy_studio.unit_editor', __name__) def use_new_unit_page(course_key): """ Returns a boolean if new studio course outline mfe is enabled """ - return ENABLE_NEW_STUDIO_UNIT_PAGE.is_enabled(course_key) + return not LEGACY_STUDIO_UNIT_EDITOR.is_enabled(course_key) -# .. toggle_name: contentstore.new_studio_mfe.use_new_course_team_page -# .. toggle_implementation: CourseWaffleFlag +# .. toggle_name: legacy_studio.course_team +# .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new studio course team page mfe +# .. toggle_description: Temporarily fall back to the old Studio Course Team page. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2023-5-15 -# .. toggle_target_removal_date: 2023-8-31 -# .. toggle_tickets: TNL-10619 -# .. toggle_warning: -ENABLE_NEW_STUDIO_COURSE_TEAM_PAGE = CourseWaffleFlag( - f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_course_team_page', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_COURSE_TEAM = CourseWaffleFlag('legacy_studio.course_team', __name__) def use_new_course_team_page(course_key): """ Returns a boolean if new studio course team mfe is enabled """ - return ENABLE_NEW_STUDIO_COURSE_TEAM_PAGE.is_enabled(course_key) + return not LEGACY_STUDIO_COURSE_TEAM.is_enabled(course_key) -# .. toggle_name: contentstore.new_studio_mfe.use_new_certificates_page -# .. toggle_implementation: CourseWaffleFlag +# .. toggle_name: legacy_studio.certificates +# .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new studio course certificates page mfe +# .. toggle_description: Temporarily fall back to the old Studio Course Certificates page. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2024-1-18 -# .. toggle_target_removal_date: 2023-4-31 -# .. toggle_tickets: https://github.com/openedx/platform-roadmap/issues/317 -# .. toggle_warning: -ENABLE_NEW_STUDIO_CERTIFICATES_PAGE = CourseWaffleFlag( - f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_certificates_page', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_CERTIFICATES = CourseWaffleFlag('legacy_studio.certificates', __name__) def use_new_certificates_page(course_key): """ Returns a boolean if new studio certificates mfe is enabled """ - return ENABLE_NEW_STUDIO_CERTIFICATES_PAGE.is_enabled(course_key) + return not LEGACY_STUDIO_CERTIFICATES.is_enabled(course_key) -# .. toggle_name: contentstore.new_studio_mfe.use_new_textbooks_page -# .. toggle_implementation: CourseWaffleFlag +# .. toggle_name: legacy_studio.textbooks +# .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new studio course textbooks page mfe +# .. toggle_description: Temporarily fall back to the old Studio Textbooks page. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2024-1-18 -# .. toggle_target_removal_date: 2023-4-31 -# .. toggle_tickets: https://github.com/openedx/platform-roadmap/issues/319 -# .. toggle_warning: -ENABLE_NEW_STUDIO_TEXTBOOKS_PAGE = CourseWaffleFlag( - f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_textbooks_page', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_TEXTBOOKS = CourseWaffleFlag('legacy_studio.textbooks', __name__) def use_new_textbooks_page(course_key): """ Returns a boolean if new studio textbooks mfe is enabled """ - return ENABLE_NEW_STUDIO_TEXTBOOKS_PAGE.is_enabled(course_key) + return not LEGACY_STUDIO_TEXTBOOKS.is_enabled(course_key) -# .. toggle_name: contentstore.new_studio_mfe.use_new_group_configurations_page -# .. toggle_implementation: CourseWaffleFlag +# .. toggle_name: legacy_studio.configurations +# .. toggle_implementation: WaffleFlag # .. toggle_default: False -# .. toggle_description: This flag enables the use of the new studio course group configurations page mfe +# .. toggle_description: Temporarily fall back to the old Studio Configurations page. # .. toggle_use_cases: temporary -# .. toggle_creation_date: 2024-1-18 -# .. toggle_target_removal_date: 2023-4-31 -# .. toggle_tickets: https://github.com/openedx/platform-roadmap/issues/318 -# .. toggle_warning: -ENABLE_NEW_STUDIO_GROUP_CONFIGURATIONS_PAGE = CourseWaffleFlag( - f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.use_new_group_configurations_page', __name__) +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available. +LEGACY_STUDIO_CONFIGURATIONS = CourseWaffleFlag('legacy_studio.configurations', __name__) def use_new_group_configurations_page(course_key): """ Returns a boolean if new studio group configurations mfe is enabled """ - return ENABLE_NEW_STUDIO_GROUP_CONFIGURATIONS_PAGE.is_enabled(course_key) + return not LEGACY_STUDIO_CONFIGURATIONS.is_enabled(course_key) # .. toggle_name: contentstore.mock_video_uploads @@ -667,3 +617,45 @@ def libraries_v2_enabled(): search_api.is_meilisearch_enabled() and not DISABLE_NEW_LIBRARIES.is_enabled() ) + + +# .. toggle_name: contentstore.enable_course_optimizer +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: This flag enables the course optimizer tool in the authoring MFE. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2025-01-17 +# .. toggle_target_removal_date: 2025-05-30 +# .. toggle_tickets: TNL-11837 +ENABLE_COURSE_OPTIMIZER = CourseWaffleFlag( + f'{CONTENTSTORE_NAMESPACE}.enable_course_optimizer', __name__ +) + + +def enable_course_optimizer(course_id): + """ + Returns a boolean if course optimizer is enabled on the course + """ + return ENABLE_COURSE_OPTIMIZER.is_enabled(course_id) + + +# .. toggle_name: legacy_studio.logged_out_home +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Temporarily fall back to the old Studio "How it Works" page when unauthenticated +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2025-03-14 +# .. toggle_target_removal_date: 2025-09-14 +# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275 +# .. toggle_warning: In Ulmo, this toggle will be removed, along with the legacy page. The only available +# behavior will be to send the user to the log-in page with a redirect to Studio Course Listing (/home). +LEGACY_STUDIO_LOGGED_OUT_HOME = WaffleFlag('legacy_studio.logged_out_home', __name__) + + +def use_legacy_logged_out_home(): + """ + Returns whether the old "how it works" page should be shown. + + If not, then we should just go to the login page w/ redirect to studio course listing. + """ + return LEGACY_STUDIO_LOGGED_OUT_HOME.is_enabled() diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index c0f656ec70..c4049a818f 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -2,6 +2,7 @@ Common utility functions useful throughout the contentstore """ from __future__ import annotations + import configparser import html import logging @@ -9,12 +10,12 @@ import re from collections import defaultdict from contextlib import contextmanager from datetime import datetime, timezone -from urllib.parse import quote_plus, urlencode, urlunparse, urlparse +from urllib.parse import quote_plus, urlencode, urlparse, urlunparse from uuid import uuid4 from bs4 import BeautifulSoup from django.conf import settings -from django.core.exceptions import ValidationError +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.urls import reverse from django.utils import translation from django.utils.text import Truncator @@ -22,32 +23,32 @@ from django.utils.translation import gettext as _ from eventtracking import tracker from help_tokens.core import HelpUrlExpert from lti_consumer.models import CourseAllowPIISharingInLTIFlag -from opaque_keys.edx.keys import CourseKey, UsageKey -from opaque_keys.edx.locator import LibraryLocator - -from openedx.core.lib.teams_config import CONTENT_GROUPS_FOR_TEAMS, TEAM_SCHEME +from milestones import api as milestones_api +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey, UsageKey, UsageKeyV2 +from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocator from openedx_events.content_authoring.data import DuplicatedXBlockData from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED from openedx_events.learning.data import CourseNotificationData from openedx_events.learning.signals import COURSE_NOTIFICATION_REQUESTED - -from milestones import api as milestones_api from pytz import UTC from xblock.fields import Scope from cms.djangoapps.contentstore.toggles import ( + enable_course_optimizer, exam_setting_view_enabled, libraries_v1_enabled, libraries_v2_enabled, split_library_view_on_dashboard, use_new_advanced_settings_page, - use_new_course_outline_page, use_new_certificates_page, + use_new_course_outline_page, + use_new_course_team_page, + use_new_custom_pages, use_new_export_page, use_new_files_uploads_page, use_new_grading_page, use_new_group_configurations_page, - use_new_course_team_page, use_new_home_page, use_new_import_page, use_new_schedule_details_page, @@ -57,16 +58,15 @@ from cms.djangoapps.contentstore.toggles import ( use_new_updates_page, use_new_video_editor, use_new_video_uploads_page, - use_new_custom_pages, ) from cms.djangoapps.models.settings.course_grading import CourseGradingModel from cms.djangoapps.models.settings.course_metadata import CourseMetadata -from common.djangoapps.course_action_state.models import CourseRerunUIStateManager, CourseRerunState from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError +from common.djangoapps.course_action_state.models import CourseRerunState, CourseRerunUIStateManager from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.edxmako.services import MakoService from common.djangoapps.student import auth -from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access, STUDIO_EDIT_ROLES +from common.djangoapps.student.auth import STUDIO_EDIT_ROLES, has_studio_read_access, has_studio_write_access from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.roles import ( CourseInstructorRole, @@ -75,41 +75,48 @@ from common.djangoapps.student.roles import ( ) from common.djangoapps.track import contexts from common.djangoapps.util.course import get_link_for_about_page +from common.djangoapps.util.date_utils import get_default_time_display from common.djangoapps.util.milestones_helpers import ( + generate_milestone_namespace, + get_namespace_choices, is_prerequisite_courses_enabled, is_valid_course_key, remove_prerequisite_course, set_prerequisite_courses, - get_namespace_choices, - generate_milestone_namespace ) -from common.djangoapps.util.date_utils import get_default_time_display from common.djangoapps.xblock_django.api import deprecated_xblocks from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService from openedx.core import toggles as core_toggles +from openedx.core.djangoapps.content_libraries.api import get_container +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content_tagging.toggles import is_tagging_feature_disabled from openedx.core.djangoapps.credit.api import get_credit_requirements, is_credit_course from openedx.core.djangoapps.discussions.config.waffle import ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration from openedx.core.djangoapps.django_comment_common.models import assign_default_role from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles +from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration.models import SiteConfiguration -from openedx.core.djangoapps.models.course_details import CourseDetails +from openedx.core.djangoapps.xblock.api import get_component_from_usage_key from openedx.core.lib.courses import course_image_url from openedx.core.lib.html_to_text import html_to_text +from openedx.core.lib.teams_config import CONTENT_GROUPS_FOR_TEAMS, TEAM_SCHEME from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML -from xmodule.library_tools import LegacyLibraryToolsService from xmodule.course_block import DEFAULT_START_DATE # lint-amnesty, pylint: disable=wrong-import-order from xmodule.data import CertificatesDisplayBehaviors +from xmodule.library_tools import LegacyLibraryToolsService from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.partitions.partitions_service import get_all_partitions_for_course # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.services import SettingsService, ConfigurationService, TeamsConfigurationService +from xmodule.partitions.partitions_service import ( + get_all_partitions_for_course, # lint-amnesty, pylint: disable=wrong-import-order +) +from xmodule.services import ConfigurationService, SettingsService, TeamsConfigurationService +from .models import ComponentLink, ContainerLink IMPORTABLE_FILE_TYPES = ('.tar.gz', '.zip') log = logging.getLogger(__name__) @@ -266,7 +273,7 @@ def get_proctored_exam_settings_url(course_locator) -> str: Gets course authoring microfrontend URL for links to proctored exam settings page """ proctored_exam_settings_url = '' - if exam_setting_view_enabled(): + if exam_setting_view_enabled(course_locator): mfe_base_url = get_course_authoring_url(course_locator) course_mfe_url = f'{mfe_base_url}/course/{course_locator}' if mfe_base_url: @@ -279,7 +286,7 @@ def get_editor_page_base_url(course_locator) -> str: Gets course authoring microfrontend URL for links to the new base editors """ editor_url = None - if use_new_text_editor() or use_new_video_editor(): + if use_new_text_editor(course_locator) or use_new_video_editor(course_locator): mfe_base_url = get_course_authoring_url(course_locator) course_mfe_url = f'{mfe_base_url}/course/{course_locator}/editor' if mfe_base_url: @@ -390,6 +397,19 @@ def get_export_url(course_locator) -> str: return export_url +def get_optimizer_url(course_locator) -> str: + """ + Gets course authoring microfrontend URL for optimizer page view. + """ + optimizer_url = None + if enable_course_optimizer(course_locator): + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/optimizer' + if mfe_base_url: + optimizer_url = course_mfe_url + return optimizer_url + + def get_files_uploads_url(course_locator) -> str: """ Gets course authoring microfrontend URL for files and uploads page view. @@ -416,7 +436,7 @@ def get_video_uploads_url(course_locator) -> str: return video_uploads_url -def get_course_outline_url(course_locator) -> str: +def get_course_outline_url(course_locator, block_to_show=None) -> str: """ Gets course authoring microfrontend URL for course oultine page view. """ @@ -424,6 +444,8 @@ def get_course_outline_url(course_locator) -> str: if use_new_course_outline_page(course_locator): mfe_base_url = get_course_authoring_url(course_locator) course_mfe_url = f'{mfe_base_url}/course/{course_locator}' + if block_to_show: + course_mfe_url += f'?show={quote_plus(block_to_show)}' if mfe_base_url: course_outline_url = course_mfe_url return course_outline_url @@ -506,6 +528,18 @@ def get_custom_pages_url(course_locator) -> str: return custom_pages_url +def get_course_libraries_url(course_locator) -> str: + """ + Gets course authoring microfrontend URL for custom pages view. + """ + url = None + if libraries_v2_enabled(): + mfe_base_url = get_course_authoring_url(course_locator) + if mfe_base_url: + url = f'{mfe_base_url}/course/{course_locator}/libraries' + return url + + def get_taxonomy_list_url() -> str | None: """ Gets course authoring microfrontend URL for taxonomy list page view. @@ -1179,6 +1213,7 @@ def duplicate_block( store.update_item(parent, user.id) # .. event_implemented_name: XBLOCK_DUPLICATED + # .. event_type: org.openedx.content_authoring.xblock.duplicated.v1 XBLOCK_DUPLICATED.send_event( time=datetime.now(timezone.utc), xblock_info=DuplicatedXBlockData( @@ -1552,6 +1587,9 @@ def get_library_context(request, request_is_json=False): from cms.djangoapps.contentstore.views.library import ( user_can_view_create_library_button, ) + from openedx.core.djangoapps.content_libraries.api import ( + user_can_create_library, + ) libraries = _accessible_libraries_iter(request.user) if libraries_v1_enabled() else [] data = { @@ -1565,6 +1603,7 @@ def get_library_context(request, request_is_json=False): 'courses': [], 'libraries_enabled': libraries_v1_enabled(), 'show_new_library_button': user_can_view_create_library_button(request.user) and request.user.is_active, + 'show_new_library_v2_button': user_can_create_library(request.user), 'user': request.user, 'request_course_creator_url': reverse('request_course_creator'), 'course_creator_status': _get_course_creator_status(request.user), @@ -1591,7 +1630,6 @@ def get_course_context(request): from cms.djangoapps.contentstore.views.course import ( get_courses_accessible_to_user, _process_courses_list, - ENABLE_GLOBAL_STAFF_OPTIMIZATION, ) def format_in_process_course_view(uca): @@ -1615,10 +1653,7 @@ def get_course_context(request): ) if uca.state == CourseRerunUIStateManager.State.FAILED else '' } - optimization_enabled = GlobalStaff().has_user(request.user) and ENABLE_GLOBAL_STAFF_OPTIMIZATION.is_enabled() - - org = request.GET.get('org', '') if optimization_enabled else None - courses_iter, in_process_course_actions = get_courses_accessible_to_user(request, org) + courses_iter, in_process_course_actions = get_courses_accessible_to_user(request) split_archived = settings.FEATURES.get('ENABLE_SEPARATE_ARCHIVED_COURSES', False) active_courses, archived_courses = _process_courses_list(courses_iter, in_process_course_actions, split_archived) in_process_course_actions = [format_in_process_course_view(uca) for uca in in_process_course_actions] @@ -1633,7 +1668,6 @@ def get_course_context_v2(request): # 'cms.djangoapps.contentstore.utils' (most likely due to a circular import) from cms.djangoapps.contentstore.views.course import ( get_courses_accessible_to_user, - ENABLE_GLOBAL_STAFF_OPTIMIZATION, ) def format_in_process_course_view(uca): @@ -1660,10 +1694,7 @@ def get_course_context_v2(request): ) if uca.state == CourseRerunUIStateManager.State.FAILED else '' } - optimization_enabled = GlobalStaff().has_user(request.user) and ENABLE_GLOBAL_STAFF_OPTIMIZATION.is_enabled() - - org = request.GET.get('org', '') if optimization_enabled else None - courses_iter, in_process_course_actions = get_courses_accessible_to_user(request, org) + courses_iter, in_process_course_actions = get_courses_accessible_to_user(request) in_process_course_actions = [format_in_process_course_view(uca) for uca in in_process_course_actions] return courses_iter, in_process_course_actions @@ -1681,18 +1712,18 @@ def get_home_context(request, no_course=False): _accessible_libraries_iter, _get_course_creator_status, _format_library_for_view, - ENABLE_GLOBAL_STAFF_OPTIMIZATION, ) from cms.djangoapps.contentstore.views.library import ( user_can_view_create_library_button, ) + from openedx.core.djangoapps.content_libraries.api import ( + user_can_create_library, + ) active_courses = [] archived_courses = [] in_process_course_actions = [] - optimization_enabled = GlobalStaff().has_user(request.user) and ENABLE_GLOBAL_STAFF_OPTIMIZATION.is_enabled() - user = request.user libraries = [] @@ -1714,13 +1745,13 @@ def get_home_context(request, no_course=False): 'taxonomy_list_mfe_url': get_taxonomy_list_url(), 'libraries': libraries, 'show_new_library_button': user_can_view_create_library_button(user), + 'show_new_library_v2_button': user_can_create_library(user), 'user': user, 'request_course_creator_url': reverse('request_course_creator'), 'course_creator_status': _get_course_creator_status(user), 'rerun_creator_status': GlobalStaff().has_user(user), 'allow_unicode_course_id': settings.FEATURES.get('ALLOW_UNICODE_COURSE_ID', False), 'allow_course_reruns': settings.FEATURES.get('ALLOW_COURSE_RERUNS', True), - 'optimization_enabled': optimization_enabled, 'active_tab': 'courses', 'allowed_organizations': get_allowed_organizations(user), 'allowed_organizations_for_libraries': get_allowed_organizations_for_libraries(user), @@ -1902,7 +1933,10 @@ def _get_course_index_context(request, course_key, course_block): course_block.discussions_settings['discussion_configuration_url'] = ( f'{get_pages_and_resources_url(course_block.id)}/discussion/settings' ) - + try: + course_overview = CourseOverview.objects.get(id=course_block.id) + except CourseOverview.DoesNotExist: + course_overview = None course_index_context = { 'language_code': request.LANGUAGE_CODE, 'context_course': course_block, @@ -1929,8 +1963,8 @@ def _get_course_index_context(request, course_key, course_block): 'advance_settings_url': reverse_course_url('advanced_settings_handler', course_block.id), 'proctoring_errors': proctoring_errors, 'taxonomy_tags_widget_url': get_taxonomy_tags_widget_url(course_block.id), + 'created_on': course_overview.created if course_overview else None, } - return course_index_context @@ -2092,11 +2126,7 @@ def get_certificates_context(course, user): handler_name='certificate_activation_handler', course_key=course_key ) - course_modes = [ - mode.slug for mode in CourseMode.modes_for_course( - course_id=course_key, include_expired=True - ) if mode.slug != 'audit' - ] + course_modes = CertificateManager.get_course_modes(course) has_certificate_modes = len(course_modes) > 0 @@ -2286,6 +2316,8 @@ def send_course_update_notification(course_key, content, user): app_name="updates", audience_filters={}, ) + # .. event_implemented_name: COURSE_NOTIFICATION_REQUESTED + # .. event_type: org.openedx.learning.course.notification.requested.v1 COURSE_NOTIFICATION_REQUESTED.send_event(course_notification_data=notification_data) @@ -2344,3 +2376,62 @@ def get_xblock_render_error(request, xblock): return str(exc) return "" + + +def _create_or_update_component_link(course_key: CourseKey, created: datetime | None, xblock): + """ + Create or update upstream->downstream link for components in database for given xblock. + """ + upstream_usage_key = UsageKeyV2.from_string(xblock.upstream) + try: + lib_component = get_component_from_usage_key(upstream_usage_key) + except ObjectDoesNotExist: + log.error(f"Library component not found for {upstream_usage_key}") + lib_component = None + ComponentLink.update_or_create( + lib_component, + upstream_usage_key=upstream_usage_key, + upstream_context_key=str(upstream_usage_key.context_key), + downstream_context_key=course_key, + downstream_usage_key=xblock.usage_key, + version_synced=xblock.upstream_version, + version_declined=xblock.upstream_version_declined, + created=created, + ) + + +def _create_or_update_container_link(course_key: CourseKey, created: datetime | None, xblock): + """ + Create or update upstream->downstream link for containers in database for given xblock. + """ + upstream_container_key = LibraryContainerLocator.from_string(xblock.upstream) + try: + lib_component = get_container(upstream_container_key).container_pk + except ObjectDoesNotExist: + log.error(f"Library component not found for {upstream_container_key}") + lib_component = None + ContainerLink.update_or_create( + lib_component, + upstream_container_key=upstream_container_key, + upstream_context_key=str(upstream_container_key.context_key), + downstream_context_key=course_key, + downstream_usage_key=xblock.usage_key, + version_synced=xblock.upstream_version, + version_declined=xblock.upstream_version_declined, + created=created, + ) + + +def create_or_update_xblock_upstream_link(xblock, course_key: CourseKey, created: datetime | None = None) -> None: + """ + Create or update upstream->downstream link in database for given xblock. + """ + if not xblock.upstream: + return None + try: + # Try to create component link + _create_or_update_component_link(course_key, created, xblock) + except InvalidKeyError: + # It is possible that the upstream is a container and UsageKeyV2 parse failed + # Create upstream container link and raise InvalidKeyError if xblock.upstream is a valid key. + _create_or_update_container_link(course_key, created, xblock) diff --git a/cms/djangoapps/contentstore/video_storage_handlers.py b/cms/djangoapps/contentstore/video_storage_handlers.py index 4cc5c738b5..87086c9951 100644 --- a/cms/djangoapps/contentstore/video_storage_handlers.py +++ b/cms/djangoapps/contentstore/video_storage_handlers.py @@ -724,7 +724,9 @@ def get_all_transcript_languages(): third_party_transcription_languages.update(cielo_fidelity['PREMIUM']['languages']) third_party_transcription_languages.update(cielo_fidelity['PROFESSIONAL']['languages']) - all_languages_dict = dict(settings.ALL_LANGUAGES, **third_party_transcription_languages) + # combines ALL_LANGUAGES with additional languages that should be supported for transcripts + extended_all_languages = settings.ALL_LANGUAGES + settings.EXTENDED_VIDEO_TRANSCRIPT_LANGUAGES + all_languages_dict = dict(extended_all_languages, **third_party_transcription_languages) # Return combined system settings and 3rd party transcript languages. all_languages = [] for key, value in sorted(all_languages_dict.items(), key=lambda k_v: k_v[1]): @@ -995,9 +997,9 @@ def get_course_youtube_edx_video_ids(course_id): f"InvalidKeyError occurred while getting YouTube video IDs for course_id: {course_id}: {error}" ) return JsonResponse({'error': invalid_key_error_msg}, status=500) - except Exception as error: + except (TypeError, AttributeError) as error: LOGGER.exception( - f"Unexpected error occurred while getting YouTube video IDs for course_id: {course_id}: {error}" + f"Error occurred while getting YouTube video IDs for course_id: {course_id}: {error}" ) return JsonResponse({'error': unexpected_error_msg}, status=500) diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index e6b41dc261..b57042085d 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -9,13 +9,14 @@ from django.core.exceptions import PermissionDenied from django.db import transaction from django.http import Http404, HttpResponse from django.utils.translation import gettext as _ +from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.http import require_http_methods from opaque_keys.edx.keys import CourseKey from web_fragments.fragment import Fragment from cms.djangoapps.contentstore.utils import load_services_for_studio from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW -from common.djangoapps.edxmako.shortcuts import render_to_string +from common.djangoapps.edxmako.shortcuts import render_to_response, render_to_string from common.djangoapps.student.auth import ( has_studio_read_access, has_studio_write_access, @@ -44,6 +45,8 @@ from ..helpers import ( is_unit, ) from .preview import get_preview_fragment +from .component import _get_item_in_course +from ..utils import get_container_handler_context from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import ( handle_xblock, @@ -54,7 +57,7 @@ from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import ( ) from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import ( usage_key_with_run, - get_children_tags_count, + get_tags_count, ) @@ -242,10 +245,10 @@ def xblock_view_handler(request, usage_key_string, view_name): force_render = request.GET.get("force_render", None) - # Fetch tags of children components + # Fetch tags of xblock and children components tags_count_map = {} if not is_tagging_feature_disabled(): - tags_count_map = get_children_tags_count(xblock) + tags_count_map = get_tags_count(xblock, include_children=True) # Set up the context to be passed to each XBlock's render method. context = request.GET.dict() @@ -302,6 +305,39 @@ def xblock_view_handler(request, usage_key_string, view_name): return HttpResponse(status=406) +@xframe_options_exempt +@require_http_methods(["GET"]) +@login_required +def xblock_edit_view(request, usage_key_string): + """ + Return rendered xblock edit view. + + Allows editing of an XBlock specified by the usage key. + """ + usage_key = usage_key_with_run(usage_key_string) + if not has_studio_read_access(request.user, usage_key.course_key): + raise PermissionDenied() + + store = modulestore() + + with store.bulk_operations(usage_key.course_key): + course, xblock, _, __ = _get_item_in_course(request, usage_key) + container_handler_context = get_container_handler_context(request, usage_key, course, xblock) + + fragment = get_preview_fragment(request, xblock, {}) + + hashed_resources = { + hash_resource(resource): resource._asdict() for resource in fragment.resources + } + + container_handler_context.update({ + "action_name": "edit", + "resources": list(hashed_resources.items()), + }) + + return render_to_response('container_editor.html', container_handler_context) + + @require_http_methods("GET") @login_required @expect_json diff --git a/cms/djangoapps/contentstore/views/certificates.py b/cms/djangoapps/contentstore/views/certificates.py index bfc1bf7e9b..c50ea54d9a 100644 --- a/cms/djangoapps/contentstore/views/certificates.py +++ b/cms/djangoapps/contentstore/views/certificates.py @@ -32,12 +32,19 @@ from django.core.exceptions import PermissionDenied from django.http import HttpResponse from django.shortcuts import redirect from django.utils.translation import gettext as _ +from django.utils.decorators import method_decorator from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import require_http_methods +from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework import status +from common.djangoapps.course_modes.models import CourseMode from eventtracking import tracker from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import AssetKey, CourseKey +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.student.auth import has_studio_write_access from common.djangoapps.student.roles import GlobalStaff @@ -46,6 +53,10 @@ from common.djangoapps.util.json_request import JsonResponse from xmodule.modulestore import EdxJSONEncoder # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from cms.djangoapps.contentstore.views.serializers import CertificateActivationSerializer +from cms.djangoapps.contentstore.views.permissions import HasStudioWriteAccess + + from ..exceptions import AssetNotFoundException from ..toggles import use_new_certificates_page from ..utils import ( @@ -271,6 +282,28 @@ class CertificateManager: certificates = [certificate for certificate in certificates if certificate.get('is_active', False)] return certificates + @staticmethod + def get_course_modes(course): + """ + Retrieve certificate modes for the given course, + including expired modes but excluding audit mode. + """ + course_modes = [ + mode.slug for mode in CourseMode.modes_for_course( + course=course, include_expired=True + ) if mode.slug != CourseMode.AUDIT + ] + return course_modes + + @staticmethod + def is_enabled(course): + """ + Is enabled when there is at least one course mode for the given course, + including expired modes but excluding audit mode + """ + course_modes = CertificateManager.get_course_modes(course) + return len(course_modes) > 0 + @staticmethod def remove_certificate(request, store, course, certificate_id): """ @@ -337,39 +370,56 @@ class Certificate: return self._certificate_data -@login_required -@require_http_methods(("POST",)) -@ensure_csrf_cookie -def certificate_activation_handler(request, course_key_string): +class ModulestoreMixin: """ - A handler for Certificate Activation/Deactivation - - POST - json: is_active. update the activation state of certificate + Mixin to provide a get_modulestore() method for views. + Makes it easier to override or patch in tests. """ - course_key = CourseKey.from_string(course_key_string) - store = modulestore() - try: - course = _get_course_and_check_access(course_key, request.user) - except PermissionDenied: - msg = _('PermissionDenied: Failed in authenticating {user}').format(user=request.user) - return JsonResponse({"error": msg}, status=403) + def get_modulestore(self): + return modulestore() - data = json.loads(request.body.decode('utf8')) - is_active = data.get('is_active', False) - certificates = CertificateManager.get_certificates(course) - # for certificate activation/deactivation, we are assuming one certificate in certificates collection. - for certificate in certificates: - certificate['is_active'] = is_active - break +class CertificateActivationAPIView( + DeveloperErrorViewMixin, + ModulestoreMixin, + APIView +): + """ + View for activating or deactivating course certificates. + This view allows instructors to toggle the activation state of course certificates. + """ + permission_classes = [IsAuthenticated, HasStudioWriteAccess] + serializer_class = CertificateActivationSerializer - store.update_item(course, request.user.id) - cert_event_type = 'activated' if is_active else 'deactivated' - CertificateManager.track_event(cert_event_type, { - 'course_id': str(course.id), - }) - return HttpResponse(status=200) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_key_string): + """ + A handler for Certificate Activation/Deactivation + + POST + json: is_active. update the activation state of certificate + """ + course_key = CourseKey.from_string(course_key_string) + course = self.get_modulestore().get_course(course_key, depth=0) + + serializer = self.serializer_class(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + is_active = serializer.validated_data['is_active'] + certificates = CertificateManager.get_certificates(course) + + # for certificate activation/deactivation, we are assuming one certificate in certificates collection. + for certificate in certificates: + certificate['is_active'] = is_active + break + + self.get_modulestore().update_item(course, request.user.id) + cert_event_type = 'activated' if is_active else 'deactivated' + CertificateManager.track_event(cert_event_type, { + 'course_id': str(course.id), + }) + return Response(status=status.HTTP_200_OK) @login_required diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 914c078846..34c1f465c5 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -25,7 +25,11 @@ from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.student.auth import has_course_author_access from common.djangoapps.xblock_django.api import authorable_xblocks, disabled_xblocks from common.djangoapps.xblock_django.models import XBlockStudioConfigurationFlag -from cms.djangoapps.contentstore.helpers import is_unit +from cms.djangoapps.contentstore.helpers import ( + get_parent_if_split_test, + is_unit, + is_library_content, +) from cms.djangoapps.contentstore.toggles import ( libraries_v1_enabled, libraries_v2_enabled, @@ -78,6 +82,16 @@ CONTAINER_TEMPLATES = [ "edit-title-button", "edit-upstream-alert", ] +DEFAULT_ADVANCED_MODULES = [ + 'google-calendar', + 'google-document', + 'lti_consumer', + 'poll', + 'split_test', + 'survey', + 'word_cloud', +] + def _advanced_component_types(show_unsupported): """ @@ -148,11 +162,12 @@ def container_handler(request, usage_key_string): # pylint: disable=too-many-st except ItemNotFoundError: return HttpResponseBadRequest() - is_unit_page = is_unit(xblock) - unit = xblock if is_unit_page else None + if use_new_unit_page(course.id): + if is_unit(xblock) or is_library_content(xblock): + return redirect(get_unit_url(course.id, xblock.location)) - if is_unit_page and use_new_unit_page(course.id): - return redirect(get_unit_url(course.id, unit.location)) + if split_xblock := get_parent_if_split_test(xblock): + return redirect(get_unit_url(course.id, split_xblock.location)) container_handler_context = get_container_handler_context(request, usage_key, course, xblock) container_handler_context.update({ @@ -355,14 +370,14 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint: #If using new problem editor, we select problem type inside the editor # because of this, we only show one problem. - if category == 'problem' and use_new_problem_editor(): + if category == 'problem' and use_new_problem_editor(courselike.context_key): templates_for_category = [ template for template in templates_for_category if template['boilerplate_name'] == 'blank_common.yaml' ] # Add any advanced problem types. Note that these are different xblocks being stored as Advanced Problems, # currently not supported in libraries . - if category == 'problem' and not library and not use_new_problem_editor(): + if category == 'problem' and not library and not use_new_problem_editor(courselike.context_key): disabled_block_names = [block.name for block in disabled_xblocks()] advanced_problem_types = [advanced_problem_type for advanced_problem_type in ADVANCED_PROBLEM_TYPES if advanced_problem_type['component'] not in disabled_block_names] @@ -440,7 +455,7 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint: # These modules should be specified as a list of strings, where the strings # are the names of the modules in ADVANCED_COMPONENT_TYPES that should be # enabled for the course. - course_advanced_keys = courselike.advanced_modules + course_advanced_keys = list(dict.fromkeys(courselike.advanced_modules + DEFAULT_ADVANCED_MODULES)) advanced_component_templates = { "type": "advanced", "templates": [], @@ -479,6 +494,11 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint: course_advanced_keys ) if advanced_component_templates['templates']: + # Advanced component templates should be sorted alphabetically by display name. + advanced_component_templates['templates'] = sorted( + advanced_component_templates['templates'], + key=lambda x: x.get('display_name') + ) component_templates.append(advanced_component_templates) return component_templates diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 9f6cfb7c43..ffb93ed010 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -15,20 +15,23 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.core.exceptions import FieldError, PermissionDenied, ValidationError as DjangoValidationError +from django.db.models import QuerySet from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound from django.shortcuts import redirect from django.urls import reverse from django.utils.translation import gettext as _ from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import require_GET, require_http_methods +from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiRequest, OpenApiResponse from edx_django_utils.monitoring import function_trace -from edx_toggles.toggles import WaffleSwitch from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import BlockUsageLocator from organizations.api import add_organization_course, ensure_organization from organizations.exceptions import InvalidOrganizationException from rest_framework.exceptions import ValidationError +from rest_framework.decorators import api_view +from openedx.core.lib.api.view_utils import view_auth_classes from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import create_xblock_info from cms.djangoapps.course_creators.views import add_user_with_status_unrequested, get_course_creator_status @@ -135,12 +138,7 @@ __all__ = ['course_info_handler', 'course_handler', 'course_listing', 'course_notifications_handler', 'textbooks_list_handler', 'textbooks_detail_handler', 'group_configurations_list_handler', 'group_configurations_detail_handler', - 'get_course_and_check_access'] - -WAFFLE_NAMESPACE = 'studio_home' -ENABLE_GLOBAL_STAFF_OPTIMIZATION = WaffleSwitch( # lint-amnesty, pylint: disable=toggle-missing-annotation - f'{WAFFLE_NAMESPACE}.enable_global_staff_optimization', __name__ -) + 'get_course_and_check_access', 'bulk_enable_disable_discussions'] class AccessListFallback(Exception): @@ -393,15 +391,12 @@ def get_in_process_course_actions(request): ] -def _accessible_courses_summary_iter(request, org=None): +def _accessible_courses_summary_iter(request): """ List all courses available to the logged in user by iterating through all the courses Arguments: request: the request object - org (string): if not None, this value will limit the courses returned. An empty - string will result in no courses, and otherwise only courses with the - specified org will be returned. The default value is None. """ def course_filter(course_summary): """ @@ -413,25 +408,15 @@ def _accessible_courses_summary_iter(request, org=None): return has_studio_read_access(request.user, course_summary.id) - enable_home_page_api_v2 = settings.FEATURES["ENABLE_HOME_PAGE_COURSE_API_V2"] - - if org is not None: - courses_summary = [] if org == '' else CourseOverview.get_all_courses(orgs=[org]) - elif enable_home_page_api_v2: - # If the new home page API is enabled, we should use the Django ORM to filter and order the courses - courses_summary = CourseOverview.get_all_courses() - else: - courses_summary = modulestore().get_course_summaries() - - if enable_home_page_api_v2: - search_query, order, active_only, archived_only = get_query_params_if_present(request) - courses_summary = get_filtered_and_ordered_courses( - courses_summary, - active_only, - archived_only, - search_query, - order, - ) + courses_summary = CourseOverview.get_all_courses() + search_query, order, active_only, archived_only = get_query_params_if_present(request) + courses_summary = get_filtered_and_ordered_courses( + courses_summary, + active_only, + archived_only, + search_query, + order, + ) courses_summary = filter(course_filter, courses_summary) in_process_course_actions = get_in_process_course_actions(request) @@ -575,6 +560,10 @@ def _accessible_courses_list_from_groups(request): if course_keys: courses_list = CourseOverview.get_all_courses(filter_={'id__in': course_keys}) + else: + # If no course keys are found for the current user, then return without filtering + # or ordering the courses list. + return courses_list, [] search_query, order, active_only, archived_only = get_query_params_if_present(request) courses_list = get_filtered_and_ordered_courses( @@ -588,7 +577,11 @@ def _accessible_courses_list_from_groups(request): return courses_list, [] -def get_courses_by_status(active_only, archived_only, course_overviews): +def get_courses_by_status( + active_only: bool, + archived_only: bool, + course_overviews: QuerySet[CourseOverview] +) -> QuerySet[CourseOverview]: """ Return course overviews based on a base queryset filtered by a status. @@ -602,7 +595,10 @@ def get_courses_by_status(active_only, archived_only, course_overviews): return CourseOverview.get_courses_by_status(active_only, archived_only, course_overviews) -def get_courses_by_search_query(search_query, course_overviews): +def get_courses_by_search_query( + search_query: str | None, + course_overviews: QuerySet[CourseOverview] +) -> QuerySet[CourseOverview]: """Return course overviews based on a base queryset filtered by a search query. Args: @@ -614,7 +610,10 @@ def get_courses_by_search_query(search_query, course_overviews): return CourseOverview.get_courses_matching_query(search_query, course_overviews=course_overviews) -def get_courses_order_by(order_query, course_overviews): +def get_courses_order_by( + order_query: str | None, + course_overviews: QuerySet[CourseOverview] +) -> QuerySet[CourseOverview]: """Return course overviews based on a base queryset ordered by a query. Args: @@ -737,7 +736,8 @@ def course_index(request, course_key): org, course, name: Attributes of the Location for the item to edit """ if use_new_course_outline_page(course_key): - return redirect(get_course_outline_url(course_key)) + block_to_show = request.GET.get("show") + return redirect(get_course_outline_url(course_key, block_to_show)) with modulestore().bulk_operations(course_key): # A depth of None implies the whole course. The course outline needs this in order to compute has_changes. # A unit may not have a draft version, but one of its components could, and hence the unit itself has changes. @@ -750,21 +750,17 @@ def course_index(request, course_key): @function_trace('get_courses_accessible_to_user') -def get_courses_accessible_to_user(request, org=None): +def get_courses_accessible_to_user(request): """ Try to get all courses by first reversing django groups and fallback to old method if it fails Note: overhead of pymongo reads will increase if getting courses from django groups fails Arguments: request: the request object - org (string): for global staff users ONLY, this value will be used to limit - the courses returned. A value of None will have no effect (all courses - returned), an empty string will result in no courses, and otherwise only courses with the - specified org will be returned. The default value is None. """ if GlobalStaff().has_user(request.user): # user has global access so no need to get courses from django groups - courses, in_process_course_actions = _accessible_courses_summary_iter(request, org) + courses, in_process_course_actions = _accessible_courses_summary_iter(request) else: try: courses, in_process_course_actions = _accessible_courses_list_from_groups(request) @@ -1718,6 +1714,89 @@ def group_configurations_detail_handler(request, course_key_string, group_config ) +@extend_schema( + summary="Bulk enable/disable discussions for all units in a course.", + description="Enable or disable discussions for all verticals in the specified course.", + request=OpenApiRequest( + request={ + "type": "object", + "properties": {"discussion_enabled": {"type": "boolean"}}, + "required": ["discussion_enabled"], + } + ), + responses={ + 200: OpenApiResponse( + response={ + "type": "object", + "properties": {"units_updated_and_republished": {"type": "integer"}}, + } + ), + 400: OpenApiResponse(description="Bad request"), + 403: OpenApiResponse(description="Permission denied"), + }, + methods=["PUT"], + parameters=[ + OpenApiParameter( + name="course_key_string", + description="Course key string", + required=True, + type=str, + location=OpenApiParameter.PATH, + ) + ], +) +@api_view(['PUT']) +@view_auth_classes() +@expect_json +def bulk_enable_disable_discussions(request, course_key_string): + """ + API endpoint to enable/disable discussions for all verticals in the course and republish them. + + PUT + json: enable/disable discussions for all units and republish + """ + try: + # Validate the course key + course_key = CourseKey.from_string(course_key_string) + except InvalidKeyError: + return JsonResponseBadRequest({"error": "Invalid course key format"}) + + user = request.user + + # check that logged in user has permissions to update this course + if not has_studio_write_access(user, course_key): + raise PermissionDenied() + + if 'discussion_enabled' not in request.json: + return JsonResponseBadRequest({"error": "Missing 'discussion_enabled' field in request body"}) + discussion_enabled = request.json['discussion_enabled'] + log.info( + "User %s is attempting to %s discussions for all verticals in course %s", + user.username, + "enable" if discussion_enabled else "disable", + course_key + ) + + if request.method == 'PUT': + try: + store = modulestore() + changed = 0 + with store.bulk_operations(course_key): + verticals = store.get_items(course_key, qualifiers={'block_type': 'vertical'}) + for vertical in verticals: + if vertical.discussion_enabled != discussion_enabled: + vertical.discussion_enabled = discussion_enabled + store.update_item(vertical, user.id) + + if store.has_published_version(vertical): + store.publish(vertical.location, user.id) + changed += 1 + return JsonResponse({"units_updated_and_republished": changed}) + except Exception as e: # lint-amnesty, pylint: disable=broad-except + log.exception("Exception occurred while enabling/disabling discussion: %s", str(e)) + return JsonResponseBadRequest({"error": str(e)}) + + def are_content_experiments_enabled(course): """ Returns True if content experiments have been enabled for the course. diff --git a/cms/djangoapps/contentstore/views/permissions.py b/cms/djangoapps/contentstore/views/permissions.py new file mode 100644 index 0000000000..6d63c7a124 --- /dev/null +++ b/cms/djangoapps/contentstore/views/permissions.py @@ -0,0 +1,22 @@ +""" +Custom permissions for the content store views. +""" + +from rest_framework.permissions import BasePermission + +from common.djangoapps.student.auth import has_studio_write_access +from openedx.core.lib.api.view_utils import validate_course_key + + +class HasStudioWriteAccess(BasePermission): + """ + Check if the user has write access to studio. + """ + + def has_permission(self, request, view): + """ + Check if the user has write access to studio. + """ + course_key_string = view.kwargs.get("course_key_string") + course_key = validate_course_key(course_key_string) + return has_studio_write_access(request.user, course_key) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index aa7421bb87..c98e3d7641 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -11,6 +11,7 @@ from django.urls import reverse from django.utils.translation import gettext as _ from django.views.decorators.clickjacking import xframe_options_exempt from opaque_keys.edx.keys import UsageKey +from opaque_keys.edx.locator import LibraryContainerLocator from rest_framework.request import Request from web_fragments.fragment import Fragment from xblock.django.request import django_to_webob_request, webob_to_django_response @@ -29,6 +30,7 @@ from xmodule.x_module import AUTHOR_VIEW, PREVIEW_VIEWS, STUDENT_VIEW, XModuleMi from cms.djangoapps.xblock_config.models import StudioConfig from cms.djangoapps.contentstore.toggles import individualize_anonymous_user_id from cms.lib.xblock.field_data import CmsFieldData +from cms.lib.xblock.upstream_sync import UpstreamLink from common.djangoapps.static_replace.services import ReplaceURLService from common.djangoapps.static_replace.wrapper import replace_urls_wrapper from common.djangoapps.student.models import anonymous_id_for_user @@ -299,8 +301,17 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): if selected_groups_label: selected_groups_label = _('Access restricted to: {list_of_groups}').format(list_of_groups=selected_groups_label) # lint-amnesty, pylint: disable=line-too-long course = modulestore().get_course(xblock.location.course_key) + can_edit = context.get('can_edit', True) can_add = context.get('can_add', True) + upstream_link = UpstreamLink.try_get_for_block(root_xblock, log_error=False) + if upstream_link.error_message is None and isinstance(upstream_link.upstream_key, LibraryContainerLocator): + # If this unit is linked to a library unit, for now we make it completely read-only + # because when it is synced, all local changes like added components will be lost. + # (This is only on the frontend; the backend doesn't enforce it) + can_edit = False + can_add = False + # Is this a course or a library? is_course = xblock.context_key.is_course tags_count_map = context.get('tags_count_map') @@ -315,7 +326,7 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False): 'is_root': is_root, 'is_reorderable': is_reorderable, 'can_edit': can_edit, - 'can_edit_visibility': context.get('can_edit_visibility', is_course), + 'can_edit_visibility': can_edit and context.get('can_edit_visibility', is_course), 'course_authoring_url': settings.COURSE_AUTHORING_MICROFRONTEND_URL, 'is_loading': context.get('is_loading', False), 'is_selected': context.get('is_selected', False), diff --git a/cms/djangoapps/contentstore/views/public.py b/cms/djangoapps/contentstore/views/public.py index 71f5ee5512..0684d52022 100644 --- a/cms/djangoapps/contentstore/views/public.py +++ b/cms/djangoapps/contentstore/views/public.py @@ -11,6 +11,7 @@ from django.shortcuts import redirect from common.djangoapps.edxmako.shortcuts import render_to_response from ..config.waffle import ENABLE_ACCESSIBILITY_POLICY_PAGE +from ..toggles import use_legacy_logged_out_home __all__ = [ 'register_redirect_to_lms', 'login_redirect_to_lms', 'howitworks', 'accessibility', @@ -62,11 +63,12 @@ def _build_next_param(request): def howitworks(request): - "Proxy view" - if request.user.is_authenticated: - return redirect('/home/') - else: + """ + Deprecated logged-out home page. New behavior is just login w/ redirect to studio course list. + """ + if use_legacy_logged_out_home() and not request.user.is_authenticated: return render_to_response('howitworks.html', {}) + return redirect('/home/') def accessibility(request): diff --git a/cms/djangoapps/contentstore/views/serializers.py b/cms/djangoapps/contentstore/views/serializers.py new file mode 100644 index 0000000000..c96e716410 --- /dev/null +++ b/cms/djangoapps/contentstore/views/serializers.py @@ -0,0 +1,16 @@ +""" +Serializers for the contentstore.views module. + +This module contains DRF serializers for various features such as certificates, blocks, and others. +Add new serializers here as needed for API endpoints in this module. +""" + +from rest_framework import serializers + + +class CertificateActivationSerializer(serializers.Serializer): + """ + Serializer for activating or deactivating course certificates. + """ + # This field indicates whether the certificate should be activated or deactivated. + is_active = serializers.BooleanField(required=False, default=False) diff --git a/cms/djangoapps/contentstore/views/tests/test_assets.py b/cms/djangoapps/contentstore/views/tests/test_assets.py index a6dafab55c..2b13338c0d 100644 --- a/cms/djangoapps/contentstore/views/tests/test_assets.py +++ b/cms/djangoapps/contentstore/views/tests/test_assets.py @@ -12,11 +12,13 @@ from unittest.mock import patch from ddt import data, ddt from django.conf import settings from django.test.utils import override_settings +from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.keys import AssetKey from opaque_keys.edx.locator import CourseLocator from PIL import Image from pytz import UTC +from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import reverse_course_url from cms.djangoapps.contentstore.views import assets @@ -84,6 +86,8 @@ class BasicAssetsTestCase(AssetsTestCase): """ Test getting assets via html w/o additional args """ + + @override_waffle_flag(toggles.LEGACY_STUDIO_FILES_UPLOADS, True) def test_basic(self): resp = self.client.get(self.url, HTTP_ACCEPT='text/html') self.assertEqual(resp.status_code, 200) diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index a6fefe5f55..5447e6e596 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -12,6 +12,7 @@ from django.http import Http404 from django.test import TestCase from django.test.client import RequestFactory from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_flag from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE from openedx_events.content_authoring.data import DuplicatedXBlockData from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED @@ -23,6 +24,7 @@ from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator from pyquery import PyQuery from pytz import UTC +from bs4 import BeautifulSoup from web_fragments.fragment import Fragment from webob import Response from xblock.core import XBlockAside @@ -55,6 +57,7 @@ from xmodule.partitions.partitions import ( from xmodule.partitions.tests.test_partitions import MockPartitionService from xmodule.x_module import STUDENT_VIEW, STUDIO_VIEW +from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import ( reverse_course_url, @@ -74,7 +77,7 @@ from lms.djangoapps.lms_xblock.mixin import NONSENSICAL_ACCESS_RESTRICTION from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration from openedx.core.djangoapps.content_tagging import api as tagging_api -from ..component import component_handler, get_component_templates +from ..component import component_handler, DEFAULT_ADVANCED_MODULES, get_component_templates from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import ( ALWAYS, VisibilityState, @@ -265,9 +268,10 @@ class GetItemTest(ItemTest): html, # The instance of the wrapper class will have an auto-generated ID. Allow any # characters after wrapper. - '"/container/{}" class="action-button">\\s*View'.format( - re.escape(str(wrapper_usage_key)) - ), + ( + '"/container/{}" class="action-button xblock-view-action-button">' + '\\s*View' + ).format(re.escape(str(wrapper_usage_key))), ) @patch("cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers.get_object_tag_counts") @@ -312,12 +316,12 @@ class GetItemTest(ItemTest): resp = self.create_xblock( parent_usage_key=split_test_usage_key, category="html", - boilerplate="zooming_image.yaml", + boilerplate="latex_html.yaml", ) self.assertEqual(resp.status_code, 200) html, __ = self._get_container_preview(split_test_usage_key) self.assertIn("Announcement", html) - self.assertIn("Zooming", html) + self.assertIn("LaTeX", html) def test_split_test_edited(self): """ @@ -558,9 +562,6 @@ class GetItemTest(ItemTest): else: self.assertNotIn("ancestors", response) xblock_info = get_block_info(xblock) - # TODO: remove after beta testing for the new problem editor parser - if xblock_info["category"] == "problem": - xblock_info["metadata"]["default_to_advanced"] = False self.assertEqual(xblock_info, response) @@ -803,6 +804,12 @@ class TestDuplicateItem(ItemTest, DuplicateHelper, OpenEdxEventsTestMixin): super().setUpClass() cls.start_events_isolation() + @classmethod + def tearDownClass(cls): + """ Don't let our event isolation affect other test cases """ + super().tearDownClass() + cls.enable_all_events() # Re-enable events other than the ENABLED_OPENEDX_EVENTS subset we isolated. + def setUp(self): """Creates the test course structure and a few components to 'duplicate'.""" super().setUp() @@ -2853,6 +2860,7 @@ class TestComponentHandler(TestCase): assert mocked_get_aside_from_xblock.called is is_get_aside_called +@override_waffle_flag(toggles.LEGACY_STUDIO_PROBLEM_EDITOR, True) class TestComponentTemplates(CourseTestCase): """ Unit tests for the generation of the component templates for a course. @@ -2901,6 +2909,16 @@ class TestComponentTemplates(CourseTestCase): self.templates = get_component_templates(self.course) + self.default_advanced_modules_titles = sorted([ + "Google Calendar", + "Google Document", + "LTI Consumer", + "Poll", + "Content Experiment", + "Survey", + "Word cloud", + ]) + def get_templates_of_type(self, template_type): """ Returns the templates for the specified type, or None if none is found. @@ -2954,7 +2972,11 @@ class TestComponentTemplates(CourseTestCase): self.assertGreater(len(self.get_templates_of_type("library")), 0) self.assertGreater(len(self.get_templates_of_type("html")), 0) self.assertGreater(len(self.get_templates_of_type("problem")), 0) - self.assertIsNone(self.get_templates_of_type("advanced")) + + # Check for default advanced modules + advanced_templates = self.get_templates_of_type("advanced") + advanced_module_keys = [t['category'] for t in advanced_templates] + self.assertCountEqual(advanced_module_keys, DEFAULT_ADVANCED_MODULES) # Now fully disable video through XBlockConfiguration XBlockConfiguration.objects.create(name="video", enabled=False) @@ -3002,29 +3024,38 @@ class TestComponentTemplates(CourseTestCase): """ Test the handling of advanced component templates. """ - self.course.advanced_modules.append("word_cloud") + self.course.advanced_modules.append("done") + EXPECTED_ADVANCED_MODULES_LENGTH = len(DEFAULT_ADVANCED_MODULES) + 1 self.templates = get_component_templates(self.course) advanced_templates = self.get_templates_of_type("advanced") - self.assertEqual(len(advanced_templates), 1) - world_cloud_template = advanced_templates[0] - self.assertEqual(world_cloud_template.get("category"), "word_cloud") - self.assertEqual(world_cloud_template.get("display_name"), "Word cloud") - self.assertIsNone(world_cloud_template.get("boilerplate_name", None)) + self.assertEqual(len(advanced_templates), EXPECTED_ADVANCED_MODULES_LENGTH) + done_template = advanced_templates[0] + self.assertEqual(done_template.get("category"), "done") + self.assertEqual(done_template.get("display_name"), "Completion") + self.assertIsNone(done_template.get("boilerplate_name", None)) - # Verify that non-advanced components are not added twice + # Verify that components are not added twice self.course.advanced_modules.append("video") self.course.advanced_modules.append("drag-and-drop-v2") + # Already defined advanced modules + self.course.advanced_modules.append("poll") + self.course.advanced_modules.append("google-document") + self.course.advanced_modules.append("survey") + self.templates = get_component_templates(self.course) advanced_templates = self.get_templates_of_type("advanced") - self.assertEqual(len(advanced_templates), 1) + self.assertEqual(len(advanced_templates), EXPECTED_ADVANCED_MODULES_LENGTH) only_template = advanced_templates[0] self.assertNotEqual(only_template.get("category"), "video") self.assertNotEqual(only_template.get("category"), "drag-and-drop-v2") + self.assertNotEqual(only_template.get("category"), "poll") + self.assertNotEqual(only_template.get("category"), "google-document") + self.assertNotEqual(only_template.get("category"), "survey") - # Now fully disable word_cloud through XBlockConfiguration - XBlockConfiguration.objects.create(name="word_cloud", enabled=False) + # Now fully disable done through XBlockConfiguration + XBlockConfiguration.objects.create(name="done", enabled=False) self.templates = get_component_templates(self.course) - self.assertIsNone(self.get_templates_of_type("advanced")) + self.assertTrue((not any(item.get("category") == "done" for item in self.get_templates_of_type("advanced")))) def test_advanced_problems(self): """ @@ -3085,8 +3116,9 @@ class TestComponentTemplates(CourseTestCase): XBlockConfiguration) if XBlockStudioConfigurationFlag is False. """ XBlockStudioConfigurationFlag.objects.create(enabled=False) - self.course.advanced_modules.extend(["annotatable", "survey"]) - self._verify_advanced_xblocks(["Annotation", "Survey"], [True, True]) + self.course.advanced_modules.extend(["annotatable", "done"]) + expected_xblocks = ["Annotation", "Completion"] + self.default_advanced_modules_titles + self._verify_advanced_xblocks(expected_xblocks, [True] * len(expected_xblocks)) def test_xblock_masquerading_as_problem(self): """ @@ -4537,3 +4569,61 @@ class TestUpdateFromSource(ModuleStoreTestCase): user_id=user.id, ) self.check_updated(source_block, destination_block.location) + + +class TestXblockEditView(CourseTestCase): + """ + Test xblock_edit_view. + """ + + def setUp(self): + super().setUp() + self.chapter = self._create_block(self.course, "chapter", "Week 1") + self.sequential = self._create_block(self.chapter, "sequential", "Lesson 1") + self.vertical = self._create_block(self.sequential, "vertical", "Unit") + self.html = self._create_block(self.vertical, "html", "HTML") + self.child_container = self._create_block( + self.vertical, "split_test", "Split Test" + ) + self.child_vertical = self._create_block( + self.child_container, "vertical", "Child Vertical" + ) + self.video = self._create_block(self.child_vertical, "video", "My Video") + self.store = modulestore() + + self.store.publish(self.vertical.location, self.user.id) + + def _create_block(self, parent, category, display_name, **kwargs): + """ + creates a block in the module store, without publishing it. + """ + return BlockFactory.create( + parent=parent, + category=category, + display_name=display_name, + publish_item=False, + user_id=self.user.id, + **kwargs, + ) + + def test_xblock_edit_view(self): + url = reverse_usage_url("xblock_edit_handler", self.video.location) + resp = self.client.get_html(url) + self.assertEqual(resp.status_code, 200) + + html_content = resp.content.decode(resp.charset) + self.assertIn("var decodedActionName = 'edit';", html_content) + + def test_xblock_edit_view_contains_resources(self): + url = reverse_usage_url("xblock_edit_handler", self.video.location) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + + html_content = resp.content.decode(resp.charset) + soup = BeautifulSoup(html_content, "html.parser") + + resource_links = [link["href"] for link in soup.find_all("link", {"rel": "stylesheet"})] + script_sources = [script["src"] for script in soup.find_all("script") if script.get("src")] + + self.assertGreater(len(resource_links), 0, f"No CSS resources found in HTML. Found: {resource_links}") + self.assertGreater(len(script_sources), 0, f"No JS resources found in HTML. Found: {script_sources}") diff --git a/cms/djangoapps/contentstore/views/tests/test_certificates.py b/cms/djangoapps/contentstore/views/tests/test_certificates.py index 7af7a448de..f50c3d3f1b 100644 --- a/cms/djangoapps/contentstore/views/tests/test_certificates.py +++ b/cms/djangoapps/contentstore/views/tests/test_certificates.py @@ -10,8 +10,10 @@ from unittest import mock import ddt from django.conf import settings from django.test.utils import override_settings +from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.keys import AssetKey +from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import get_lms_link_for_certificate_web_view, reverse_course_url from common.djangoapps.course_modes.tests.factories import CourseModeFactory @@ -275,6 +277,7 @@ class CertificatesListHandlerTestCase( ) self.assertEqual(link, test_url) + @override_waffle_flag(toggles.LEGACY_STUDIO_CERTIFICATES, True) @mock.patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': True}) def test_certificate_info_in_response(self): """ @@ -302,6 +305,7 @@ class CertificatesListHandlerTestCase( self.assertEqual(data[0]['version'], CERTIFICATE_SCHEMA_VERSION) @mock.patch.dict('django.conf.settings.FEATURES', {'CERTIFICATES_HTML_VIEW': True}) + @override_waffle_flag(toggles.LEGACY_STUDIO_CERTIFICATES, True) def test_certificate_info_not_in_response(self): """ Test that certificate has not been rendered audit only course mode. @@ -346,6 +350,7 @@ class CertificatesListHandlerTestCase( ) self.assertContains(response, "error", status_code=403) + @override_waffle_flag(toggles.LEGACY_STUDIO_CERTIFICATES, True) def test_audit_course_mode_is_skipped(self): """ Tests audit course mode is skipped when rendering certificates page. @@ -359,6 +364,7 @@ class CertificatesListHandlerTestCase( self.assertContains(response, 'verified') self.assertNotContains(response, 'audit') + @override_waffle_flag(toggles.LEGACY_STUDIO_CERTIFICATES, True) def test_audit_only_disables_cert(self): """ Tests audit course mode is skipped when rendering certificates page. @@ -379,6 +385,7 @@ class CertificatesListHandlerTestCase( ['verified', 'credit'], ['professional'] ) + @override_waffle_flag(toggles.LEGACY_STUDIO_CERTIFICATES, True) def test_non_audit_enables_cert(self, slugs): """ Tests audit course mode is skipped when rendering certificates page. diff --git a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py index 9244ffa989..3fae9d996f 100644 --- a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py +++ b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py @@ -6,6 +6,13 @@ APIs. import ddt from opaque_keys.edx.keys import UsageKey from rest_framework.test import APIClient +from openedx_events.content_authoring.signals import ( + LIBRARY_BLOCK_DELETED, + XBLOCK_CREATED, + XBLOCK_DELETED, + XBLOCK_UPDATED, +) +from openedx_events.tests.utils import OpenEdxEventsTestMixin from openedx_tagging.core.tagging.models import Tag from organizations.models import Organization from xmodule.modulestore.django import contentstore, modulestore @@ -393,10 +400,16 @@ class ClipboardPasteTestCase(ModuleStoreTestCase): assert source_pic2_hash != dest_pic2_hash # Because there was a conflict, this file was unchanged. -class ClipboardPasteFromV2LibraryTestCase(ModuleStoreTestCase): +class ClipboardPasteFromV2LibraryTestCase(OpenEdxEventsTestMixin, ModuleStoreTestCase): """ Test Clipboard Paste functionality with a "new" (as of Sumac) library """ + ENABLED_OPENEDX_EVENTS = [ + LIBRARY_BLOCK_DELETED.event_type, + XBLOCK_CREATED.event_type, + XBLOCK_DELETED.event_type, + XBLOCK_UPDATED.event_type, + ] def setUp(self): """ @@ -477,6 +490,16 @@ class ClipboardPasteFromV2LibraryTestCase(ModuleStoreTestCase): assert object_tag.value in self.lib_block_tags assert object_tag.is_copied + # If we delete the upstream library block... + library_api.delete_library_block(self.lib_block_key) + + # ...the copied tags remain, but should no longer be marked as "copied" + object_tags = tagging_api.get_object_tags(new_block_key) + assert len(object_tags) == len(self.lib_block_tags) + for object_tag in object_tags: + assert object_tag.value in self.lib_block_tags + assert not object_tag.is_copied + def test_paste_from_library_copies_asset(self): """ Assets from a library component copied into a subdir of Files & Uploads. @@ -555,7 +578,6 @@ class ClipboardPasteFromV2LibraryTestCase(ModuleStoreTestCase): 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/djangoapps/contentstore/views/tests/test_container_page.py b/cms/djangoapps/contentstore/views/tests/test_container_page.py index 426477e234..e6b58257b6 100644 --- a/cms/djangoapps/contentstore/views/tests/test_container_page.py +++ b/cms/djangoapps/contentstore/views/tests/test_container_page.py @@ -10,10 +10,12 @@ from unittest.mock import Mock, patch from django.http import Http404 from django.test.client import RequestFactory from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_flag from pytz import UTC from urllib.parse import quote import cms.djangoapps.contentstore.views.component as views +from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.test_libraries import LibraryTestCase from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order @@ -83,6 +85,7 @@ class ContainerPageTestCase(StudioPageTestCase, LibraryTestCase): ), ) + @override_waffle_flag(toggles.LEGACY_STUDIO_UNIT_EDITOR, True) def test_container_on_container_html(self): """ Create the scenario of an xblock with children (non-vertical) on the container page. @@ -221,6 +224,7 @@ class ContainerPageTestCase(StudioPageTestCase, LibraryTestCase): 'cms.djangoapps.contentstore.views.component.render_to_response', Mock(return_value=Mock(status_code=200, content='')) ) + @override_waffle_flag(toggles.LEGACY_STUDIO_UNIT_EDITOR, True) def test_container_page_with_valid_and_invalid_usage_key_string(self): """ Check that invalid 'usage_key_string' raises Http404. @@ -263,6 +267,7 @@ class ContainerEmbedPageTestCase(ContainerPageTestCase): # lint-amnesty, pylint ), ) + @override_waffle_flag(toggles.LEGACY_STUDIO_UNIT_EDITOR, True) def test_container_on_container_html(self): """ Create the scenario of an xblock with children (non-vertical) on the container page. diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py index c3dcfe5305..a22ce637fe 100644 --- a/cms/djangoapps/contentstore/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py @@ -14,9 +14,11 @@ from django.conf import settings from django.core.exceptions import PermissionDenied from django.test.utils import override_settings from django.utils.translation import gettext as _ +from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.locator import CourseLocator from search.api import perform_search +from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.courseware_index import CoursewareSearchIndexer, SearchIndexingError from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import ( @@ -40,12 +42,9 @@ from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, Lib from ..course import _deprecated_blocks_info, course_outline_initial_state, reindex_course_and_check_access from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import VisibilityState, create_xblock_info -FEATURES_WITH_HOME_PAGE_COURSE_V2_API = settings.FEATURES.copy() -FEATURES_WITH_HOME_PAGE_COURSE_V2_API['ENABLE_HOME_PAGE_COURSE_API_V2'] = True -FEATURES_WITHOUT_HOME_PAGE_COURSE_V2_API = settings.FEATURES.copy() -FEATURES_WITHOUT_HOME_PAGE_COURSE_V2_API['ENABLE_HOME_PAGE_COURSE_API_V2'] = False - +@override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True) +@override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True) class TestCourseIndex(CourseTestCase): """ Unit tests for getting the list of courses and the course outline. @@ -340,6 +339,7 @@ class TestCourseIndex(CourseTestCase): self.assertContains(response, 'display_course_number: ""') +@override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True) @ddt.ddt class TestCourseIndexArchived(CourseTestCase): """ @@ -430,19 +430,18 @@ class TestCourseIndexArchived(CourseTestCase): archived_course_tab = parsed_html.find_class('archived-courses') self.assertEqual(len(archived_course_tab), 1 if separate_archived_courses else 0) - @override_settings(FEATURES=FEATURES_WITHOUT_HOME_PAGE_COURSE_V2_API) @ddt.data( # Staff user has course staff access - (True, 'staff', None, 0, 21), - (False, 'staff', None, 0, 21), + (True, 'staff', None, 23), + (False, 'staff', None, 23), # Base user has global staff access - (True, 'user', ORG, 2, 21), - (False, 'user', ORG, 2, 21), - (True, 'user', None, 2, 21), - (False, 'user', None, 2, 21), + (True, 'user', ORG, 23), + (False, 'user', ORG, 23), + (True, 'user', None, 23), + (False, 'user', None, 23), ) @ddt.unpack - def test_separate_archived_courses(self, separate_archived_courses, username, org, mongo_queries, sql_queries): + def test_separate_archived_courses(self, separate_archived_courses, username, org, sql_queries): """ Ensure that archived courses are shown as expected for all user types, when the feature is enabled/disabled. Also ensure that enabling the feature does not adversely affect the database query count. @@ -458,19 +457,18 @@ class TestCourseIndexArchived(CourseTestCase): with override_settings(FEATURES=features): self.check_index_page_with_query_count(separate_archived_courses=separate_archived_courses, org=org, - mongo_queries=mongo_queries, + mongo_queries=0, sql_queries=sql_queries) - @override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API) @ddt.data( # Staff user has course staff access - (True, 'staff', None, 0, 21), - (False, 'staff', None, 0, 21), + (True, 'staff', None, 23), + (False, 'staff', None, 23), # Base user has global staff access - (True, 'user', ORG, 0, 21), - (False, 'user', ORG, 0, 21), - (True, 'user', None, 0, 21), - (False, 'user', None, 0, 21), + (True, 'user', ORG, 23), + (False, 'user', ORG, 23), + (True, 'user', None, 23), + (False, 'user', None, 23), ) @ddt.unpack def test_separate_archived_courses_with_home_page_course_v2_api( @@ -478,7 +476,6 @@ class TestCourseIndexArchived(CourseTestCase): separate_archived_courses, username, org, - mongo_queries, sql_queries ): """ @@ -496,10 +493,11 @@ class TestCourseIndexArchived(CourseTestCase): with override_settings(FEATURES=features): self.check_index_page_with_query_count(separate_archived_courses=separate_archived_courses, org=org, - mongo_queries=mongo_queries, + mongo_queries=0, sql_queries=sql_queries) +@override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True) @ddt.ddt class TestCourseOutline(CourseTestCase): """ @@ -717,11 +715,12 @@ class TestCourseOutline(CourseTestCase): """ Test to check number of queries made to mysql and mongo """ - with self.assertNumQueries(29, table_ignorelist=WAFFLE_TABLES): + with self.assertNumQueries(39, table_ignorelist=WAFFLE_TABLES): with check_mongo_calls(3): self.client.get_html(reverse_course_url('course_handler', self.course.id)) +@override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True) class TestCourseReIndex(CourseTestCase): """ Unit tests for the course outline. diff --git a/cms/djangoapps/contentstore/views/tests/test_course_updates.py b/cms/djangoapps/contentstore/views/tests/test_course_updates.py index be8e8cac82..7b3c31abe1 100644 --- a/cms/djangoapps/contentstore/views/tests/test_course_updates.py +++ b/cms/djangoapps/contentstore/views/tests/test_course_updates.py @@ -5,7 +5,9 @@ unit tests for course_info views and models. import json from opaque_keys.edx.keys import UsageKey +from edx_toggles.toggles.testutils import override_waffle_flag +from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.test_course_settings import CourseTestCase from cms.djangoapps.contentstore.utils import reverse_course_url, reverse_usage_url from openedx.core.lib.xblock_utils import get_course_update_items @@ -21,6 +23,7 @@ class CourseUpdateTest(CourseTestCase): # lint-amnesty, pylint: disable=missing return reverse_course_url('course_info_update_handler', course_key, kwargs=kwargs) # The do all and end all of unit test cases. + @override_waffle_flag(toggles.LEGACY_STUDIO_UPDATES, True) def test_course_update(self): """Go through each interface and ensure it works.""" def get_response(content, date): diff --git a/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py b/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py index 5cfa2ded2b..66e42598be 100644 --- a/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py +++ b/cms/djangoapps/contentstore/views/tests/test_credit_eligibility.py @@ -4,7 +4,9 @@ Unit tests for credit eligibility UI in Studio. from unittest import mock +from edx_toggles.toggles.testutils import override_waffle_flag +from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import reverse_course_url from openedx.core.djangoapps.credit.api import get_credit_requirements @@ -24,6 +26,7 @@ class CreditEligibilityTest(CourseTestCase): self.course_details_url = reverse_course_url('settings_handler', str(self.course.id)) @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_CREDIT_ELIGIBILITY': False}) + @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) def test_course_details_with_disabled_setting(self): """ Test that user don't see credit eligibility requirements in response @@ -35,6 +38,7 @@ class CreditEligibilityTest(CourseTestCase): self.assertNotContains(response, "Steps required to earn course credit") @mock.patch.dict("django.conf.settings.FEATURES", {'ENABLE_CREDIT_ELIGIBILITY': True}) + @override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) def test_course_details_with_enabled_setting(self): """ Test that credit eligibility requirements are present in diff --git a/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py b/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py index 0f38722e12..9bad2c77fc 100644 --- a/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py +++ b/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py @@ -8,24 +8,28 @@ import ddt import lxml from django.conf import settings from django.test.utils import override_settings +from edx_toggles.toggles.testutils import override_waffle_flag +from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import get_proctored_exam_settings_url, reverse_course_url from common.djangoapps.util.testing import UrlResetMixin -FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() -FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True - -FEATURES_WITH_EXAM_SETTINGS_ENABLED = FEATURES_WITH_CERTS_ENABLED.copy() -FEATURES_WITH_EXAM_SETTINGS_ENABLED['ENABLE_EXAM_SETTINGS_HTML_VIEW'] = True -FEATURES_WITH_EXAM_SETTINGS_ENABLED['ENABLE_PROCTORED_EXAMS'] = True - -FEATURES_WITH_EXAM_SETTINGS_DISABLED = FEATURES_WITH_CERTS_ENABLED.copy() -FEATURES_WITH_EXAM_SETTINGS_DISABLED['ENABLE_EXAM_SETTINGS_HTML_VIEW'] = False -FEATURES_WITH_EXAM_SETTINGS_DISABLED['ENABLE_PROCTORED_EXAMS'] = True - @ddt.ddt +@override_settings( + FEATURES={ + **settings.FEATURES, + "CERTIFICATES_HTML_VIEW": True, + "ENABLE_PROCTORED_EXAMS": True, + }, +) +@override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True) +@override_waffle_flag(toggles.LEGACY_STUDIO_CERTIFICATES, True) +@override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True) +@override_waffle_flag(toggles.LEGACY_STUDIO_CONFIGURATIONS, True) +@override_waffle_flag(toggles.LEGACY_STUDIO_GRADING, True) +@override_waffle_flag(toggles.LEGACY_STUDIO_ADVANCED_SETTINGS, True) class TestExamSettingsView(CourseTestCase, UrlResetMixin): """ Unit tests for the exam settings view. @@ -46,7 +50,7 @@ class TestExamSettingsView(CourseTestCase, UrlResetMixin): alert_node = alert_nodes[0] return alert_node.text_content() - @override_settings(FEATURES=FEATURES_WITH_EXAM_SETTINGS_DISABLED) + @override_waffle_flag(toggles.LEGACY_STUDIO_EXAM_SETTINGS, True) @ddt.data( "certificates_list_handler", "settings_handler", @@ -64,7 +68,6 @@ class TestExamSettingsView(CourseTestCase, UrlResetMixin): self.assertEqual(resp.status_code, 200) self.assertNotContains(resp, 'Proctored Exam Settings') - @override_settings(FEATURES=FEATURES_WITH_EXAM_SETTINGS_ENABLED) @ddt.data( "certificates_list_handler", "settings_handler", @@ -87,7 +90,6 @@ class TestExamSettingsView(CourseTestCase, UrlResetMixin): 'DEFAULT': 'test_proctoring_provider', 'proctortrack': {} }, - FEATURES=FEATURES_WITH_EXAM_SETTINGS_ENABLED, ) @ddt.data( "advanced_settings_handler", @@ -125,12 +127,12 @@ class TestExamSettingsView(CourseTestCase, UrlResetMixin): 'DEFAULT': 'test_proctoring_provider', 'proctortrack': {} }, - FEATURES=FEATURES_WITH_EXAM_SETTINGS_DISABLED, ) @ddt.data( "advanced_settings_handler", "course_handler", ) + @override_waffle_flag(toggles.LEGACY_STUDIO_EXAM_SETTINGS, True) def test_exam_settings_alert_with_exam_settings_disabled(self, page_handler): """ An alert should appear if current exam settings are invalid. @@ -168,7 +170,6 @@ class TestExamSettingsView(CourseTestCase, UrlResetMixin): 'proctortrack': {}, 'test_proctoring_provider': {}, }, - FEATURES=FEATURES_WITH_EXAM_SETTINGS_ENABLED, ) @ddt.data( "advanced_settings_handler", @@ -212,7 +213,6 @@ class TestExamSettingsView(CourseTestCase, UrlResetMixin): alert_nodes = parsed_html.find_class('exam-settings-alert') assert len(alert_nodes) == 0 - @override_settings(FEATURES={'ENABLE_EXAM_SETTINGS_HTML_VIEW': True}) @patch('cms.djangoapps.models.settings.course_metadata.CourseMetadata.validate_proctoring_settings') def test_proctoring_link_is_visible(self, mock_validate_proctoring_settings): diff --git a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py index 0c3c7dc6aa..6e9192aff5 100644 --- a/cms/djangoapps/contentstore/views/tests/test_group_configurations.py +++ b/cms/djangoapps/contentstore/views/tests/test_group_configurations.py @@ -6,9 +6,11 @@ Group Configuration Tests. import json from operator import itemgetter from unittest.mock import patch +from edx_toggles.toggles.testutils import override_waffle_flag import ddt +from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.course_group_config import ( CONTENT_GROUP_CONFIGURATION_NAME, ENROLLMENT_SCHEME, @@ -256,6 +258,7 @@ class GroupConfigurationsListHandlerTestCase(CourseTestCase, GroupConfigurations """ return reverse_course_url('group_configurations_list_handler', self.course.id) + @override_waffle_flag(toggles.LEGACY_STUDIO_CONFIGURATIONS, True) def test_view_index_ok(self): """ Basic check that the groups configuration page responds correctly. diff --git a/cms/djangoapps/contentstore/views/tests/test_header_menu.py b/cms/djangoapps/contentstore/views/tests/test_header_menu.py index 454fd7e257..fb961cc4fa 100644 --- a/cms/djangoapps/contentstore/views/tests/test_header_menu.py +++ b/cms/djangoapps/contentstore/views/tests/test_header_menu.py @@ -5,7 +5,9 @@ from unittest import SkipTest from django.conf import settings from django.test.utils import override_settings +from edx_toggles.toggles.testutils import override_waffle_flag +from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore.tests.utils import CourseTestCase from cms.djangoapps.contentstore.utils import reverse_course_url from common.djangoapps.util.testing import UrlResetMixin @@ -21,6 +23,7 @@ FEATURES_WITH_EXAM_SETTINGS_DISABLED['ENABLE_EXAM_SETTINGS_HTML_VIEW'] = False @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) +@override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True) class TestHeaderMenu(CourseTestCase, UrlResetMixin): """ Unit tests for the course header menu. @@ -61,6 +64,7 @@ class TestHeaderMenu(CourseTestCase, UrlResetMixin): self.assertContains(resp, ' % endif @@ -211,12 +211,6 @@ upstream_info = UpstreamLink.try_get_for_block(xblock) % endif - % elif not show_inline: -
  • - - ${_("Details")} - -
  • % endif % endif @@ -229,7 +223,7 @@ upstream_info = UpstreamLink.try_get_for_block(xblock)
    ${_('This block contains multiple components.')}
    diff --git a/cms/templates/widgets/sock_links.html b/cms/templates/widgets/sock_links.html index f5e752ede4..412b6d2df8 100644 --- a/cms/templates/widgets/sock_links.html +++ b/cms/templates/widgets/sock_links.html @@ -11,11 +11,11 @@ from django.utils.translation import gettext as _ links = [ { 'href': 'http://docs.edx.org', - 'sr_mouseover_text': _('Access documentation on http://docs.edx.org'), + 'sr_mouseover_text': _('Access edx.org documentation on http://docs.edx.org'), 'text': _('edX Documentation'), 'condition': True }, { - 'href': 'https://open.edx.org', + 'href': 'https://openedx.org', 'sr_mouseover_text': _('Access the Open edX Portal'), 'text': _('Open edX Portal'), 'condition': True diff --git a/cms/urls.py b/cms/urls.py index d721894458..af10c6b619 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -18,6 +18,7 @@ import openedx.core.djangoapps.debug.views import openedx.core.djangoapps.lang_pref.views from cms.djangoapps.contentstore import toggles from cms.djangoapps.contentstore import views as contentstore_views +from cms.djangoapps.contentstore.views.block import xblock_edit_view from cms.djangoapps.contentstore.views.organization import OrganizationListView from openedx.core.apidocs import api_info from openedx.core.djangoapps.password_policy import compliance as password_policy_compliance @@ -150,6 +151,8 @@ urlpatterns = oauth2_urlpatterns + [ name='xblock_outline_handler'), re_path(fr'^xblock/container/{settings.USAGE_KEY_PATTERN}$', contentstore_views.xblock_container_handler, name='xblock_container_handler'), + re_path(fr'^xblock/{settings.USAGE_KEY_PATTERN}/action/edit$', xblock_edit_view, + name='xblock_edit_handler'), re_path(fr'^xblock/{settings.USAGE_KEY_PATTERN}/(?P[^/]+)$', contentstore_views.xblock_view_handler, name='xblock_view_handler'), re_path(fr'^xblock/{settings.USAGE_KEY_PATTERN}?$', contentstore_views.xblock_handler, @@ -168,7 +171,7 @@ urlpatterns = oauth2_urlpatterns + [ 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'), @@ -198,6 +201,9 @@ urlpatterns = oauth2_urlpatterns + [ path('accessibility', contentstore_views.accessibility, name='accessibility'), re_path(fr'api/youtube/courses/{COURSELIKE_KEY_PATTERN}/edx-video-ids$', contentstore_views.get_course_youtube_edx_videos_ids, name='youtube_edx_video_ids'), + re_path(fr'^api/courses/{settings.COURSE_KEY_PATTERN}/bulk_enable_disable_discussions$', + contentstore_views.bulk_enable_disable_discussions, + name='bulk_enable_disable_discussions'), ] if not settings.DISABLE_DEPRECATED_SIGNIN_URL: @@ -258,7 +264,7 @@ if core_toggles.ENTRANCE_EXAMS.is_enabled(): # Enable Web/HTML Certificates if settings.FEATURES.get('CERTIFICATES_HTML_VIEW'): from cms.djangoapps.contentstore.views.certificates import ( - certificate_activation_handler, + CertificateActivationAPIView, signatory_detail_handler, certificates_detail_handler, certificates_list_handler @@ -266,7 +272,7 @@ if settings.FEATURES.get('CERTIFICATES_HTML_VIEW'): urlpatterns += [ re_path(fr'^certificates/activation/{settings.COURSE_KEY_PATTERN}/', - certificate_activation_handler, + CertificateActivationAPIView.as_view(), name='certificate_activation_handler'), re_path(r'^certificates/{}/(?P\d+)/signatories/(?P\d+)?$'.format( settings.COURSE_KEY_PATTERN), signatory_detail_handler, name='signatory_detail_handler'), diff --git a/cms/wsgi.py b/cms/wsgi.py index 0ccd953d98..9c9af42998 100644 --- a/cms/wsgi.py +++ b/cms/wsgi.py @@ -18,9 +18,6 @@ defuse_xml_libs() import os # lint-amnesty, pylint: disable=wrong-import-order, wrong-import-position os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cms.envs.aws") -import cms.startup as startup # lint-amnesty, pylint: disable=wrong-import-position -startup.run() - # This application object is used by the development server # as well as any WSGI server configured to use this file. from django.core.wsgi import get_wsgi_application # lint-amnesty, pylint: disable=wrong-import-order, wrong-import-position diff --git a/common/djangoapps/course_action_state/admin.py b/common/djangoapps/course_action_state/admin.py new file mode 100644 index 0000000000..8db98ab420 --- /dev/null +++ b/common/djangoapps/course_action_state/admin.py @@ -0,0 +1,9 @@ +""" +Admin site bindings for CourseActionState +""" + +from django.contrib import admin + +from common.djangoapps.course_action_state.models import CourseRerunState + +admin.site.register(CourseRerunState) diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index d7b0698653..b4a777d2d6 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.models import CourseEnrollment 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.django import modulestore # lint-amnesty, pylint: disa 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 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest # 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 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest 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 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest 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 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest # 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 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest 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 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest 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 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest 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 759073a135..09164dc40e 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -29,7 +29,6 @@ from common.djangoapps.course_modes.models import CourseMode 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 @@ from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disa 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 @@ class ChooseModeView(View): ) 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 @@ class ChooseModeView(View): ), "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 @@ class ChooseModeView(View): ) ) - 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 @@ class ChooseModeView(View): 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/edxmako/paths.py b/common/djangoapps/edxmako/paths.py index 8a1ab6ea75..3da9844eed 100644 --- a/common/djangoapps/edxmako/paths.py +++ b/common/djangoapps/edxmako/paths.py @@ -4,8 +4,8 @@ Set up lookup paths for mako templates. import contextlib import hashlib import os +import importlib.resources as resources -import pkg_resources from django.conf import settings from mako.exceptions import TopLevelLookupException from mako.lookup import TemplateLookup @@ -122,7 +122,7 @@ def add_lookup(namespace, directory, package=None, prepend=False): """ Adds a new mako template lookup directory to the given namespace. - If `package` is specified, `pkg_resources` is used to look up the directory + If `package` is specified, `importlib.resources` is used to look up the directory inside the given package. Otherwise `directory` is assumed to be a path in the filesystem. """ @@ -136,7 +136,9 @@ def add_lookup(namespace, directory, package=None, prepend=False): encoding_errors='replace', ) if package: - directory = pkg_resources.resource_filename(package, directory) + with resources.as_file(resources.files(package.rsplit('.', 1)[0]) / directory) as dir_path: + directory = str(dir_path) + templates.add_directory(directory, prepend=prepend) diff --git a/common/djangoapps/entitlements/management/commands/update_entitlement_mode.py b/common/djangoapps/entitlements/management/commands/update_entitlement_mode.py index eeb26a26f4..3d7b988bde 100644 --- a/common/djangoapps/entitlements/management/commands/update_entitlement_mode.py +++ b/common/djangoapps/entitlements/management/commands/update_entitlement_mode.py @@ -21,7 +21,7 @@ class Command(BaseCommand): Example usage: # Change entitlement_mode for given order_number with course_uuid to new_mode: - $ ./manage.py lms --settings=devstack_docker update_entitlement_mode \ + $ ./manage.py lms --settings=devstack update_entitlement_mode \ ORDER_NUMBER_123,ORDER_NUMBER_456 1234567-0000-1111-2222-123456789012 professional """ help = dedent(__doc__).strip() diff --git a/common/djangoapps/static_replace/__init__.py b/common/djangoapps/static_replace/__init__.py index 310ad83432..cfab4dde83 100644 --- a/common/djangoapps/static_replace/__init__.py +++ b/common/djangoapps/static_replace/__init__.py @@ -11,7 +11,7 @@ from opaque_keys.edx.locator import AssetLocator from xmodule.contentstore.content import StaticContent log = logging.getLogger(__name__) -XBLOCK_STATIC_RESOURCE_PREFIX = '/static/xblock' +XBLOCK_STATIC_RESOURCE_PREFIX = '/static/xblock/' def _url_replace_regex(prefix): diff --git a/common/djangoapps/static_replace/test/test_static_replace.py b/common/djangoapps/static_replace/test/test_static_replace.py index 1bdb770cad..c71f8fa15c 100644 --- a/common/djangoapps/static_replace/test/test_static_replace.py +++ b/common/djangoapps/static_replace/test/test_static_replace.py @@ -86,6 +86,16 @@ def test_process_url_no_match(): assert process_static_urls(STATIC_SOURCE, processor) == '"test/static/file.png"' +def test_process_url_no_match_starts_with_xblock(): + def processor(original, prefix, quote, rest): # pylint: disable=unused-argument, redefined-outer-name + return quote + 'test' + prefix + rest + quote + assert process_static_urls( + '"/static/xblock-file.png"', + processor, + data_dir=DATA_DIRECTORY + ) == '"test/static/xblock-file.png"' + + @patch('django.http.HttpRequest', autospec=True) def test_static_urls(mock_request): mock_request.build_absolute_uri = lambda url: 'http://' + url diff --git a/common/djangoapps/student/admin.py b/common/djangoapps/student/admin.py index 1c73937c15..677b74b1bf 100644 --- a/common/djangoapps/student/admin.py +++ b/common/djangoapps/student/admin.py @@ -2,6 +2,9 @@ from functools import wraps +from dal_select2.views import Select2ListView +from dal_select2.widgets import ListSelect2 +from django_countries import countries from config_models.admin import ConfigurationModelAdmin from django import forms @@ -11,12 +14,14 @@ from django.contrib.admin.sites import NotRegistered from django.contrib.admin.utils import unquote from django.contrib.auth import get_user_model from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.contrib.auth.decorators import login_required from django.contrib.auth.forms import ReadOnlyPasswordHashField from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm from django.db import models, router, transaction from django.http import HttpResponseRedirect from django.http.request import QueryDict -from django.urls import reverse +from django.urls import reverse, path +from django.utils.decorators import method_decorator from django.utils.translation import ngettext from django.utils.translation import gettext_lazy as _ from opaque_keys import InvalidKeyError @@ -45,6 +50,7 @@ from common.djangoapps.student.models import ( UserProfile, UserTestGroup ) +from common.djangoapps.student.constants import LANGUAGE_CHOICES from common.djangoapps.student.roles import REGISTERED_ACCESS_ROLES from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order @@ -309,9 +315,81 @@ class CourseEnrollmentAdmin(DisableEnrollmentAdminMixin, admin.ModelAdmin): return super().get_queryset(request).select_related('user') # lint-amnesty, pylint: disable=no-member, super-with-arguments +@method_decorator(login_required, name='dispatch') +class LanguageAutocomplete(Select2ListView): + def get_list(self): + if not self.request.user.is_staff: + return [] + return [lang for lang in LANGUAGE_CHOICES if self.q.lower() in lang.lower()] + + +@method_decorator(login_required, name='dispatch') +class CountryAutocomplete(Select2ListView): + """ + Autocomplete view for selecting countries using Select2. + + Only accessible to authenticated staff users. Filters the list of countries + based on the user input (query string) and returns matching results. + """ + + def get_list(self): + """ + Returns a filtered list of country tuples (code, name) based on the query. + """ + if not self.request.user.is_staff: + return [] + results = [] + for code, name in countries: + if self.q.lower() in name.lower(): + results.append((code, name)) + return results + + def get_result_label(self, item): + """ What the user sees in the dropdown """ + return dict(countries).get(item, item) + + def get_result_value(self, item): + """ What gets sent back on selection (the code) """ + return item + + +class UserProfileInlineForm(forms.ModelForm): + """ + A custom form for editing the UserProfile model within the admin inline. + """ + language = forms.CharField( + required=False, + widget=ListSelect2(url='admin:language-autocomplete') # pylint: disable=no-member + ) + country = forms.CharField( + required=False, + widget=ListSelect2(url='admin:country-autocomplete') # pylint: disable=no-member + ) + + class Meta: + model = UserProfile + fields = '__all__' + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if self.instance and self.instance.pk: + if self.instance.country: + code = self.instance.country + name = countries.name(code) if code in countries else code + self.fields['country'].widget.choices = [(code, name)] + self.initial['country'] = code + + if self.instance.language: + language = self.instance.language + self.fields['language'].initial = language + self.fields['language'].widget.choices = [(language, language)] + + class UserProfileInline(admin.StackedInline): """ Inline admin interface for UserProfile model. """ model = UserProfile + form = UserProfileInlineForm can_delete = False verbose_name_plural = _('User profile') @@ -359,6 +437,18 @@ class UserAdmin(BaseUserAdmin): return django_readonly + ('username',) return django_readonly + def get_urls(self): + urls = super().get_urls() + custom_urls = [ + path( + 'language-autocomplete/', + LanguageAutocomplete.as_view(), + name='language-autocomplete' + ), + path('country-autocomplete/', CountryAutocomplete.as_view(), name='country-autocomplete'), + ] + return custom_urls + urls + @admin.register(UserAttribute) class UserAttributeAdmin(admin.ModelAdmin): diff --git a/common/djangoapps/student/api.py b/common/djangoapps/student/api.py index 2bf42f4828..c5bd7b8618 100644 --- a/common/djangoapps/student/api.py +++ b/common/djangoapps/student/api.py @@ -4,6 +4,7 @@ Python APIs exposed by the student app to other in-process apps. """ +from typing import TYPE_CHECKING import logging from django.contrib.auth import get_user_model @@ -32,6 +33,10 @@ from common.djangoapps.student.roles import ( ) from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +if TYPE_CHECKING: + from django.contrib.auth.models import AnonymousUser, User # pylint: disable=imported-auth-user + from django.db.models.query import QuerySet + # This is done so that if these strings change within the app, we can keep exported constants the same ENROLLED_TO_ENROLLED = _ENROLLED_TO_ENROLLED @@ -92,13 +97,7 @@ def create_manual_enrollment_audit( else: enrollment = None - _create_manual_enrollment_audit( - enrolled_by, - user_email, - transition_state, - reason, - enrollment - ) + _create_manual_enrollment_audit(enrolled_by, user_email, transition_state, reason, enrollment) def get_access_role_by_role_name(role_name): @@ -132,7 +131,31 @@ def is_user_staff_or_instructor_in_course(user, course_key): course_key = CourseKey.from_string(course_key) return ( - GlobalStaff().has_user(user) or - CourseStaffRole(course_key).has_user(user) or - CourseInstructorRole(course_key).has_user(user) + GlobalStaff().has_user(user) + or CourseStaffRole(course_key).has_user(user) + or CourseInstructorRole(course_key).has_user(user) ) + + +def get_course_enrollments( + user: "AnonymousUser | User", + is_filtered: bool = False, + course_ids: list[str | None] | None = None, +) -> "QuerySet[CourseEnrollment]": + """ + Return enrollments for a user, potentially filtered by course_id. + + Because an empty `course_ids` value is a meaningful filter, the easiest way to verify + that the list should be filtered intentionally is to specify `is_filtered`. + + Arguments: + + * is_filtered (bool): whether or not the list is filtered + * course_ids (list): a list of course IDs to filter by. + """ + course_enrollments = CourseEnrollment.enrollments_for_user(user).select_related("course") + + if is_filtered: + course_enrollments = course_enrollments.filter(course_id__in=course_ids) + + return course_enrollments diff --git a/common/djangoapps/student/auth.py b/common/djangoapps/student/auth.py index d3dc51616c..e199142fe3 100644 --- a/common/djangoapps/student/auth.py +++ b/common/djangoapps/student/auth.py @@ -73,11 +73,17 @@ def user_has_role(user, role): return False -def get_user_permissions(user, course_key, org=None): +def get_user_permissions(user, course_key, org=None, service_variant=None): """ Get the bitmask of permissions that this user has in the given course context. Can also set course_key=None and pass in an org to get the user's permissions for that organization as a whole. + + :param user: a user + :param course_key: a CourseKey or None + :param org: an organization name or None + :param service_variant: the variant of the service (lms or cms). Permissions may differ between the two, + see the HACK comment in the function for more details. """ if org is None: org = course_key.org @@ -103,7 +109,7 @@ def get_user_permissions(user, course_key, org=None): # the LMS and Studio permissions will be separated as a part of this project. Once this is done (and this code is # not removed during its implementation), we can replace the Limited Staff permissions with more granular ones. if course_key and user_has_role(user, CourseLimitedStaffRole(course_key)): - if settings.SERVICE_VARIANT == 'lms': + if (service_variant or settings.SERVICE_VARIANT) == 'lms': return STUDIO_EDIT_CONTENT else: return STUDIO_NO_PERMISSIONS @@ -119,7 +125,7 @@ def get_user_permissions(user, course_key, org=None): return STUDIO_NO_PERMISSIONS -def has_studio_write_access(user, course_key): +def has_studio_write_access(user, course_key, service_variant=None): """ Return True if user has studio write access to the given course. Note that the CMS permissions model is with respect to courses. @@ -131,15 +137,17 @@ def has_studio_write_access(user, course_key): :param user: :param course_key: a CourseKey + :param service_variant: the variant of the service (lms or cms). Permissions may differ between the two, + see the comment in get_user_permissions for more details. """ - return bool(STUDIO_EDIT_CONTENT & get_user_permissions(user, course_key)) + return bool(STUDIO_EDIT_CONTENT & get_user_permissions(user, course_key, service_variant=service_variant)) -def has_course_author_access(user, course_key): +def has_course_author_access(user, course_key, service_variant=None): """ Old name for has_studio_write_access """ - return has_studio_write_access(user, course_key) + return has_studio_write_access(user, course_key, service_variant=service_variant) def has_studio_advanced_settings_access(user): diff --git a/common/djangoapps/student/constants.py b/common/djangoapps/student/constants.py new file mode 100644 index 0000000000..43607f1373 --- /dev/null +++ b/common/djangoapps/student/constants.py @@ -0,0 +1,4 @@ +"""# Generate a sorted list of unique language names from pycountry """ +import pycountry + +LANGUAGE_CHOICES = sorted({lang.name for lang in pycountry.languages if hasattr(lang, 'alpha_2')}) diff --git a/common/djangoapps/student/management/commands/backfill_is_disabled.py b/common/djangoapps/student/management/commands/backfill_is_disabled.py new file mode 100644 index 0000000000..b941bbc5cb --- /dev/null +++ b/common/djangoapps/student/management/commands/backfill_is_disabled.py @@ -0,0 +1,152 @@ +""" +Backfill the is_disabled attribute for existing users in Segment and Braze. + +This management command identifies users with unusable passwords (starting with +UNUSABLE_PASSWORD_PREFIX) and syncs the is_disabled=true attribute to their Segment +profiles using the segment.identify() function. It processes users in +batches to minimize memory usage and supports a dry-run mode for testing. +""" + +import logging +import time +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import UNUSABLE_PASSWORD_PREFIX +from django.db import DatabaseError +from common.djangoapps.track import segment +import requests + +LOGGER = logging.getLogger(__name__) +User = get_user_model() + + +class Command(BaseCommand): + """ + Backfill is_disabled attribute for users with unusable passwords in Segment. + """ + help = 'Backfill is_disabled attribute for existing disabled users in Segment' + + def add_arguments(self, parser): + parser.add_argument( + '--batch-size', + type=int, + default=9000, + help='Number of users to process per batch' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Simulate the back fill without calling Segment' + ) + parser.add_argument( + '--retry-limit', + type=int, default=3, + help='Retry attempts for failed API calls' + ) + + def _process_user(self, user_id, batch_number, dry_run): + """Process a single user, logging success or failure.""" + if dry_run: + LOGGER.info( + f"[Dry Run] Would update user {user_id} with is_disabled=true " + f"in batch {batch_number}" + ) + return True + try: + segment.analytics.identify(user_id=user_id, traits={'is_disabled': True}) + LOGGER.info( + f"Successfully updated user {user_id} with is_disabled=true " + f"in batch {batch_number}" + ) + return True + except (ConnectionError, ValueError) as e: + LOGGER.error( + f"Failed to update user {user_id} in batch {batch_number}: " + f"{str(e)}" + ) + return False + + def _process_batch(self, users_batch, batch_number, dry_run, retry_limit): + """Process a batch of users with retries.""" + current_batch_size = len(users_batch) + if dry_run: + for user_id in users_batch: + self._process_user(user_id, batch_number, dry_run) + return current_batch_size + + retry_count = 0 + success = False + while not success and retry_count <= retry_limit: + try: + for user_id in users_batch: + self._process_user(user_id, batch_number, dry_run) + segment.analytics.flush() + LOGGER.info(f"Successfully processed batch {batch_number}") + success = True + return current_batch_size + except (requests.exceptions.RequestException, segment.analytics.errors.APIError) as e: + retry_count += 1 + if retry_count <= retry_limit: + LOGGER.warning( + f"Batch {batch_number} failed (attempt {retry_count}/" + f"{retry_limit}): {str(e)}" + ) + time.sleep(2 * retry_count) + else: + LOGGER.error( + f"Batch {batch_number} failed after {retry_limit} attempts, " + f"processed {{processed}} users: {str(e)}" + ) + return None + + def handle(self, *args, **options): + batch_size = options['batch_size'] + dry_run = options['dry_run'] + retry_limit = options['retry_limit'] + + try: + LOGGER.info( + f"Starting backfill (batch_size={batch_size}, dry_run={dry_run}, " + f"retry_limit={retry_limit})" + ) + + total_users = User.objects.filter( + password__startswith=UNUSABLE_PASSWORD_PREFIX + ).count() + + if total_users == 0: + LOGGER.info("No users to process, exiting") + return + + LOGGER.info(f"Found {total_users} users that are disabled") + + offset = 0 + processed = 0 + batch_number = 0 + + while offset < total_users: + batch_number += 1 + users_batch = User.objects.filter( + password__startswith=UNUSABLE_PASSWORD_PREFIX + ).values_list('id', flat=True)[offset:offset + batch_size] + LOGGER.info(f"Processing batch {batch_number} ({len(users_batch)} users)") + + batch_result = self._process_batch( + users_batch, batch_number, dry_run, retry_limit + ) + if batch_result is None: + LOGGER.error( + f"Backfill stopped, processed {processed} users" + ) + return + processed += batch_result + offset += batch_size + LOGGER.info(f"Processed {processed} / {total_users} users") + + LOGGER.info( + f"Completed: processed {processed} / {total_users} users in " + f"{batch_number} batches" + ) + + except DatabaseError as e: + LOGGER.error(f"Back fill failed: {str(e)}") 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 a9c1d53498..69f2e35de4 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 @@ class Command(BaseCommand): 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/student/management/commands/populate_created_on_site_user_attribute.py b/common/djangoapps/student/management/commands/populate_created_on_site_user_attribute.py index c980bbf171..32350d6d9e 100644 --- a/common/djangoapps/student/management/commands/populate_created_on_site_user_attribute.py +++ b/common/djangoapps/student/management/commands/populate_created_on_site_user_attribute.py @@ -19,7 +19,7 @@ class Command(BaseCommand): """ help = """This command back-populates domain of the site the user account was created on. Example: ./manage.py lms populate_created_on_site_user_attribute --users ,... - '--activation-keys ,... --site-domain --settings=devstack_docker""" + '--activation-keys ,... --site-domain --settings=devstack""" def add_arguments(self, parser): """ diff --git a/common/djangoapps/student/management/commands/retrieve_unsubscribed_emails.py b/common/djangoapps/student/management/commands/retrieve_unsubscribed_emails.py index a9abd8831d..3c79d86879 100644 --- a/common/djangoapps/student/management/commands/retrieve_unsubscribed_emails.py +++ b/common/djangoapps/student/management/commands/retrieve_unsubscribed_emails.py @@ -9,7 +9,7 @@ from django.core.mail.message import EmailMultiAlternatives from django.core.management.base import BaseCommand, CommandError from django.template.loader import get_template -from lms.djangoapps.utils import get_braze_client +from lms.djangoapps.utils import get_email_client logger = logging.getLogger(__name__) @@ -69,12 +69,12 @@ class Command(BaseCommand): logger.info(f'Retrieving unsubscribed emails from {start_date} to {end_date}') try: - braze_client = get_braze_client() - if not braze_client: - logger.info('No Braze client found. Unable to retrieve unsubscribed emails.') + email_client = get_email_client() + if not email_client: + logger.info('No Email client found. Unable to retrieve unsubscribed emails.') return - emails = braze_client.retrieve_unsubscribed_emails( + emails = email_client.retrieve_unsubscribed_emails( start_date=start_date, end_date=end_date, ) diff --git a/common/djangoapps/student/management/commands/unsubscribe_user_email.py b/common/djangoapps/student/management/commands/unsubscribe_user_email.py index 23973b8475..f24db4cef8 100644 --- a/common/djangoapps/student/management/commands/unsubscribe_user_email.py +++ b/common/djangoapps/student/management/commands/unsubscribe_user_email.py @@ -4,7 +4,7 @@ import logging from django.core.management.base import BaseCommand, CommandError -from lms.djangoapps.utils import get_braze_client +from lms.djangoapps.utils import get_email_client logger = logging.getLogger(__name__) CHUNK_SIZE = 50 @@ -63,10 +63,10 @@ class Command(BaseCommand): chunks = self._chunk_list(emails) try: - braze_client = get_braze_client() - if braze_client: + email_client = get_email_client() + if email_client: for i, chunk in enumerate(chunks): - braze_client.unsubscribe_user_email( + email_client.unsubscribe_user_email( email=chunk, ) logger.info(f"Successfully unsubscribed for chunk-{i + 1} consist of {len(chunk)} emails") diff --git a/common/djangoapps/student/management/tests/test_retrieve_unsubscribed_emails.py b/common/djangoapps/student/management/tests/test_retrieve_unsubscribed_emails.py index 5a2a74302e..e86badf885 100644 --- a/common/djangoapps/student/management/tests/test_retrieve_unsubscribed_emails.py +++ b/common/djangoapps/student/management/tests/test_retrieve_unsubscribed_emails.py @@ -39,14 +39,14 @@ class RetrieveUnsubscribedEmailsTests(TestCase): BRAZE_UNSUBSCRIBED_EMAILS_RECIPIENT_EMAIL=['test@example.com'] ) @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.EmailMultiAlternatives.send') - @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.get_braze_client') + @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.get_email_client') @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.logger.info') - def test_retrieve_unsubscribed_emails_command(self, mock_logger_info, mock_get_braze_client, mock_send): + def test_retrieve_unsubscribed_emails_command(self, mock_logger_info, mock_get_email_client, mock_send): """ Test the retrieve_unsubscribed_emails command """ - mock_braze_client = mock_get_braze_client.return_value - mock_braze_client.retrieve_unsubscribed_emails.return_value = [ + mock_email_client = mock_get_email_client.return_value + mock_email_client.retrieve_unsubscribed_emails.return_value = [ {'email': 'test1@example.com', 'unsubscribed_at': '2023-06-01 10:00:00'}, {'email': 'test2@example.com', 'unsubscribed_at': '2023-06-02 12:00:00'}, ] @@ -81,14 +81,14 @@ class RetrieveUnsubscribedEmailsTests(TestCase): BRAZE_UNSUBSCRIBED_EMAILS_RECIPIENT_EMAIL=['test@example.com'] ) @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.EmailMultiAlternatives.send') - @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.get_braze_client') + @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.get_email_client') @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.logger.info') - def test_retrieve_unsubscribed_emails_command_with_dates(self, mock_logger_info, mock_get_braze_client, mock_send): + def test_retrieve_unsubscribed_emails_command_with_dates(self, mock_logger_info, mock_get_email_client, mock_send): """ Test the retrieve_unsubscribed_emails command with custom start and end dates. """ - mock_braze_client = mock_get_braze_client.return_value - mock_braze_client.retrieve_unsubscribed_emails.return_value = [ + mock_email_client = mock_get_email_client.return_value + mock_email_client.retrieve_unsubscribed_emails.return_value = [ {'email': 'test3@example.com', 'unsubscribed_at': '2023-06-03 08:00:00'}, {'email': 'test4@example.com', 'unsubscribed_at': '2023-06-04 14:00:00'}, ] @@ -123,15 +123,15 @@ class RetrieveUnsubscribedEmailsTests(TestCase): self.assertIn('test4@example.com,2023-06-04 14:00:00', csv_data) @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.EmailMultiAlternatives.send') - @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.get_braze_client') + @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.get_email_client') @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.logger.exception') - def test_retrieve_unsubscribed_emails_command_braze_exception(self, mock_logger_exception, mock_get_braze_client, + def test_retrieve_unsubscribed_emails_command_braze_exception(self, mock_logger_exception, mock_get_email_client, mock_send): """ Test the retrieve_unsubscribed_emails command when an exception is raised. """ - mock_braze_client = mock_get_braze_client.return_value - mock_braze_client.retrieve_unsubscribed_emails.side_effect = Exception('Braze API error') + mock_email_client = mock_get_email_client.return_value + mock_email_client.retrieve_unsubscribed_emails.side_effect = Exception('Braze API error') mock_send.return_value = MagicMock() with self.assertRaises(CommandError): @@ -143,14 +143,14 @@ class RetrieveUnsubscribedEmailsTests(TestCase): mock_send.assert_not_called() @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.EmailMultiAlternatives.send') - @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.get_braze_client') + @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.get_email_client') @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.logger.info') - def test_retrieve_unsubscribed_emails_command_no_data(self, mock_logger_info, mock_get_braze_client, mock_send): + def test_retrieve_unsubscribed_emails_command_no_data(self, mock_logger_info, mock_get_email_client, mock_send): """ Test the retrieve_unsubscribed_emails command when no unsubscribed emails are returned. """ - mock_braze_client = mock_get_braze_client.return_value - mock_braze_client.retrieve_unsubscribed_emails.return_value = [] + mock_email_client = mock_get_email_client.return_value + mock_email_client.retrieve_unsubscribed_emails.return_value = [] mock_send.return_value = MagicMock() call_command('retrieve_unsubscribed_emails') @@ -166,15 +166,15 @@ class RetrieveUnsubscribedEmailsTests(TestCase): BRAZE_UNSUBSCRIBED_EMAILS_RECIPIENT_EMAIL=['test@example.com'] ) @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.EmailMultiAlternatives.send') - @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.get_braze_client') + @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.get_email_client') @patch('common.djangoapps.student.management.commands.retrieve_unsubscribed_emails.logger.exception') def test_retrieve_unsubscribed_emails_command_error_sending_email(self, mock_logger_exception, - mock_get_braze_client, mock_send): + mock_get_email_client, mock_send): """ Test the retrieve_unsubscribed_emails command when an error occurs during email sending. """ - mock_braze_client = mock_get_braze_client.return_value - mock_braze_client.retrieve_unsubscribed_emails.return_value = [ + mock_email_client = mock_get_email_client.return_value + mock_email_client.retrieve_unsubscribed_emails.return_value = [ {'email': 'test1@example.com', 'unsubscribed_at': '2023-06-01 10:00:00'}, ] mock_send.side_effect = Exception('Email sending error') diff --git a/common/djangoapps/student/management/tests/test_unsubscribe_user_email.py b/common/djangoapps/student/management/tests/test_unsubscribe_user_email.py index 66d0be8db3..0e9c9f0266 100644 --- a/common/djangoapps/student/management/tests/test_unsubscribe_user_email.py +++ b/common/djangoapps/student/management/tests/test_unsubscribe_user_email.py @@ -37,8 +37,8 @@ class UnsubscribeUserEmailTests(TestCase): csv.seek(0) return csv - @patch("common.djangoapps.student.management.commands.unsubscribe_user_email.get_braze_client") - def test_unsubscribe_user_email(self, mock_get_braze_client): + @patch("common.djangoapps.student.management.commands.unsubscribe_user_email.get_email_client") + def test_unsubscribe_user_email(self, mock_get_email_client): """ Test CSV file to unsubscribe user's email""" with NamedTemporaryFile() as csv: @@ -50,7 +50,7 @@ class UnsubscribeUserEmailTests(TestCase): csv.name ) - mock_get_braze_client.assert_called_once() + mock_get_email_client.assert_called_once() def test_command_error_for_csv_path(self): """ Test command error raised if csv_path is not valid""" diff --git a/common/djangoapps/student/models/course_enrollment.py b/common/djangoapps/student/models/course_enrollment.py index 750ac66e38..7350d5b407 100644 --- a/common/djangoapps/student/models/course_enrollment.py +++ b/common/djangoapps/student/models/course_enrollment.py @@ -512,6 +512,7 @@ class CourseEnrollment(models.Model): ) # .. event_implemented_name: COURSE_ENROLLMENT_CHANGED + # .. event_type: org.openedx.learning.course.enrollment.changed.v1 COURSE_ENROLLMENT_CHANGED.send_event( enrollment=CourseEnrollmentData( user=UserData( @@ -539,6 +540,7 @@ class CourseEnrollment(models.Model): self.send_signal(EnrollStatusChange.unenroll) # .. event_implemented_name: COURSE_UNENROLLMENT_COMPLETED + # .. event_type: org.openedx.learning.course.unenrollment.completed.v1 COURSE_UNENROLLMENT_COMPLETED.send_event( enrollment=CourseEnrollmentData( user=UserData( @@ -717,6 +719,8 @@ class CourseEnrollment(models.Model): Also emits relevant events for analytics purposes. """ try: + # .. filter_implemented_name: CourseEnrollmentStarted + # .. filter_type: org.openedx.learning.course.enrollment.started.v1 user, course_key, mode = CourseEnrollmentStarted.run_filter( user=user, course_key=course_key, mode=mode, ) @@ -775,6 +779,7 @@ class CourseEnrollment(models.Model): enrollment.send_signal(EnrollStatusChange.enroll) # .. event_implemented_name: COURSE_ENROLLMENT_CREATED + # .. event_type: org.openedx.learning.course.enrollment.created.v1 COURSE_ENROLLMENT_CREATED.send_event( enrollment=CourseEnrollmentData( user=UserData( diff --git a/common/djangoapps/student/models/user.py b/common/djangoapps/student/models/user.py index 9d979beb19..94cb99d0ce 100644 --- a/common/djangoapps/student/models/user.py +++ b/common/djangoapps/student/models/user.py @@ -11,29 +11,20 @@ file and check it in at the same time as your model changes. To do that, 3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/ """ -import crum -import hashlib # lint-amnesty, pylint: disable=wrong-import-order -import json # lint-amnesty, pylint: disable=wrong-import-order -import logging # lint-amnesty, pylint: disable=wrong-import-order -import uuid # lint-amnesty, pylint: disable=wrong-import-order -from datetime import datetime, timedelta # lint-amnesty, pylint: disable=wrong-import-order -from functools import total_ordering # lint-amnesty, pylint: disable=wrong-import-order -from importlib import import_module # lint-amnesty, pylint: disable=wrong-import-order +import hashlib +import json +import logging +import uuid +from datetime import datetime, timedelta +from functools import total_ordering +from importlib import import_module from urllib.parse import urlencode -from .course_enrollment import ( - ALLOWEDTOENROLL_TO_ENROLLED, - CourseEnrollment, - CourseEnrollmentAllowed, - CourseOverview, - ManualEnrollmentAudit, - segment -) - +import crum from config_models.models import ConfigurationModel from django.apps import apps from django.conf import settings -from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user +from django.contrib.auth import get_user_model from django.contrib.auth.signals import user_logged_in, user_logged_out from django.contrib.sites.models import Site from django.core.cache import cache @@ -64,6 +55,16 @@ from openedx.core.djangoapps.xmodule_django.models import NoneToEmptyManager from openedx.core.djangolib.model_mixins import DeletableByUserValue from openedx.core.toggles import ENTRANCE_EXAMS +from .course_enrollment import ( + ALLOWEDTOENROLL_TO_ENROLLED, + CourseEnrollment, + CourseEnrollmentAllowed, + CourseOverview, + ManualEnrollmentAudit, + segment +) + +User = get_user_model() log = logging.getLogger(__name__) AUDIT_LOG = logging.getLogger("audit") SessionStore = import_module(settings.SESSION_ENGINE).SessionStore # pylint: disable=invalid-name @@ -72,6 +73,7 @@ IS_MARKETABLE = 'is_marketable' USER_LOGGED_IN_EVENT_NAME = 'edx.user.login' USER_LOGGED_OUT_EVENT_NAME = 'edx.user.logout' +USER_STREAK_UPDATED_EVENT_NAME = "edx.user.celebration.streak_updated" class AnonymousUserId(models.Model): @@ -694,10 +696,6 @@ def user_profile_pre_save_callback(sender, **kwargs): """ user_profile = kwargs['instance'] - # Remove profile images for users who require parental consent - if user_profile.requires_parental_consent() and user_profile.has_profile_image: - user_profile.profile_image_uploaded_at = None - # Cache "old" field values on the model instance so that they can be # retrieved in the post_save callback when we emit an event with new and # old field values. @@ -1028,7 +1026,7 @@ class LoginFailures(models.Model): entry = cls._get_record_for_user(user) entry.delete() except ObjectDoesNotExist: - return + pass def __str__(self): """Str -> Username: count - date.""" @@ -1769,7 +1767,7 @@ class UserCelebration(TimeStampedModel): # Celebrate if we didn't already celebrate today streak_length_to_celebrate = streak_length - return last_day_of_streak, streak_length, streak_length_to_celebrate + return last_day_of_streak, streak_length, streak_length_to_celebrate, already_updated_streak_today def _update_streak(self, last_day_of_streak, streak_length): """ Update the celebration with the new streak data """ @@ -1781,6 +1779,32 @@ class UserCelebration(TimeStampedModel): self.save() + def _emit_streak_update_event(self, user: User, course_id: str, current_streak_length: int) -> None: + """ + Emits a server-side event using the event tracking library with details about the learner's current streak. The + course run ID is included to enable tracking of progress trends over time. + + Args: + user (User): The user whose streak is being updated. + course_id (str): The course run ID the learner is currently engaged with. + current_streak_length (int): The number of consecutive days the user has been active. + """ + context = { + "user_id": user.id, + "course_id": course_id, + } + data = { + "user_id": user.id, + "current_course_id": course_id, + "current_streak_length": current_streak_length, + } + + with tracker.get_tracker().context(USER_STREAK_UPDATED_EVENT_NAME, context): + tracker.emit( + USER_STREAK_UPDATED_EVENT_NAME, + data, + ) + @classmethod def _get_celebration(cls, user, course_key): """ Retrieve (or create) the celebration for the provided user and course_key """ @@ -1795,30 +1819,46 @@ class UserCelebration(TimeStampedModel): @classmethod def perform_streak_updates(cls, user, course_key, browser_timezone=None): - """ Determine if the user should see a streak celebration and - return the length of the streak the user should celebrate. - Also update the streak data that is stored in the database.""" + """ + Determine if the user should see a streak celebration and return the length of the streak the user should + celebrate. + + Additionally, we record any updates to the current streak in the database and emit a server side + event about the update. + + Args: + user (User): The user whose streak is being updated. + course_key (CourseLocator): The Course Run key of the course the user is currently engaged with when + recording the streak update. + browser_timezone (str): String representing the current time zone set from a user's web browser. + May be null. + + Returns: + streak_length_to_celebrate (int): A number representing how many days in a row a learner has been actively + engaging in learning content in the courseware. + """ # importing here to avoid a circular import from lms.djangoapps.courseware.masquerade import is_masquerading_as_specific_student - if not user or user.is_anonymous: - return None - - if is_masquerading_as_specific_student(user, course_key): + if ( + not user + or user.is_anonymous + or is_masquerading_as_specific_student(user, course_key) + ): return None celebration = cls._get_celebration(user, course_key) - if not celebration: return None today = cls._get_now(browser_timezone).date() - # pylint: disable=protected-access - last_day_of_streak, streak_length, streak_length_to_celebrate = \ + last_day_of_streak, streak_length, streak_length_to_celebrate, already_updated_streak_today = \ celebration._calculate_streak_updates(today) # pylint: enable=protected-access - cls._update_streak(celebration, last_day_of_streak, streak_length) + if not already_updated_streak_today: + cls._update_streak(celebration, last_day_of_streak, streak_length) + cls._emit_streak_update_event(celebration, user, str(course_key), streak_length) return streak_length_to_celebrate diff --git a/common/djangoapps/student/signals/receivers.py b/common/djangoapps/student/signals/receivers.py index 82300f4e3a..529ac261b4 100644 --- a/common/djangoapps/student/signals/receivers.py +++ b/common/djangoapps/student/signals/receivers.py @@ -12,7 +12,7 @@ from django.db.models.signals import post_delete, post_save, pre_save from django.dispatch import receiver from lms.djangoapps.courseware.toggles import courseware_mfe_progress_milestones_are_active -from lms.djangoapps.utils import get_braze_client +from lms.djangoapps.utils import get_email_client from common.djangoapps.student.helpers import EMAIL_EXISTS_MSG_FMT, USERNAME_EXISTS_MSG_FMT, AccountValidationError from common.djangoapps.student.models import ( CourseAccessRole, @@ -145,8 +145,8 @@ def _listen_for_user_email_changed(sender, user, request, **kwargs): attributes = [{'email': email, 'external_id': user_id}] try: - braze_client = get_braze_client() - if braze_client: - braze_client.track_user(attributes=attributes) + email_client = get_email_client() + if email_client: + email_client.track_user(attributes=attributes) except Exception as exc: # pylint: disable=broad-except logger.exception(f'Unable to sync new email [{email}] with Braze for user [{user_id}]') diff --git a/common/djangoapps/student/signals/signals.py b/common/djangoapps/student/signals/signals.py index 15ccbe5cc0..a9fe3cf1f5 100644 --- a/common/djangoapps/student/signals/signals.py +++ b/common/djangoapps/student/signals/signals.py @@ -31,6 +31,8 @@ def emit_course_access_role_added(user, course_id, org_key, role): """ Emit an event to the event-bus when a CourseAccessRole is added """ + # .. event_implemented_name: COURSE_ACCESS_ROLE_ADDED + # .. event_type: org.openedx.learning.user.course_access_role.added.v1 COURSE_ACCESS_ROLE_ADDED.send_event( course_access_role_data=CourseAccessRoleData( user=UserData( @@ -52,6 +54,8 @@ def emit_course_access_role_removed(user, course_id, org_key, role): """ Emit an event to the event-bus when a CourseAccessRole is deleted """ + # .. event_implemented_name: COURSE_ACCESS_ROLE_REMOVED + # .. event_type: org.openedx.learning.user.course_access_role.removed.v1 COURSE_ACCESS_ROLE_REMOVED.send_event( course_access_role_data=CourseAccessRoleData( user=UserData( diff --git a/common/djangoapps/student/tasks.py b/common/djangoapps/student/tasks.py index c7e0842664..c13c5bf96b 100644 --- a/common/djangoapps/student/tasks.py +++ b/common/djangoapps/student/tasks.py @@ -14,7 +14,7 @@ from common.djangoapps.student.helpers import ( get_course_dates_for_email, get_instructors, ) -from lms.djangoapps.utils import get_braze_client +from lms.djangoapps.utils import get_email_client from openedx.core.djangoapps.catalog.utils import ( get_course_uuid_for_course, get_owners_for_course, @@ -115,6 +115,7 @@ def send_course_enrollment_email( "short_description": course_run.get("short_description"), "pacing_type": course_run.get("pacing_type"), "partner_image_url": owners[0].get("logo_image_url") if owners else "", + "org_name": owners[0].get("name") if owners else "", } ) except Exception as err: # pylint: disable=broad-except @@ -131,9 +132,9 @@ def send_course_enrollment_email( try: recipients = [{"external_user_id": user_id}] - braze_client = get_braze_client() - if braze_client: - braze_client.send_canvas_message( + email_client = get_email_client() + if email_client: + email_client.send_canvas_message( canvas_id=settings.BRAZE_COURSE_ENROLLMENT_CANVAS_ID, recipients=recipients, canvas_entry_properties=canvas_entry_properties, diff --git a/common/djangoapps/student/tests/test_admin_views.py b/common/djangoapps/student/tests/test_admin_views.py index 2914bcd61c..968ba330a9 100644 --- a/common/djangoapps/student/tests/test_admin_views.py +++ b/common/djangoapps/student/tests/test_admin_views.py @@ -4,10 +4,12 @@ Tests student admin.py import datetime +import json from unittest.mock import Mock import ddt import pytest + from django.contrib.admin.sites import AdminSite from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.forms import ValidationError @@ -24,7 +26,7 @@ from common.djangoapps.student.admin import ( # lint-amnesty, pylint: disable=l UserAdmin ) from common.djangoapps.student.models import AllowedAuthUser, CourseEnrollment, LoginFailures -from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory, UserProfileFactory from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order @@ -532,3 +534,164 @@ class AllowedAuthUserFormTest(SiteMixin, TestCase): db_allowed_auth_user = AllowedAuthUser.objects.all().first() assert AllowedAuthUser.objects.all().count() == 1 assert db_allowed_auth_user.email == self.other_valid_email + + +@ddt.ddt +class TestUserProfileAutocompleteAdmin(TestCase): + """Tests for language and country autocomplete in UserProfile inline form via Django admin.""" + + def setUp(self): + super().setUp() + self.staff_user = UserFactory(is_staff=True) + self.staff_user.set_password('test') + self.staff_user.save() + + self.non_staff_user = UserFactory(is_staff=False) + self.non_staff_user.set_password('test') + self.non_staff_user.save() + + self.client.login(username=self.staff_user.username, password='test') + + self.language_url = reverse('admin:language-autocomplete') + self.country_url = reverse('admin:country-autocomplete') + + user1 = UserFactory() + user1.set_password('test') + user1.save() + UserProfileFactory(user=user1, language='English', country='PK') + + user2 = UserFactory() + user2.set_password('test') + user2.save() + UserProfileFactory(user=user2, language='French', country='GB') + + user3 = UserFactory() + user3.set_password('test') + user3.save() + UserProfileFactory(user=user3, language='German', country='US') + + def test_language_autocomplete_returns_expected_result(self): + """Verify language autocomplete returns expected filtered results.""" + profile = UserProfileFactory(user=self.staff_user, language='Esperanto') + + response = self.client.get(self.language_url) + self.assertEqual(response.status_code, 200) + + data = json.loads(response.content.decode('utf-8')) + self.assertTrue( + any('Esperanto' in item['text'] for item in data['results']), + f"Esperanto not found in: {data['results']}" + ) + + profile.language = 'French' + profile.save() + + response = self.client.get(f'{self.language_url}?q=Fren') + self.assertEqual(response.status_code, 200) + + data = json.loads(response.content.decode('utf-8')) + self.assertTrue( + any('French' in item['text'] for item in data['results']), + f"French not found in: {data['results']}" + ) + + def test_country_autocomplete_returns_expected_result(self): + """Verify country autocomplete returns expected filtered results.""" + profile = UserProfileFactory(user=self.staff_user, country='SE') + + response = self.client.get(self.country_url) + self.assertEqual(response.status_code, 200) + data = json.loads(response.content.decode('utf-8')) + self.assertTrue( + any('Sweden' in item['text'] for item in data['results']), + f"Sweden not found in: {data['results']}" + ) + + profile.country = 'JP' + profile.save() + + response = self.client.get(f'{self.country_url}?q=Japan') + self.assertEqual(response.status_code, 200) + + data = json.loads(response.content.decode('utf-8')) + self.assertTrue( + any('Japan' in item['text'] for item in data['results']), + f"Japan not found in: {data['results']}" + ) + + @ddt.data('eng', 'fren', 'GER') + def test_language_autocomplete_filters_correctly(self, term): + response = self.client.get(f'{self.language_url}?q={term}') + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertTrue(any(term.lower() in item['text'].lower() for item in data['results'])) + + def test_language_autocomplete_returns_empty_on_no_match(self): + response = self.client.get(f'{self.language_url}?q=not-a-lang') + self.assertEqual(json.loads(response.content)['results'], []) + + @ddt.data('United', 'Kingdom', 'Pakistan') + def test_country_autocomplete_filters_correctly(self, term): + response = self.client.get(f'{self.country_url}?q={term}') + self.assertEqual(response.status_code, 200) + data = json.loads(response.content) + self.assertTrue(any(term.lower() in item['text'].lower() for item in data['results'])) + + def test_country_autocomplete_returns_empty_on_gibberish(self): + response = self.client.get(f'{self.country_url}?q=asdfghjkl') + self.assertEqual(json.loads(response.content)['results'], []) + + def test_admin_inline_autocomplete_urls_render(self): + admin = UserFactory(is_staff=True, is_superuser=True) + admin.set_password('test') + admin.save() + + user = UserFactory() + user.set_password('test') + user.save() + self.client.login(username=admin.username, password='test') # re-login as admin + + response = self.client.get(reverse('admin:auth_user_change', args=[user.id])) + self.assertEqual(response.status_code, 200) + self.assertContains(response, self.language_url) + self.assertContains(response, self.country_url) + + def test_language_autocomplete_blocks_non_staff(self): + self.client.logout() + self.client.login(username=self.non_staff_user.username, password='test') + response = self.client.get(f'{self.language_url}?q=english') + data = json.loads(response.content) + self.assertEqual(data['results'], []) + + def test_country_autocomplete_blocks_non_staff(self): + self.client.logout() + self.client.login(username=self.non_staff_user.username, password='test') + response = self.client.get(f'{self.country_url}?q=pakistan') + data = json.loads(response.content) + self.assertEqual(data['results'], []) + + def test_language_autocomplete_blocks_anonymous_user(self): + """Ensure anonymous user gets blocked or redirected.""" + self.client.logout() + response = self.client.get(f'{self.language_url}?q=English') + self.assertIn(response.status_code, [302, 403]) + + def test_country_autocomplete_blocks_anonymous_user(self): + """Ensure anonymous user gets blocked or redirected.""" + self.client.logout() + response = self.client.get(f'{self.country_url}?q=Pakistan') + self.assertIn(response.status_code, [302, 403]) + + def test_language_autocomplete_status_for_non_staff(self): + self.client.logout() + self.client.login(username=self.non_staff_user.username, password='test') + response = self.client.get(f'{self.language_url}?q=English') + self.assertEqual(response.status_code, 200) # still 200, but empty results expected + self.assertEqual(json.loads(response.content)['results'], []) + + def test_unknown_autocomplete_path_404s(self): + logged_in = self.client.login(username=self.staff_user.username, password='test') + assert logged_in, "Login failed — test user not authenticated" + + response = self.client.get('/admin/myapp/mymodel/fake-autocomplete/') + self.assertEqual(response.status_code, 404) diff --git a/common/djangoapps/student/tests/test_api.py b/common/djangoapps/student/tests/test_api.py index ad462830a1..7cb20380cf 100644 --- a/common/djangoapps/student/tests/test_api.py +++ b/common/djangoapps/student/tests/test_api.py @@ -1,10 +1,16 @@ """ Test Student api.py """ + from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from common.djangoapps.student.api import is_user_enrolled_in_course, is_user_staff_or_instructor_in_course +from common.djangoapps.student.api import ( + is_user_enrolled_in_course, + is_user_staff_or_instructor_in_course, + get_course_enrollments, +) +from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import ( CourseEnrollmentFactory, GlobalStaffFactory, @@ -33,10 +39,7 @@ class TestStudentApi(SharedModuleStoreTestCase): """ Verify the correct value is returned when a learner is actively enrolled in a course-run. """ - CourseEnrollmentFactory.create( - user_id=self.user.id, - course_id=self.course.id - ) + CourseEnrollmentFactory.create(user_id=self.user.id, course_id=self.course.id) result = is_user_enrolled_in_course(self.user, self.course_run_key) assert result @@ -45,11 +48,7 @@ class TestStudentApi(SharedModuleStoreTestCase): """ Verify the correct value is returned when a learner is not actively enrolled in a course-run. """ - CourseEnrollmentFactory.create( - user_id=self.user.id, - course_id=self.course.id, - is_active=False - ) + CourseEnrollmentFactory.create(user_id=self.user.id, course_id=self.course.id, is_active=False) result = is_user_enrolled_in_course(self.user, self.course_run_key) assert not result @@ -79,3 +78,25 @@ class TestStudentApi(SharedModuleStoreTestCase): assert is_user_staff_or_instructor_in_course(instructor, self.course_run_key) assert not is_user_staff_or_instructor_in_course(self.user, self.course_run_key) assert not is_user_staff_or_instructor_in_course(instructor_different_course, self.course_run_key) + + def test_get_course_enrollments(self): + """Verify all enrollments can be retrieved""" + course_2 = CourseFactory.create() + CourseEnrollmentFactory.create(user_id=self.user.id, course_id=self.course.id) + CourseEnrollmentFactory.create(user_id=self.user.id, course_id=course_2.id) + expected = CourseEnrollment.objects.all() + + result = get_course_enrollments(self.user) + + self.assertQuerySetEqual(expected, result) + + def test_get_filtered_course_enrollments(self): + """Verify a filtered subset of enrollments can be retrieved""" + course_2 = CourseFactory.create() + CourseEnrollmentFactory.create(user_id=self.user.id, course_id=self.course.id) + ce_2 = CourseEnrollmentFactory.create(user_id=self.user.id, course_id=course_2.id) + expected = CourseEnrollment.objects.filter(id=ce_2.id) + + result = get_course_enrollments(self.user, True, course_ids=[course_2.id]) + + self.assertQuerySetEqual(expected, result) diff --git a/common/djangoapps/student/tests/test_filters.py b/common/djangoapps/student/tests/test_filters.py index 376595a850..bf79ed7ae4 100644 --- a/common/djangoapps/student/tests/test_filters.py +++ b/common/djangoapps/student/tests/test_filters.py @@ -1,6 +1,7 @@ """ Test that various filters are fired for models/views in the student app. """ +from django.conf import settings from django.http import HttpResponse from django.test import override_settings from django.urls import reverse @@ -421,7 +422,7 @@ class StudentDashboardFiltersTest(ModuleStoreTestCase): response = self.client.get(self.dashboard_url) self.assertEqual(status.HTTP_302_FOUND, response.status_code) - self.assertEqual(reverse("account_settings"), response.url) + self.assertEqual(settings.ACCOUNT_MICROFRONTEND_URL, response.url) @override_settings( OPEN_EDX_FILTERS_CONFIG={ diff --git a/common/djangoapps/student/tests/test_models.py b/common/djangoapps/student/tests/test_models.py index 2388a21c7d..02df1a6714 100644 --- a/common/djangoapps/student/tests/test_models.py +++ b/common/djangoapps/student/tests/test_models.py @@ -2,6 +2,7 @@ import datetime import hashlib from unittest import mock +from unittest.mock import MagicMock import ddt import pytz @@ -460,6 +461,51 @@ class UserCelebrationTests(SharedModuleStoreTestCase): UserCelebration.perform_streak_updates(self.user, self.course_key) update_streak_mock.assert_not_called() + def test_event_emit_when_streak_is_updated(self): + """ + Ensure we call the `emit_streak_update_event` method when a streak is updated. + """ + with mock.patch.object(UserCelebration, '_emit_streak_update_event') as emit_streak_event_mock: + UserCelebration.perform_streak_updates(self.user, self.course_key) + emit_streak_event_mock.assert_called_once() + + @mock.patch("common.djangoapps.student.models.user.tracker.emit") + @mock.patch("common.djangoapps.student.models.tracker.get_tracker") + def test_emit_streak_update_event(self, mock_get_tracker, mock_tracker): + """ + Ensure the event emission code of the `emit_streak_update_event` method works as expected. + """ + mock_context_manager = MagicMock() + mock_context_manager.__enter__.return_value = None + mock_context_manager.__exit__.return_value = None + + mock_tracker_instance = MagicMock() + mock_tracker_instance.context.return_value = mock_context_manager + mock_get_tracker.return_value = mock_tracker_instance + + expected_data = { + "user_id": self.user.id, + "current_course_id": str(self.course_key), + "current_streak_length": 4, + } + + celebration = UserCelebration() + # pylint: disable=protected-access + celebration._emit_streak_update_event(self.user, str(self.course_key), 4) + # pylint: enable=protected-access + + mock_tracker_instance.context.assert_called_once_with( + "edx.user.celebration.streak_updated", + { + "user_id": self.user.id, + "course_id": str(self.course_key), + } + ) + mock_tracker.assert_called_once_with( + "edx.user.celebration.streak_updated", + expected_data, + ) + class PendingNameChangeTests(SharedModuleStoreTestCase): """ diff --git a/common/djangoapps/student/tests/test_parental_controls.py b/common/djangoapps/student/tests/test_parental_controls.py index 4e49f88af9..a8ae471c61 100644 --- a/common/djangoapps/student/tests/test_parental_controls.py +++ b/common/djangoapps/student/tests/test_parental_controls.py @@ -59,29 +59,9 @@ class ProfileParentalControlsTest(TestCase): self.set_year_of_birth(current_year - 13) assert self.profile.requires_parental_consent() assert self.profile.requires_parental_consent(year=current_year) - assert not self.profile.requires_parental_consent(year=(current_year + 1)) + assert not self.profile.requires_parental_consent(year=current_year + 1) # Verify for a child born 14 years ago self.set_year_of_birth(current_year - 14) assert not self.profile.requires_parental_consent() assert not self.profile.requires_parental_consent(year=current_year) - - def test_profile_image(self): - """Verify that a profile's image obeys parental controls.""" - - # Verify that an image cannot be set for a user with no year of birth set - self.profile.profile_image_uploaded_at = now() - self.profile.save() - assert not self.profile.has_profile_image - - # Verify that an image can be set for an adult user - current_year = now().year - self.set_year_of_birth(current_year - 20) - self.profile.profile_image_uploaded_at = now() - self.profile.save() - assert self.profile.has_profile_image - - # verify that a user's profile image is removed when they switch to requiring parental controls - self.set_year_of_birth(current_year - 10) - self.profile.save() - assert not self.profile.has_profile_image diff --git a/common/djangoapps/student/tests/test_receivers.py b/common/djangoapps/student/tests/test_receivers.py index e987120a9d..424df4759d 100644 --- a/common/djangoapps/student/tests/test_receivers.py +++ b/common/djangoapps/student/tests/test_receivers.py @@ -72,11 +72,11 @@ class ReceiversTest(SharedModuleStoreTestCase): assert profile.name == new_name @skip_unless_lms - @patch('common.djangoapps.student.signals.receivers.get_braze_client') - def test_listen_for_user_email_changed(self, mock_get_braze_client): + @patch('common.djangoapps.student.signals.receivers.get_email_client') + def test_listen_for_user_email_changed(self, mock_get_email_client): """ Ensure that USER_EMAIL_CHANGED signal triggers correct calls to - get_braze_client and update email in session. + get_email_client and update email in session. """ user = UserFactory(email='email@test.com', username='jdoe') request = get_mock_request(user=user) @@ -88,5 +88,5 @@ class ReceiversTest(SharedModuleStoreTestCase): USER_EMAIL_CHANGED.send(sender=None, user=user, request=request) - assert mock_get_braze_client.called + assert mock_get_email_client.called assert request.session.get('email', None) == user.email diff --git a/common/djangoapps/student/tests/test_tasks.py b/common/djangoapps/student/tests/test_tasks.py index 99d24fed37..d5fc3d3db0 100644 --- a/common/djangoapps/student/tests/test_tasks.py +++ b/common/djangoapps/student/tests/test_tasks.py @@ -85,6 +85,7 @@ class TestCourseEnrollmentEmailTask(ModuleStoreTestCase): return [ { "logo_image_url": "https://prod/organization/logos/2cc39992c67a.png", + "name": "edX University", } ] @@ -164,6 +165,7 @@ class TestCourseEnrollmentEmailTask(ModuleStoreTestCase): "short_description": course_run["short_description"], "pacing_type": course_run["pacing_type"], "partner_image_url": self._get_course_owners()[0]["logo_image_url"], + "org_name": self._get_course_owners()[0]["name"], } ) @@ -173,10 +175,10 @@ class TestCourseEnrollmentEmailTask(ModuleStoreTestCase): @patch("common.djangoapps.student.tasks.get_owners_for_course") @patch("common.djangoapps.student.tasks.get_course_run_details") @patch("common.djangoapps.student.tasks.get_course_dates_for_email") - @patch("common.djangoapps.student.tasks.get_braze_client") + @patch("common.djangoapps.student.tasks.get_email_client") def test_success_calls_for_canvas_properties( self, - mock_get_braze_client, + mock_get_email_client, mock_get_course_dates_for_email, mock_get_course_run_details, mock_get_owners_for_course, @@ -194,7 +196,7 @@ class TestCourseEnrollmentEmailTask(ModuleStoreTestCase): send_course_enrollment_email.apply_async( kwargs=self.send_course_enrollment_email_kwargs ) - mock_get_braze_client.return_value.send_canvas_message.assert_called_with( + mock_get_email_client.return_value.send_canvas_message.assert_called_with( canvas_id=BRAZE_COURSE_ENROLLMENT_CANVAS_ID, recipients=[ { @@ -207,14 +209,14 @@ class TestCourseEnrollmentEmailTask(ModuleStoreTestCase): @patch("common.djangoapps.student.tasks.get_course_uuid_for_course") @patch("common.djangoapps.student.tasks.get_owners_for_course") @patch("common.djangoapps.student.tasks.get_course_run_details") - @patch("common.djangoapps.student.tasks.get_braze_client") + @patch("common.djangoapps.student.tasks.get_email_client") @patch( "common.djangoapps.student.tasks.get_course_dates_for_email", Mock(side_effect=Exception), ) def test_canvas_properties_without_course_dates( self, - mock_get_braze_client, + mock_get_email_client, mock_get_course_run_details, mock_get_owners_for_course, mock_get_course_uuid_for_course, @@ -230,7 +232,7 @@ class TestCourseEnrollmentEmailTask(ModuleStoreTestCase): send_course_enrollment_email.apply_async( kwargs=self.send_course_enrollment_email_kwargs ) - mock_get_braze_client.return_value.send_canvas_message.assert_called_with( + mock_get_email_client.return_value.send_canvas_message.assert_called_with( canvas_id=BRAZE_COURSE_ENROLLMENT_CANVAS_ID, recipients=[ { @@ -243,14 +245,14 @@ class TestCourseEnrollmentEmailTask(ModuleStoreTestCase): @patch("common.djangoapps.student.tasks.get_course_uuid_for_course") @patch("common.djangoapps.student.tasks.get_owners_for_course") @patch("common.djangoapps.student.tasks.get_course_dates_for_email") - @patch("common.djangoapps.student.tasks.get_braze_client") + @patch("common.djangoapps.student.tasks.get_email_client") @patch( "common.djangoapps.student.tasks.get_course_run_details", Mock(side_effect=Exception), ) def test_canvas_properties_on_get_course_run_details_failure( self, - mock_get_braze_client, + mock_get_email_client, mock_get_course_dates_for_email, mock_get_owners_for_course, mock_get_course_uuid_for_course, @@ -266,7 +268,7 @@ class TestCourseEnrollmentEmailTask(ModuleStoreTestCase): send_course_enrollment_email.apply_async( kwargs=self.send_course_enrollment_email_kwargs ) - mock_get_braze_client.return_value.send_canvas_message.assert_called_with( + mock_get_email_client.return_value.send_canvas_message.assert_called_with( canvas_id=BRAZE_COURSE_ENROLLMENT_CANVAS_ID, recipients=[ { @@ -280,12 +282,12 @@ class TestCourseEnrollmentEmailTask(ModuleStoreTestCase): @patch("common.djangoapps.student.tasks.get_course_uuid_for_course") @patch("common.djangoapps.student.tasks.get_course_dates_for_email") - @patch("common.djangoapps.student.tasks.get_braze_client") + @patch("common.djangoapps.student.tasks.get_email_client") @patch(TASK_LOGGER) def test_email_task_when_course_uuid_is_missing( self, mocked_logger, - mock_get_braze_client, + mock_get_email_client, mock_get_course_dates_for_email, mock_get_course_uuid_for_course, ): @@ -304,7 +306,7 @@ class TestCourseEnrollmentEmailTask(ModuleStoreTestCase): f"[Course Enrollment] Course run call failed for " f"user: {self.user.id} course: {self.course.id} error: Missing course_uuid" ) - mock_get_braze_client.return_value.send_canvas_message.assert_called_with( + mock_get_email_client.return_value.send_canvas_message.assert_called_with( canvas_id=BRAZE_COURSE_ENROLLMENT_CANVAS_ID, recipients=[ { @@ -318,12 +320,12 @@ class TestCourseEnrollmentEmailTask(ModuleStoreTestCase): @patch("common.djangoapps.student.tasks.get_owners_for_course") @patch("common.djangoapps.student.tasks.get_course_run_details") @patch("common.djangoapps.student.tasks.get_course_dates_for_email") - @patch("common.djangoapps.student.tasks.get_braze_client") + @patch("common.djangoapps.student.tasks.get_email_client") @patch(TASK_LOGGER) def test_email_task_when_course_run_is_missing( self, mocked_logger, - mock_get_braze_client, + mock_get_email_client, mock_get_course_dates_for_email, mock_get_course_run_details, mock_get_owners_for_course, @@ -346,7 +348,7 @@ class TestCourseEnrollmentEmailTask(ModuleStoreTestCase): f"[Course Enrollment] Course run call failed for " f"user: {self.user.id} course: {self.course.id} error: Missing course_run" ) - mock_get_braze_client.return_value.send_canvas_message.assert_called_with( + mock_get_email_client.return_value.send_canvas_message.assert_called_with( canvas_id=BRAZE_COURSE_ENROLLMENT_CANVAS_ID, recipients=[ { @@ -360,7 +362,7 @@ class TestCourseEnrollmentEmailTask(ModuleStoreTestCase): @patch("common.djangoapps.student.tasks.get_owners_for_course") @patch("common.djangoapps.student.tasks.get_course_run_details") @patch("common.djangoapps.student.tasks.get_course_dates_for_email") - def test_retry_with_braze_client_exception( + def test_retry_with_email_client_exception( self, mock_get_course_dates_for_email, mock_get_course_run_details, @@ -377,12 +379,12 @@ class TestCourseEnrollmentEmailTask(ModuleStoreTestCase): mock_get_course_dates_for_email.return_value = self._get_course_dates() with patch( - 'common.djangoapps.student.tasks.get_braze_client', + 'common.djangoapps.student.tasks.get_email_client', new_callable=PropertyMock, side_effect=Exception('Braze Client Exception') - ) as mock_get_braze_client: + ) as mock_get_email_client: task = send_course_enrollment_email.apply_async( kwargs=self.send_course_enrollment_email_kwargs ) pytest.raises(Exception, task.get) - self.assertEqual(mock_get_braze_client.call_count, (MAX_RETRIES + 1)) + self.assertEqual(mock_get_email_client.call_count, (MAX_RETRIES + 1)) diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py index 16bab13b90..b63c522bbd 100644 --- a/common/djangoapps/student/tests/test_views.py +++ b/common/djangoapps/student/tests/test_views.py @@ -51,8 +51,8 @@ from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # l TOMORROW = now() + timedelta(days=1) ONE_WEEK_AGO = now() - timedelta(weeks=1) -THREE_YEARS_FROM_NOW = now() + timedelta(days=(365 * 3)) -THREE_YEARS_AGO = now() - timedelta(days=(365 * 3)) +THREE_YEARS_FROM_NOW = now() + timedelta(days=365 * 3) +THREE_YEARS_AGO = now() - timedelta(days=365 * 3) # Name of the method to mock for Content Type Gating. GATING_METHOD_NAME = 'openedx.features.content_type_gating.models.ContentTypeGatingConfig.enabled_for_enrollment' @@ -233,7 +233,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, """ UserProfile.objects.get(user=self.user).delete() response = self.client.get(self.path) - self.assertRedirects(response, reverse('account_settings')) + self.assertRedirects(response, settings.ACCOUNT_MICROFRONTEND_URL, target_status_code=302) @patch('common.djangoapps.student.views.dashboard.learner_home_mfe_enabled') def test_redirect_to_learner_home(self, mock_learner_home_mfe_enabled): diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index c640462acf..c2e6d3b2aa 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -47,6 +47,7 @@ from openedx.core.djangoapps.content.course_overviews.tests.factories import Cou from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms +from openedx.features.course_experience.url_helpers import make_learning_mfe_courseware_url from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls # lint-amnesty, pylint: disable=wrong-import-order from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order @@ -907,15 +908,15 @@ class ChangeEnrollmentViewTest(ModuleStoreTestCase): ) return response - @ddt.data( - (True, 'courseware'), - (False, None), - ) - @ddt.unpack - def test_enrollment_url(self, waffle_flag_enabled, returned_view): - with override_waffle_switch(REDIRECT_TO_COURSEWARE_AFTER_ENROLLMENT, waffle_flag_enabled): + def test_enrollment_url_without_redirect(self): + with override_waffle_switch(REDIRECT_TO_COURSEWARE_AFTER_ENROLLMENT, False): response = self._enroll_through_view(self.course) - data = reverse(returned_view, args=[str(self.course.id)]) if returned_view else '' + assert response.content.decode('utf8') == '' + + def test_enrollment_with_redirect(self): + with override_waffle_switch(REDIRECT_TO_COURSEWARE_AFTER_ENROLLMENT, True): + response = self._enroll_through_view(self.course) + data = make_learning_mfe_courseware_url(self.course.id) assert response.content.decode('utf8') == data def test_enroll_as_default(self): diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py index f729a2aee1..e507826920 100644 --- a/common/djangoapps/student/views/dashboard.py +++ b/common/djangoapps/student/views/dashboard.py @@ -22,6 +22,7 @@ from opaque_keys.edx.keys import CourseKey from openedx_filters.learning.filters import DashboardRenderStarted from pytz import UTC +from edx_django_utils.plugins import pluggable_override from lms.djangoapps.bulk_email.api import is_bulk_email_feature_enabled from lms.djangoapps.bulk_email.models import Optout from common.djangoapps.course_modes.models import CourseMode @@ -323,6 +324,14 @@ def reverification_info(statuses): return reverifications +@pluggable_override('OVERRIDE_GET_CREDIT_BUTTON_HREF') +def get_credit_button_href(course_key): + """ + Get the credit button URL for a course. + """ + return f"{settings.ECOMMERCE_PUBLIC_URL_ROOT}/credit/checkout/{course_key}/" + + def credit_statuses(user, course_enrollments): """ Retrieve the status for credit courses. @@ -423,6 +432,7 @@ def credit_statuses(user, course_enrollments): "provider_id": None, "request_status": request_status_by_course.get(course_key), "error": False, + "credit_btn_href": get_credit_button_href(str(course_key)), } # If the user has purchased credit, then include information about the credit @@ -518,7 +528,7 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem """ user = request.user if not UserProfile.objects.filter(user=user).exists(): - return redirect(reverse('account_settings')) + return redirect(settings.ACCOUNT_MICROFRONTEND_URL) if learner_home_mfe_enabled(): return redirect(settings.LEARNER_HOME_MICROFRONTEND_URL) @@ -623,7 +633,7 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem "Go to {link_start}your Account Settings{link_end}.") ).format( link_start=HTML("").format( - account_setting_page=reverse('account_settings'), + account_setting_page=settings.ACCOUNT_MICROFRONTEND_URL, ), link_end=HTML("") ) @@ -892,7 +902,7 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem except DashboardRenderStarted.RenderInvalidDashboard as exc: response = render_to_response(exc.dashboard_template, exc.template_context) except DashboardRenderStarted.RedirectToPage as exc: - response = HttpResponseRedirect(exc.redirect_to or reverse('account_settings')) + response = HttpResponseRedirect(exc.redirect_to or settings.ACCOUNT_MICROFRONTEND_URL) except DashboardRenderStarted.RenderCustomResponse as exc: response = exc.response else: diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index b06cac7b7e..4cf8fad8ae 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -62,6 +62,7 @@ from openedx.core.djangoapps.user_authn.toggles import ( ) from openedx.core.djangolib.markup import HTML, Text from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser +from openedx.features.course_experience.url_helpers import make_learning_mfe_courseware_url from openedx.features.discounts.applicability import FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG from openedx.features.enterprise_support.utils import is_enterprise_learner from common.djangoapps.student.email_helpers import generate_activation_email_context @@ -70,6 +71,7 @@ from common.djangoapps.student.message_types import AccountActivation, EmailChan from common.djangoapps.student.models import ( # lint-amnesty, pylint: disable=unused-import AccountRecovery, CourseEnrollment, + EnrollmentNotAllowed, PendingEmailChange, # unimport:skip PendingSecondaryEmailChange, Registration, @@ -407,7 +409,7 @@ def change_enrollment(request, check_access=True): return HttpResponse(redirect_url) if CourseEntitlement.check_for_existing_entitlement_and_enroll(user=user, course_run_key=course_id): - return HttpResponse(reverse('courseware', args=[str(course_id)])) + return HttpResponse(make_learning_mfe_courseware_url(course_id)) # Check that auto enrollment is allowed for this course # (= the course is NOT behind a paywall) @@ -422,6 +424,8 @@ def change_enrollment(request, check_access=True): enroll_mode = CourseMode.auto_enroll_mode(course_id, available_modes) if enroll_mode: CourseEnrollment.enroll(user, course_id, check_access=check_access, mode=enroll_mode) + except EnrollmentNotAllowed as exc: + return HttpResponseBadRequest(str(exc)) except Exception: # pylint: disable=broad-except return HttpResponseBadRequest(_("Could not enroll")) @@ -435,7 +439,7 @@ def change_enrollment(request, check_access=True): ) if should_redirect_to_courseware_after_enrollment(): - return HttpResponse(reverse('courseware', args=[str(course_id)])) + return HttpResponse(make_learning_mfe_courseware_url(course_id)) else: return HttpResponse() elif action == "unenroll": diff --git a/common/djangoapps/terrain/stubs/catalog.py b/common/djangoapps/terrain/stubs/catalog.py deleted file mode 100644 index 1767485028..0000000000 --- a/common/djangoapps/terrain/stubs/catalog.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -Stub implementation of catalog service for acceptance tests -""" -# pylint: disable=invalid-name - - -import re - -import six.moves.urllib.parse - -from .http import StubHttpRequestHandler, StubHttpService - - -class StubCatalogServiceHandler(StubHttpRequestHandler): # lint-amnesty, pylint: disable=missing-class-docstring - - def do_GET(self): # lint-amnesty, pylint: disable=missing-function-docstring - pattern_handlers = { - r'/api/v1/programs/$': self.program_list, - r'/api/v1/programs/([0-9a-f-]+)/$': self.program_detail, - r'/api/v1/program_types/$': self.program_types, - r'/api/v1/pathways/$': self.pathways - } - - if self.match_pattern(pattern_handlers): - return - - self.send_response(404, content='404 Not Found') - - def match_pattern(self, pattern_handlers): - """ - Find the correct handler method given the path info from the HTTP request. - """ - path = six.moves.urllib.parse.urlparse(self.path).path - for pattern, handler in pattern_handlers.items(): - match = re.match(pattern, path) - if match: - handler(*match.groups()) - return True - - def program_list(self): - """Stub the catalog's program list endpoint.""" - programs = self.server.config.get('catalog.programs', []) - self.send_json_response(programs) - - def program_detail(self, program_uuid): - """Stub the catalog's program detail endpoint.""" - program = self.server.config.get('catalog.programs.' + program_uuid) - self.send_json_response(program) - - def program_types(self): - program_types = self.server.config.get('catalog.programs_types', []) - self.send_json_response(program_types) - - def pathways(self): - pathways = self.server.config.get('catalog.pathways', []) - self.send_json_response(pathways) - - -class StubCatalogService(StubHttpService): - HANDLER_CLASS = StubCatalogServiceHandler diff --git a/common/djangoapps/terrain/stubs/comments.py b/common/djangoapps/terrain/stubs/comments.py deleted file mode 100644 index 8fef4b33e3..0000000000 --- a/common/djangoapps/terrain/stubs/comments.py +++ /dev/null @@ -1,145 +0,0 @@ -""" -Stub implementation of cs_comments_service for acceptance tests -""" - - -import re -from collections import OrderedDict - -import six.moves.urllib.parse - -from .http import StubHttpRequestHandler, StubHttpService - - -class StubCommentsServiceHandler(StubHttpRequestHandler): # lint-amnesty, pylint: disable=missing-class-docstring - - @property - def _params(self): - return six.moves.urllib.parse.parse_qs(six.moves.urllib.parse.urlparse(self.path).query) - - def do_GET(self): # lint-amnesty, pylint: disable=missing-function-docstring - pattern_handlers = OrderedDict([ - ("/api/v1/users/(?P\\d+)/active_threads$", self.do_user_profile), - ("/api/v1/users/(?P\\d+)$", self.do_user), - ("/api/v1/search/threads$", self.do_search_threads), - ("/api/v1/threads$", self.do_threads), - ("/api/v1/threads/(?P\\w+)$", self.do_thread), - ("/api/v1/comments/(?P\\w+)$", self.do_comment), - ("/api/v1/(?P\\w+)/threads$", self.do_commentable), - ]) - if self.match_pattern(pattern_handlers): - return - - self.send_response(404, content="404 Not Found") - - def match_pattern(self, pattern_handlers): # lint-amnesty, pylint: disable=missing-function-docstring - path = six.moves.urllib.parse.urlparse(self.path).path - for pattern in pattern_handlers: - match = re.match(pattern, path) - if match: - pattern_handlers[pattern](**match.groupdict()) - return True - return None - - def do_PUT(self): - if self.path.startswith('/set_config'): - return StubHttpRequestHandler.do_PUT(self) - pattern_handlers = { - "/api/v1/users/(?P\\d+)$": self.do_put_user, - } - if self.match_pattern(pattern_handlers): - return - self.send_response(204, "") - - def do_put_user(self, user_id): # lint-amnesty, pylint: disable=unused-argument - self.server.config['default_sort_key'] = self.post_dict.get("default_sort_key", "date") - self.send_json_response({'username': self.post_dict.get("username"), 'external_id': self.post_dict.get("external_id")}) # lint-amnesty, pylint: disable=line-too-long - - def do_DELETE(self): # lint-amnesty, pylint: disable=missing-function-docstring - pattern_handlers = { - "/api/v1/comments/(?P\\w+)$": self.do_delete_comment - } - if self.match_pattern(pattern_handlers): - return - self.send_json_response({}) - - def do_user(self, user_id): # lint-amnesty, pylint: disable=missing-function-docstring - response = { - "id": user_id, - "default_sort_key": self.server.config.get("default_sort_key", "date"), - "upvoted_ids": [], - "downvoted_ids": [], - "subscribed_thread_ids": [], - } - if 'course_id' in self._params: - response.update({ - "threads_count": 1, - "comments_count": 2 - }) - self.send_json_response(response) - - def do_user_profile(self, user_id): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument - if 'active_threads' in self.server.config: - user_threads = self.server.config['active_threads'][:] - params = self._params - page = int(params.get("page", ["1"])[0]) - per_page = int(params.get("per_page", ["20"])[0]) - num_pages = max(len(user_threads) - 1, 1) / per_page + 1 - user_threads = user_threads[(page - 1) * per_page:page * per_page] - self.send_json_response({ - "collection": user_threads, - "page": page, - "num_pages": num_pages - }) - else: - self.send_response(404, content="404 Not Found") - - def do_thread(self, thread_id): # lint-amnesty, pylint: disable=missing-function-docstring - if thread_id in self.server.config.get('threads', {}): - thread = self.server.config['threads'][thread_id].copy() - params = six.moves.urllib.parse.parse_qs(six.moves.urllib.parse.urlparse(self.path).query) - if "recursive" in params and params["recursive"][0] == "True": - thread.setdefault('children', []) - resp_total = thread.setdefault('resp_total', len(thread['children'])) # lint-amnesty, pylint: disable=unused-variable - resp_skip = int(params.get("resp_skip", ["0"])[0]) - resp_limit = int(params.get("resp_limit", ["10000"])[0]) - thread['children'] = thread['children'][resp_skip:(resp_skip + resp_limit)] - self.send_json_response(thread) - else: - self.send_response(404, content="404 Not Found") - - def do_threads(self): - threads = self.server.config.get('threads', {}) - threads_data = list(threads.values()) - self.send_json_response({"collection": threads_data, "page": 1, "num_pages": 1}) - - def do_search_threads(self): - self.send_json_response(self.server.config.get('search_result', {})) - - def do_comment(self, comment_id): - # django_comment_client calls GET comment before doing a DELETE, so that's what this is here to support. - if comment_id in self.server.config.get('comments', {}): - comment = self.server.config['comments'][comment_id] - self.send_json_response(comment) - - def do_delete_comment(self, comment_id): - """Handle comment deletion. Returns a JSON representation of the - deleted comment.""" - if comment_id in self.server.config.get('comments', {}): - comment = self.server.config['comments'][comment_id] - self.send_json_response(comment) - - def do_commentable(self, commentable_id): - self.send_json_response({ - "collection": [ - thread - for thread in self.server.config.get('threads', {}).values() - if thread.get('commentable_id') == commentable_id - ], - "page": 1, - "num_pages": 1, - }) - - -class StubCommentsService(StubHttpService): - HANDLER_CLASS = StubCommentsServiceHandler diff --git a/common/djangoapps/terrain/stubs/data/ora_graded_rubric.xml b/common/djangoapps/terrain/stubs/data/ora_graded_rubric.xml deleted file mode 100644 index 5db0138ebe..0000000000 --- a/common/djangoapps/terrain/stubs/data/ora_graded_rubric.xml +++ /dev/null @@ -1 +0,0 @@ -Writing Applications0 Language Conventions 1 diff --git a/common/djangoapps/terrain/stubs/data/ora_rubric.xml b/common/djangoapps/terrain/stubs/data/ora_rubric.xml deleted file mode 100644 index 14959de008..0000000000 --- a/common/djangoapps/terrain/stubs/data/ora_rubric.xml +++ /dev/null @@ -1 +0,0 @@ -Writing Applications Language Conventions diff --git a/common/djangoapps/terrain/stubs/ecommerce.py b/common/djangoapps/terrain/stubs/ecommerce.py deleted file mode 100644 index 96835ab0c1..0000000000 --- a/common/djangoapps/terrain/stubs/ecommerce.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -Stub implementation of ecommerce service for acceptance tests -""" - - -import re - -import six.moves.urllib.parse - -from .http import StubHttpRequestHandler, StubHttpService - - -class StubEcommerceServiceHandler(StubHttpRequestHandler): # pylint: disable=missing-class-docstring - - # pylint: disable=missing-function-docstring - def do_GET(self): - pattern_handlers = { - '/api/v2/orders/$': self.get_orders_list, - } - if self.match_pattern(pattern_handlers): - return - self.send_response(404, content='404 Not Found') - - def match_pattern(self, pattern_handlers): - """ - Find the correct handler method given the path info from the HTTP request. - """ - path = six.moves.urllib.parse.urlparse(self.path).path - for pattern in pattern_handlers: - match = re.match(pattern, path) - if match: - pattern_handlers[pattern](**match.groupdict()) - return True - return None - - def get_orders_list(self): - """ - Stubs the orders list endpoint. - """ - orders = { - 'results': [ - { - 'status': 'Complete', - 'number': 'Edx-123', - 'total_excl_tax': '100.00', - 'date_placed': '2016-04-21T23:14:23Z', - 'lines': [ - { - 'title': 'Test Course', - 'line_price_excl_tax': '100.00', - 'product': { - 'product_class': 'Seat' - } - } - ], - } - ] - } - orders = self.server.config.get('orders', orders) - self.send_json_response(orders) - - -class StubEcommerceService(StubHttpService): - HANDLER_CLASS = StubEcommerceServiceHandler diff --git a/common/djangoapps/terrain/stubs/edxnotes.py b/common/djangoapps/terrain/stubs/edxnotes.py deleted file mode 100644 index a147825c25..0000000000 --- a/common/djangoapps/terrain/stubs/edxnotes.py +++ /dev/null @@ -1,395 +0,0 @@ -""" -Stub implementation of EdxNotes for acceptance tests -""" - - -import json -import re -from copy import deepcopy -from datetime import datetime -from math import ceil -from uuid import uuid4 - -from urllib.parse import urlencode - -from .http import StubHttpRequestHandler, StubHttpService - - -class StubEdxNotesServiceHandler(StubHttpRequestHandler): - """ - Handler for EdxNotes requests. - """ - URL_HANDLERS = { - "GET": { - "/api/v1/annotations$": "_collection", - "/api/v1/annotations/(?P[0-9A-Fa-f]+)$": "_read", - "/api/v1/search$": "_search", - }, - "POST": { - "/api/v1/annotations$": "_create", - "/create_notes": "_create_notes", - }, - "PUT": { - "/api/v1/annotations/(?P[0-9A-Fa-f]+)$": "_update", - "/cleanup$": "_cleanup", - }, - "DELETE": { - "/api/v1/annotations/(?P[0-9A-Fa-f]+)$": "_delete", - }, - } - - def _match_pattern(self, pattern_handlers): - """ - Finds handler by the provided handler patterns and delegate response to - the matched handler. - """ - for pattern in pattern_handlers: - match = re.match(pattern, self.path_only) - if match: - handler = getattr(self, pattern_handlers[pattern], None) - if handler: - handler(**match.groupdict()) - return True - return None - - def _send_handler_response(self, method): - """ - Delegate response to handler methods. - If no handler defined, send a 404 response. - """ - # Choose the list of handlers based on the HTTP method - if method in self.URL_HANDLERS: - handlers_list = self.URL_HANDLERS[method] - else: - self.log_error(f"Unrecognized method '{method}'") - return - - # Check the path (without querystring params) against our list of handlers - if self._match_pattern(handlers_list): - return - # If we don't have a handler for this URL and/or HTTP method, - # respond with a 404. - else: - self.send_response(404, content="404 Not Found") - - def do_GET(self): - """ - Handle GET methods to the EdxNotes API stub. - """ - self._send_handler_response("GET") - - def do_POST(self): - """ - Handle POST methods to the EdxNotes API stub. - """ - self._send_handler_response("POST") - - def do_PUT(self): - """ - Handle PUT methods to the EdxNotes API stub. - """ - if self.path.startswith("/set_config"): - return StubHttpRequestHandler.do_PUT(self) - - self._send_handler_response("PUT") - - def do_DELETE(self): - """ - Handle DELETE methods to the EdxNotes API stub. - """ - self._send_handler_response("DELETE") - - def do_OPTIONS(self): - """ - Handle OPTIONS methods to the EdxNotes API stub. - """ - self.send_response(200, headers={ - "Access-Control-Allow-Origin": "*", - "Access-Control-Allow-Methods": "GET, POST, PUT, DELETE, OPTIONS", - "Access-Control-Allow-Headers": "Content-Length, Content-Type, X-Annotator-Auth-Token, X-Requested-With, X-Annotator-Auth-Token, X-Requested-With, X-CSRFToken", # lint-amnesty, pylint: disable=line-too-long - }) - - def respond(self, status_code=200, content=None): - """ - Send a response back to the client with the HTTP `status_code` (int), - the given content serialized as JSON (str), and the headers set appropriately. - """ - headers = { - "Access-Control-Allow-Origin": "*", - } - if status_code < 400 and content: - headers["Content-Type"] = "application/json" - content = json.dumps(content).encode('utf-8') - else: - headers["Content-Type"] = "text/html" - - self.send_response(status_code, content, headers) - - def _create(self): - """ - Create a note, assign id, annotator_schema_version, created and updated dates. - """ - note = json.loads(self.request_content.decode('utf-8')) - note.update({ - "id": uuid4().hex, - "annotator_schema_version": "v1.0", - "created": datetime.utcnow().isoformat(), - "updated": datetime.utcnow().isoformat(), - }) - self.server.add_notes(note) - self.respond(content=note) - - def _create_notes(self): - """ - The same as self._create, but it works a list of notes. - """ - try: - notes = json.loads(self.request_content.decode('utf-8')) - except ValueError: - self.respond(400, "Bad Request") - return - - if not isinstance(notes, list): - self.respond(400, "Bad Request") - return - - for note in notes: - note.update({ - "id": uuid4().hex, - "annotator_schema_version": "v1.0", - "created": note["created"] if note.get("created") else datetime.utcnow().isoformat(), - "updated": note["updated"] if note.get("updated") else datetime.utcnow().isoformat(), - }) - self.server.add_notes(note) - - self.respond(content=notes) - - def _read(self, note_id): - """ - Return the note by note id. - """ - notes = self.server.get_all_notes() - result = self.server.filter_by_id(notes, note_id) - if result: - self.respond(content=result[0]) - else: - self.respond(404, "404 Not Found") - - def _update(self, note_id): - """ - Update the note by note id. - """ - note = self.server.update_note(note_id, json.loads(self.request_content.decode('utf-8'))) - if note: - self.respond(content=note) - else: - self.respond(404, "404 Not Found") - - def _delete(self, note_id): - """ - Delete the note by note id. - """ - if self.server.delete_note(note_id): - self.respond(204, "No Content") - else: - self.respond(404, "404 Not Found") - - @staticmethod - def _get_next_prev_url(url_path, query_params, page_num, page_size): - """ - makes url with the query params including pagination params - for pagination next and previous urls - """ - query_params = deepcopy(query_params) - query_params.update({ - "page": page_num, - "page_size": page_size - }) - return url_path + "?" + urlencode(query_params) - - def _get_paginated_response(self, notes, page_num, page_size): - """ - Returns a paginated response of notes. - """ - start = (page_num - 1) * page_size - end = start + page_size - total_notes = len(notes) - url_path = "http://{server_address}:{port}{path}".format( - server_address=self.client_address[0], - port=self.server.port, - path=self.path_only - ) - - next_url = None if end >= total_notes else self._get_next_prev_url( - url_path, self.get_params, page_num + 1, page_size - ) - prev_url = None if page_num == 1 else self._get_next_prev_url( - url_path, self.get_params, page_num - 1, page_size) - - # Get notes from range - notes = deepcopy(notes[start:end]) - - paginated_response = { - 'total': total_notes, - 'num_pages': int(ceil(float(total_notes) / page_size)), - 'current_page': page_num, - 'rows': notes, - 'next': next_url, - 'start': start, - 'previous': prev_url - } - - return paginated_response - - def _search(self): - """ - Search for a notes by user id, course_id and usage_id. - """ - search_with_usage_id = False - user = self.get_params.get("user", None) - usage_ids = self.get_params.get("usage_id", []) - course_id = self.get_params.get("course_id", None) - text = self.get_params.get("text", None) - page = int(self.get_params.get("page", 1)) - page_size = int(self.get_params.get("page_size", 2)) - - if user is None: - self.respond(400, "Bad Request") - return - - notes = self.server.get_all_notes() - if course_id is not None: - notes = self.server.filter_by_course_id(notes, course_id) - if len(usage_ids) > 0: - search_with_usage_id = True - notes = self.server.filter_by_usage_id(notes, usage_ids) - if text: - notes = self.server.search(notes, text) - if not search_with_usage_id: - notes = self._get_paginated_response(notes, page, page_size) - self.respond(content=notes) - - def _collection(self): - """ - Return all notes for the user. - """ - user = self.get_params.get("user", None) - page = int(self.get_params.get("page", 1)) - page_size = int(self.get_params.get("page_size", 2)) - notes = self.server.get_all_notes() - - if user is None: - self.send_response(400, content="Bad Request") - return - notes = self._get_paginated_response(notes, page, page_size) - self.respond(content=notes) - - def _cleanup(self): - """ - Helper method that removes all notes to the stub EdxNotes service. - """ - self.server.cleanup() - self.respond() - - -class StubEdxNotesService(StubHttpService): - """ - Stub EdxNotes service. - """ - HANDLER_CLASS = StubEdxNotesServiceHandler - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.notes = [] - - def get_all_notes(self): - """ - Returns a list of all notes without pagination - """ - notes = deepcopy(self.notes) - notes.reverse() - return notes - - def add_notes(self, notes): - """ - Adds `notes(list)` to the stub EdxNotes service. - """ - if not isinstance(notes, list): - notes = [notes] - - for note in notes: - self.notes.append(note) - - def update_note(self, note_id, note_info): - """ - Updates the note with `note_id(str)` by the `note_info(dict)` to the - stub EdxNotes service. - """ - note = self.filter_by_id(self.notes, note_id) - if note: - note[0].update(note_info) - return note - else: - return None - - def delete_note(self, note_id): - """ - Removes the note with `note_id(str)` to the stub EdxNotes service. - """ - note = self.filter_by_id(self.notes, note_id) - if note: - index = self.notes.index(note[0]) - self.notes.pop(index) - return True - else: - return False - - def cleanup(self): - """ - Removes all notes to the stub EdxNotes service. - """ - self.notes = [] - - def filter_by_id(self, data, note_id): - """ - Filters provided `data(list)` by the `note_id(str)`. - """ - return self.filter_by(data, "id", note_id) - - def filter_by_user(self, data, user): - """ - Filters provided `data(list)` by the `user(str)`. - """ - return self.filter_by(data, "user", user) - - def filter_by_usage_id(self, data, usage_ids): - """ - Filters provided `data(list)` by the `usage_id(str)`. - """ - if not isinstance(usage_ids, list): - usage_ids = [usage_ids] - return self.filter_by_list(data, "usage_id", usage_ids) - - def filter_by_course_id(self, data, course_id): - """ - Filters provided `data(list)` by the `course_id(str)`. - """ - return self.filter_by(data, "course_id", course_id) - - def filter_by(self, data, field_name, value): - """ - Filters provided `data(list)` by the `field_name(str)` with `value`. - """ - return [note for note in data if note.get(field_name) == value] - - def filter_by_list(self, data, field_name, values): - """ - Filters provided `data(list)` by the `field_name(str)` in values. - """ - return [note for note in data if note.get(field_name) in values] - - def search(self, data, query): - """ - Search the `query(str)` text in the provided `data(list)`. - """ - return [note for note in data if str(query).strip() in note.get("text", "").split()] diff --git a/common/djangoapps/terrain/stubs/http.py b/common/djangoapps/terrain/stubs/http.py deleted file mode 100644 index 9dae75e215..0000000000 --- a/common/djangoapps/terrain/stubs/http.py +++ /dev/null @@ -1,281 +0,0 @@ -""" -Stub implementation of an HTTP service. -""" - - -import json -import threading -from functools import wraps -from logging import getLogger - -import six -from lazy import lazy -from six.moves.BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer -from six.moves.socketserver import ThreadingMixIn - -LOGGER = getLogger(__name__) - - -def require_params(method, *required_keys): - """ - Decorator to ensure that the method has all the required parameters. - - Example: - - @require_params('GET', 'id', 'state') - def handle_request(self): - # .... - - would send a 400 response if no GET parameters were specified - for 'id' or 'state' (or if those parameters had empty values). - - The wrapped function should be a method of a `StubHttpRequestHandler` - subclass. - - Currently, "GET" and "POST" are the only supported methods. - """ - def decorator(func): - @wraps(func) - def wrapper(self, *args, **kwargs): - - # Read either GET querystring params or POST dict params - if method == "GET": - params = self.get_params - elif method == "POST": - params = self.post_dict - else: - raise ValueError(f"Unsupported method '{method}'") - - # Check for required values - missing = [] - for key in required_keys: - if params.get(key) is None: - missing.append(key) - - if len(missing) > 0: - msg = "Missing required key(s) {keys}".format(keys=",".join(missing)) - self.send_response(400, content=msg, headers={'Content-type': 'text/plain'}) - - # If nothing is missing, execute the function as usual - else: - return func(self, *args, **kwargs) - return wrapper - return decorator - - -class StubHttpRequestHandler(BaseHTTPRequestHandler): - """ - Handler for the stub HTTP service. - """ - - protocol = "HTTP/1.0" - - def log_message(self, format_str, *args): - """ - Redirect messages to keep the test console clean. - """ - LOGGER.debug(self._format_msg(format_str, *args)) - - def log_error(self, format_str, *args): - """ - Helper to log a server error. - """ - LOGGER.error(self._format_msg(format_str, *args)) - - @lazy - def request_content(self): - """ - Retrieve the content of the request. - """ - try: - length = int(self.headers.get('content-length')) - - except (TypeError, ValueError): - return "" - else: - return self.rfile.read(length) - - @lazy - def post_dict(self): - """ - Retrieve the request POST parameters from the client as a dictionary. - If no POST parameters can be interpreted, return an empty dict. - """ - - if isinstance(self.request_content, bytes): - contents = self.request_content.decode('utf-8') - else: - contents = self.request_content - - # The POST dict will contain a list of values for each key. - # None of our parameters are lists, however, so we map [val] --> val - # If the list contains multiple entries, we pick the first one - try: - post_dict = six.moves.urllib.parse.parse_qs(contents, keep_blank_values=True) - return { - key: list_val[0] - for key, list_val in post_dict.items() - } - - except: # lint-amnesty, pylint: disable=bare-except - return {} - - @lazy - def get_params(self): - """ - Return the GET parameters (querystring in the URL). - """ - query = six.moves.urllib.parse.urlparse(self.path).query - - # By default, `parse_qs` returns a list of values for each param - # For convenience, we replace lists of 1 element with just the element - return { - key: value[0] if len(value) == 1 else value - for key, value in six.moves.urllib.parse.parse_qs(query).items() - } - - @lazy - def path_only(self): - """ - Return the URL path without GET parameters. - Removes the trailing slash if there is one. - """ - path = six.moves.urllib.parse.urlparse(self.path).path - if path.endswith('/'): - return path[:-1] - else: - return path - - def do_PUT(self): - """ - Allow callers to configure the stub server using the /set_config URL. - The request should have POST data, such that: - - Each POST parameter is the configuration key. - Each POST value is a JSON-encoded string value for the configuration. - """ - if self.path in ("/set_config", "/set_config/"): - - if len(self.post_dict) > 0: - for key, value in self.post_dict.items(): - - self.log_message(f"Set config '{key}' to '{value}'") - - try: - value = json.loads(value) - - except ValueError: - self.log_message(f"Could not parse JSON: {value}") - self.send_response(400) - - else: - self.server.config[key] = value - self.send_response(200) - - # No parameters sent to configure, so return success by default - else: - self.send_response(200) - - else: - self.send_response(404) - - def send_response(self, status_code, content=None, headers=None): - """ - Send a response back to the client with the HTTP `status_code` (int), - `content` (str) and `headers` (dict). - """ - self.log_message( - f"Sent HTTP response: {status_code} with content '{content}' and headers {headers}" - ) - - if headers is None: - headers = { - 'Access-Control-Allow-Origin': "*", - } - - BaseHTTPRequestHandler.send_response(self, status_code) - - for (key, value) in headers.items(): - self.send_header(key, value) - - if len(headers) > 0: - self.end_headers() - - if content is not None: - if isinstance(content, str): - content = content.encode('utf-8') - self.wfile.write(content) - - def send_json_response(self, content): - """ - Send a response with status code 200, the given content serialized as - JSON, and the Content-Type header set appropriately - """ - self.send_response(200, json.dumps(content), {"Content-Type": "application/json"}) - - def _format_msg(self, format_str, *args): - """ - Format message for logging. - `format_str` is a string with old-style Python format escaping; - `args` is an array of values to fill into the string. - """ - if not args: - format_str = six.moves.urllib.parse.unquote(format_str) - return "{} - - [{}] {}\n".format( - self.client_address[0], - self.log_date_time_string(), - format_str % args - ) - - def do_HEAD(self): - """ - Respond to an HTTP HEAD request - """ - self.send_response(200) - - -class StubHttpService(ThreadingMixIn, HTTPServer): - """ - Stub HTTP service implementation. - """ - - # Subclasses override this to provide the handler class to use. - # Should be a subclass of `StubHttpRequestHandler` - HANDLER_CLASS = StubHttpRequestHandler - - def __init__(self, port_num=0): - """ - Configure the server to listen on localhost. - Default is to choose an arbitrary open port. - """ - address = ('0.0.0.0', port_num) - HTTPServer.__init__(self, address, self.HANDLER_CLASS) - - # Create a dict to store configuration values set by the client - self.config = {} - - # Start the server in a separate thread - server_thread = threading.Thread(target=self.serve_forever) - server_thread.daemon = True - server_thread.start() - - # Log the port we're using to help identify port conflict errors - LOGGER.debug(f'Starting service on port {self.port}') - - def shutdown(self): - """ - Stop the server and free up the port - """ - # First call superclass shutdown() - HTTPServer.shutdown(self) - - # We also need to manually close the socket - self.socket.close() - - @property - def port(self): - """ - Return the port that the service is listening on. - """ - _, port = self.server_address - return port diff --git a/common/djangoapps/terrain/stubs/lti.py b/common/djangoapps/terrain/stubs/lti.py deleted file mode 100644 index 46535abb9f..0000000000 --- a/common/djangoapps/terrain/stubs/lti.py +++ /dev/null @@ -1,317 +0,0 @@ -""" -Stub implementation of LTI Provider. - -What is supported: ------------------- - -1.) This LTI Provider can service only one Tool Consumer at the same time. It is -not possible to have this LTI multiple times on a single page in LMS. - -""" - - -import base64 -import hashlib -import logging -import textwrap -from unittest import mock -from uuid import uuid4 - -import oauthlib.oauth1 -import requests -import six -from oauthlib.oauth1.rfc5849 import parameters, signature - -from openedx.core.djangolib.markup import HTML - -from .http import StubHttpRequestHandler, StubHttpService - -log = logging.getLogger(__name__) - - -class StubLtiHandler(StubHttpRequestHandler): - """ - A handler for LTI POST and GET requests. - """ - DEFAULT_CLIENT_KEY = 'test_client_key' - DEFAULT_CLIENT_SECRET = 'test_client_secret' - DEFAULT_LTI_ENDPOINT = 'correct_lti_endpoint' - DEFAULT_LTI_ADDRESS = 'http://{host}:{port}/' - - def do_GET(self): - """ - Handle a GET request from the client and sends response back. - - Used for checking LTI Provider started correctly. - """ - self.send_response(200, 'This is LTI Provider.', {'Content-type': 'text/plain'}) - - def do_POST(self): - """ - Handle a POST request from the client and sends response back. - """ - if 'grade' in self.path and self._send_graded_result().status_code == 200: - status_message = HTML('LTI consumer (edX) responded with XML content:
    {grade_data}').format( - grade_data=self.server.grade_data['TC answer'] - ) - content = self._create_content(status_message) - self.send_response(200, content) - elif 'lti2_outcome' in self.path and self._send_lti2_outcome().status_code == 200: - status_message = HTML('LTI consumer (edX) responded with HTTP {}
    ').format( - self.server.grade_data['status_code']) - content = self._create_content(status_message) - self.send_response(200, content) - elif 'lti2_delete' in self.path and self._send_lti2_delete().status_code == 200: - status_message = HTML('LTI consumer (edX) responded with HTTP {}
    ').format( - self.server.grade_data['status_code']) - content = self._create_content(status_message) - self.send_response(200, content) - # Respond to request with correct lti endpoint - elif self._is_correct_lti_request(): - params = {k: v for k, v in self.post_dict.items() if k != 'oauth_signature'} - if self._check_oauth_signature(params, self.post_dict.get('oauth_signature', "")): - status_message = "This is LTI tool. Success." - # Set data for grades what need to be stored as server data - if 'lis_outcome_service_url' in self.post_dict: - self.server.grade_data = { - 'callback_url': self.post_dict.get('lis_outcome_service_url').replace('https', 'http'), - 'sourcedId': self.post_dict.get('lis_result_sourcedid') - } - host = self.server.server_address[0] - submit_url = f'//{host}:{self.server.server_address[1]}' - content = self._create_content(status_message, submit_url) - self.send_response(200, content) - else: - content = self._create_content("Wrong LTI signature") - self.send_response(200, content) - else: - content = self._create_content("Invalid request URL") - self.send_response(500, content) - - def _send_graded_result(self): - """ - Send grade request. - """ - values = { - 'textString': 0.5, - 'sourcedId': self.server.grade_data['sourcedId'], - 'imsx_messageIdentifier': uuid4().hex, - } - payload = textwrap.dedent(""" - - - - - V1.0 - {imsx_messageIdentifier} / - - - - - - - {sourcedId} - - - - en-us - {textString} - - - - - - - """) - - data = payload.format(**values) - url = self.server.grade_data['callback_url'] - headers = { - 'Content-Type': 'application/xml', - 'X-Requested-With': 'XMLHttpRequest', - 'Authorization': self._oauth_sign(url, data) - } - - # Send request ignoring verifirecation of SSL certificate - response = requests.post(url, data=data, headers=headers, verify=False) - - self.server.grade_data['TC answer'] = response.content - return response - - def _send_lti2_outcome(self): - """ - Send a grade back to consumer - """ - payload = textwrap.dedent(""" - {{ - "@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result", - "@type" : "Result", - "resultScore" : {score}, - "comment" : "This is awesome." - }} - """) - data = payload.format(score=0.8) - return self._send_lti2(data) - - def _send_lti2_delete(self): - """ - Send a delete back to consumer - """ - payload = textwrap.dedent(""" - { - "@context" : "http://purl.imsglobal.org/ctx/lis/v2/Result", - "@type" : "Result" - } - """) - return self._send_lti2(payload) - - def _send_lti2(self, payload): - """ - Send lti2 json result service request. - """ - ### We compute the LTI V2.0 service endpoint from the callback_url (which is set by the launch call) - url = self.server.grade_data['callback_url'] - url_parts = url.split('/') - url_parts[-1] = "lti_2_0_result_rest_handler" - anon_id = self.server.grade_data['sourcedId'].split(":")[-1] - url_parts.extend(["user", anon_id]) - new_url = '/'.join(url_parts) - - content_type = 'application/vnd.ims.lis.v2.result+json' - headers = { - 'Content-Type': content_type, - 'Authorization': self._oauth_sign(new_url, payload, - method='PUT', - content_type=content_type) - } - - # Send request ignoring verifirecation of SSL certificate - response = requests.put(new_url, data=payload, headers=headers, verify=False) - self.server.grade_data['status_code'] = response.status_code - self.server.grade_data['TC answer'] = response.content - return response - - def _create_content(self, response_text, submit_url=None): - """ - Return content (str) either for launch, send grade or get result from TC. - """ - if submit_url: - submit_form = textwrap.dedent(HTML(""" -
    - -
    -
    - -
    -
    - -
    - """)).format(submit_url=submit_url) - else: - submit_form = '' - - # Show roles only for LTI launch. - if self.post_dict.get('roles'): - role = HTML('
    Role: {}
    ').format(self.post_dict['roles']) - else: - role = '' - - response_str = textwrap.dedent(HTML(""" - - - TEST TITLE - - -
    -

    IFrame loaded

    -

    Server response is:

    -

    {response}

    - {role} -
    - {submit_form} - - - """)).format(response=response_text, role=role, submit_form=submit_form) - - # Currently LTI block doublequotes the lis_result_sourcedid parameter. - # Unquote response two times. - return six.moves.urllib.parse.unquote(six.moves.urllib.parse.unquote(response_str)) - - def _is_correct_lti_request(self): - """ - Return a boolean indicating whether the URL path is a valid LTI end-point. - """ - lti_endpoint = self.server.config.get('lti_endpoint', self.DEFAULT_LTI_ENDPOINT) - return lti_endpoint in self.path - - def _oauth_sign(self, url, body, content_type='application/x-www-form-urlencoded', method='POST'): - """ - Signs request and returns signed Authorization header. - """ - client_key = self.server.config.get('client_key', self.DEFAULT_CLIENT_KEY) - client_secret = self.server.config.get('client_secret', self.DEFAULT_CLIENT_SECRET) - client = oauthlib.oauth1.Client( - client_key=str(client_key), - client_secret=str(client_secret) - ) - headers = { - # This is needed for body encoding: - 'Content-Type': content_type, - } - - # Calculate and encode body hash. See http://oauth.googlecode.com/svn/spec/ext/body_hash/1.0/oauth-bodyhash.html - sha1 = hashlib.sha1() - sha1.update(body.encode('utf-8')) - oauth_body_hash = base64.b64encode(sha1.digest()).decode('utf-8') - mock_request = mock.Mock( - uri=str(six.moves.urllib.parse.unquote(url)), - headers=headers, - body="", - decoded_body="", - http_method=str(method), - ) - params = client.get_oauth_params(mock_request) - mock_request.oauth_params = params - mock_request.oauth_params.append(('oauth_body_hash', oauth_body_hash)) - sig = client.get_oauth_signature(mock_request) - mock_request.oauth_params.append(('oauth_signature', sig)) - new_headers = parameters.prepare_headers(mock_request.oauth_params, headers, realm=None) - return new_headers['Authorization'] - - def _check_oauth_signature(self, params, client_signature): - """ - Checks oauth signature from client. - - `params` are params from post request except signature, - `client_signature` is signature from request. - - Builds mocked request and verifies hmac-sha1 signing:: - 1. builds string to sign from `params`, `url` and `http_method`. - 2. signs it with `client_secret` which comes from server settings. - 3. obtains signature after sign and then compares it with request.signature - (request signature comes form client in request) - - Returns `True` if signatures are correct, otherwise `False`. - - """ - client_secret = str(self.server.config.get('client_secret', self.DEFAULT_CLIENT_SECRET)) - host = '127.0.0.1' - port = self.server.server_address[1] - lti_base = self.DEFAULT_LTI_ADDRESS.format(host=host, port=port) - lti_endpoint = self.server.config.get('lti_endpoint', self.DEFAULT_LTI_ENDPOINT) - url = lti_base + lti_endpoint - request = mock.Mock() - request.params = [(str(k), str(v)) for k, v in params.items()] - request.uri = str(url) - request.http_method = 'POST' - request.signature = str(client_signature) - return signature.verify_hmac_sha1(request, client_secret) - - -class StubLtiService(StubHttpService): - """ - A stub LTI provider server that responds - to POST and GET requests to localhost. - """ - - HANDLER_CLASS = StubLtiHandler diff --git a/common/djangoapps/terrain/stubs/start.py b/common/djangoapps/terrain/stubs/start.py deleted file mode 100644 index 976fb058f9..0000000000 --- a/common/djangoapps/terrain/stubs/start.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -Command-line utility to start a stub service. -""" - - -import logging -import sys -import time - -from .catalog import StubCatalogService -from .comments import StubCommentsService -from .ecommerce import StubEcommerceService -from .edxnotes import StubEdxNotesService -from .lti import StubLtiService -from .video_source import VideoSourceHttpService -from .xqueue import StubXQueueService -from .youtube import StubYouTubeService - -USAGE = "USAGE: python -m stubs.start SERVICE_NAME PORT_NUM [CONFIG_KEY=CONFIG_VAL, ...]" - -SERVICES = { - 'xqueue': StubXQueueService, - 'youtube': StubYouTubeService, - 'comments': StubCommentsService, - 'lti': StubLtiService, - 'video': VideoSourceHttpService, - 'edxnotes': StubEdxNotesService, - 'ecommerce': StubEcommerceService, - 'catalog': StubCatalogService, -} - -# Log to stdout, including debug messages -logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(message)s") - - -def get_args(): - """ - Parse arguments, returning tuple of `(service_name, port_num, config_dict)`. - Exits with a message if arguments are invalid. - """ - if len(sys.argv) < 3: - print(USAGE) - sys.exit(1) - - service_name = sys.argv[1] - port_num = sys.argv[2] - config_dict = _parse_config_args(sys.argv[3:]) - - if service_name not in SERVICES: - print("Unrecognized service '{}'. Valid choices are: {}".format( - service_name, ", ".join(list(SERVICES.keys())))) - sys.exit(1) - - try: - port_num = int(port_num) - if port_num < 0: - raise ValueError - - except ValueError: - print(f"Port '{port_num}' must be a positive integer") - sys.exit(1) - - return service_name, port_num, config_dict - - -def _parse_config_args(args): - """ - Parse stub configuration arguments, which are strings of the form "KEY=VAL". - `args` is a list of arguments from the command line. - Any argument that does not match the "KEY=VAL" format will be logged and skipped. - - Returns a dictionary with the configuration keys and values. - """ - config_dict = {} - for config_str in args: - try: - components = config_str.split('=') - if len(components) >= 2: - config_dict[components[0]] = "=".join(components[1:]) - - except: # lint-amnesty, pylint: disable=bare-except - print(f"Warning: could not interpret config value '{config_str}'") - - return config_dict - - -def main(): - """ - Start a server; shut down on keyboard interrupt signal. - """ - service_name, port_num, config_dict = get_args() - print(f"Starting stub service '{service_name}' on port {port_num}...") - - server = SERVICES[service_name](port_num=port_num) - server.config.update(config_dict) - - try: - while True: - time.sleep(1) - - except KeyboardInterrupt: - print("Stopping stub service...") - - finally: - server.shutdown() - - -if __name__ == "__main__": - main() diff --git a/common/djangoapps/terrain/stubs/tests/test_edxnotes.py b/common/djangoapps/terrain/stubs/tests/test_edxnotes.py deleted file mode 100644 index 5d22d6c551..0000000000 --- a/common/djangoapps/terrain/stubs/tests/test_edxnotes.py +++ /dev/null @@ -1,372 +0,0 @@ -""" -Unit tests for stub EdxNotes implementation. -""" - - -import json -import unittest -from uuid import uuid4 - -import ddt -import requests -import six - -from ..edxnotes import StubEdxNotesService - - -@ddt.ddt -class StubEdxNotesServiceTest(unittest.TestCase): - """ - Test cases for the stub EdxNotes service. - """ - def setUp(self): - """ - Start the stub server. - """ - super().setUp() - self.server = StubEdxNotesService() - dummy_notes = self._get_dummy_notes(count=5) - self.server.add_notes(dummy_notes) - self.addCleanup(self.server.shutdown) - - def _get_dummy_notes(self, count=1): - """ - Returns a list of dummy notes. - """ - return [self._get_dummy_note(i) for i in range(count)] - - def _get_dummy_note(self, uid=0): - """ - Returns a single dummy note. - """ - nid = uuid4().hex - return { - "id": nid, - "created": "2014-10-31T10:05:00.000000", - "updated": "2014-10-31T10:50:00.101010", - "user": "dummy-user-id", - "usage_id": "dummy-usage-id-" + str(uid), - "course_id": "dummy-course-id", - "text": "dummy note text " + nid, - "quote": "dummy note quote", - "ranges": [ - { - "start": "/p[1]", - "end": "/p[1]", - "startOffset": 0, - "endOffset": 10, - } - ], - } - - def test_note_create(self): - dummy_note = { - "user": "dummy-user-id", - "usage_id": "dummy-usage-id", - "course_id": "dummy-course-id", - "text": "dummy note text", - "quote": "dummy note quote", - "ranges": [ - { - "start": "/p[1]", - "end": "/p[1]", - "startOffset": 0, - "endOffset": 10, - } - ], - } - response = requests.post(self._get_url("api/v1/annotations"), data=json.dumps(dummy_note)) - assert response.ok - response_content = response.json() - assert 'id' in response_content - assert 'created' in response_content - assert 'updated' in response_content - assert 'annotator_schema_version' in response_content - self.assertDictContainsSubset(dummy_note, response_content) - - def test_note_read(self): - notes = self._get_notes() - for note in notes: - response = requests.get(self._get_url("api/v1/annotations/" + note["id"])) - assert response.ok - self.assertDictEqual(note, response.json()) - - response = requests.get(self._get_url("api/v1/annotations/does_not_exist")) - assert response.status_code == 404 - - def test_note_update(self): - notes = self._get_notes() - for note in notes: - response = requests.get(self._get_url("api/v1/annotations/" + note["id"])) - assert response.ok - self.assertDictEqual(note, response.json()) - - response = requests.get(self._get_url("api/v1/annotations/does_not_exist")) - assert response.status_code == 404 - - def test_search(self): - # Without user - response = requests.get(self._get_url("api/v1/search")) - assert response.status_code == 400 - - # get response with default page and page size - response = requests.get(self._get_url("api/v1/search"), params={ - "user": "dummy-user-id", - "course_id": "dummy-course-id", - }) - - assert response.ok - self._verify_pagination_info( - response=response.json(), - total_notes=5, - num_pages=3, - notes_per_page=2, - start=0, - current_page=1, - next_page=2, - previous_page=None - ) - - # search notes with text that don't exist - response = requests.get(self._get_url("api/v1/search"), params={ - "user": "dummy-user-id", - "course_id": "dummy-course-id", - "text": "world war 2" - }) - - assert response.ok - self._verify_pagination_info( - response=response.json(), - total_notes=0, - num_pages=0, - notes_per_page=0, - start=0, - current_page=1, - next_page=None, - previous_page=None - ) - - @ddt.data( - '?usage_id=dummy-usage-id-0', - '?usage_id=dummy-usage-id-0&usage_id=dummy-usage-id-1&dummy-usage-id-2&dummy-usage-id-3&dummy-usage-id-4' - ) - def test_search_usage_ids(self, usage_ids): - """ - Test search with usage ids. - """ - url = self._get_url('api/v1/search') + usage_ids - response = requests.get(url, params={ - 'user': 'dummy-user-id', - 'course_id': 'dummy-course-id' - }) - assert response.ok - response = response.json() - parsed = six.moves.urllib.parse.urlparse(url) - query_params = six.moves.urllib.parse.parse_qs(parsed.query) - query_params['usage_id'].reverse() - assert len(response) == len(query_params['usage_id']) - for index, usage_id in enumerate(query_params['usage_id']): - assert response[index]['usage_id'] == usage_id - - def test_delete(self): - notes = self._get_notes() - response = requests.delete(self._get_url("api/v1/annotations/does_not_exist")) - assert response.status_code == 404 - - for note in notes: - response = requests.delete(self._get_url("api/v1/annotations/" + note["id"])) - assert response.status_code == 204 - remaining_notes = self.server.get_all_notes() - assert note['id'] not in [note['id'] for note in remaining_notes] - - assert len(remaining_notes) == 0 - - def test_update(self): - note = self._get_notes()[0] - response = requests.put(self._get_url("api/v1/annotations/" + note["id"]), data=json.dumps({ - "text": "new test text" - })) - assert response.status_code == 200 - - updated_note = self._get_notes()[0] - assert 'new test text' == updated_note['text'] - assert note['id'] == updated_note['id'] - self.assertCountEqual(note, updated_note) - - response = requests.get(self._get_url("api/v1/annotations/does_not_exist")) - assert response.status_code == 404 - - # pylint: disable=too-many-arguments - def _verify_pagination_info( - self, - response, - total_notes, - num_pages, - notes_per_page, - current_page, - previous_page, - next_page, - start - ): - """ - Verify the pagination information. - - Argument: - response: response from api - total_notes: total notes in the response - num_pages: total number of pages in response - notes_per_page: number of notes in the response - current_page: current page number - previous_page: previous page number - next_page: next page number - start: start of the current page - """ - def get_page_value(url): - """ - Return page value extracted from url. - """ - if url is None: - return None - - parsed = six.moves.urllib.parse.urlparse(url) - query_params = six.moves.urllib.parse.parse_qs(parsed.query) - - page = query_params["page"][0] - return page if page is None else int(page) - - assert response['total'] == total_notes - assert response['num_pages'] == num_pages - assert len(response['rows']) == notes_per_page - assert response['current_page'] == current_page - assert get_page_value(response['previous']) == previous_page - assert get_page_value(response['next']) == next_page - assert response['start'] == start - - def test_notes_collection(self): - """ - Test paginated response of notes api - """ - - # Without user - response = requests.get(self._get_url("api/v1/annotations")) - assert response.status_code == 400 - - # Without any pagination parameters - response = requests.get(self._get_url("api/v1/annotations"), params={"user": "dummy-user-id"}) - - assert response.ok - self._verify_pagination_info( - response=response.json(), - total_notes=5, - num_pages=3, - notes_per_page=2, - start=0, - current_page=1, - next_page=2, - previous_page=None - ) - - # With pagination parameters - response = requests.get(self._get_url("api/v1/annotations"), params={ - "user": "dummy-user-id", - "page": 2, - "page_size": 3 - }) - - assert response.ok - self._verify_pagination_info( - response=response.json(), - total_notes=5, - num_pages=2, - notes_per_page=2, - start=3, - current_page=2, - next_page=None, - previous_page=1 - ) - - def test_notes_collection_next_previous_with_one_page(self): - """ - Test next and previous urls of paginated response of notes api - when number of pages are 1 - """ - response = requests.get(self._get_url("api/v1/annotations"), params={ - "user": "dummy-user-id", - "page_size": 10 - }) - - assert response.ok - self._verify_pagination_info( - response=response.json(), - total_notes=5, - num_pages=1, - notes_per_page=5, - start=0, - current_page=1, - next_page=None, - previous_page=None - ) - - def test_notes_collection_when_no_notes(self): - """ - Test paginated response of notes api when there's no note present - """ - - # Delete all notes - self.test_cleanup() - - # Get default page - response = requests.get(self._get_url("api/v1/annotations"), params={"user": "dummy-user-id"}) - assert response.ok - self._verify_pagination_info( - response=response.json(), - total_notes=0, - num_pages=0, - notes_per_page=0, - start=0, - current_page=1, - next_page=None, - previous_page=None - ) - - def test_cleanup(self): - response = requests.put(self._get_url("cleanup")) - assert response.ok - assert len(self.server.get_all_notes()) == 0 - - def test_create_notes(self): - dummy_notes = self._get_dummy_notes(count=2) - response = requests.post(self._get_url("create_notes"), data=json.dumps(dummy_notes)) - assert response.ok - assert len(self._get_notes()) == 7 - - response = requests.post(self._get_url("create_notes")) - assert response.status_code == 400 - - def test_headers(self): - note = self._get_notes()[0] - response = requests.get(self._get_url("api/v1/annotations/" + note["id"])) - assert response.ok - assert response.headers.get('access-control-allow-origin') == '*' - - response = requests.options(self._get_url("api/v1/annotations/")) - assert response.ok - assert response.headers.get('access-control-allow-origin') == '*' - assert response.headers.get('access-control-allow-methods') == 'GET, POST, PUT, DELETE, OPTIONS' - assert 'X-CSRFToken' in response.headers.get('access-control-allow-headers') - - def _get_notes(self): - """ - Return a list of notes from the stub EdxNotes service. - """ - notes = self.server.get_all_notes() - assert len(notes) > 0, 'Notes are empty.' - return notes - - def _get_url(self, path): - """ - Construt a URL to the stub EdxNotes service. - """ - return "http://127.0.0.1:{port}/{path}/".format( - port=self.server.port, path=path - ) diff --git a/common/djangoapps/terrain/stubs/tests/test_http.py b/common/djangoapps/terrain/stubs/tests/test_http.py deleted file mode 100644 index f9f5125c1f..0000000000 --- a/common/djangoapps/terrain/stubs/tests/test_http.py +++ /dev/null @@ -1,124 +0,0 @@ -""" -Unit tests for stub HTTP server base class. -""" - - -import json -import unittest - -import requests - -from common.djangoapps.terrain.stubs.http import StubHttpRequestHandler, StubHttpService, require_params - - -class StubHttpServiceTest(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - def setUp(self): - super().setUp() - self.server = StubHttpService() - self.addCleanup(self.server.shutdown) - self.url = f"http://127.0.0.1:{self.server.port}/set_config" - - def test_configure(self): - """ - All HTTP stub servers have an end-point that allows - clients to configure how the server responds. - """ - params = { - 'test_str': 'This is only a test', - 'test_empty': '', - 'test_int': 12345, - 'test_float': 123.45, - 'test_dict': { - 'test_key': 'test_val', - }, - 'test_empty_dict': {}, - 'test_unicode': '\u2603 the snowman', - 'test_none': None, - 'test_boolean': False - } - - for key, val in params.items(): - - # JSON-encode each parameter - post_params = {key: json.dumps(val)} - response = requests.put(self.url, data=post_params) - assert response.status_code == 200 - - # Check that the expected values were set in the configuration - for key, val in params.items(): - assert self.server.config.get(key) == val - - def test_bad_json(self): - response = requests.put(self.url, data="{,}") - assert response.status_code == 400 - - def test_no_post_data(self): - response = requests.put(self.url, data={}) - assert response.status_code == 200 - - def test_unicode_non_json(self): - # Send unicode without json-encoding it - response = requests.put(self.url, data={'test_unicode': '\u2603 the snowman'}) - assert response.status_code == 400 - - def test_unknown_path(self): - response = requests.put( - f"http://127.0.0.1:{self.server.port}/invalid_url", - data="{}" - ) - assert response.status_code == 404 - - -class RequireRequestHandler(StubHttpRequestHandler): # lint-amnesty, pylint: disable=missing-class-docstring - @require_params('GET', 'test_param') - def do_GET(self): - self.send_response(200) - - @require_params('POST', 'test_param') - def do_POST(self): - self.send_response(200) - - -class RequireHttpService(StubHttpService): - HANDLER_CLASS = RequireRequestHandler - - -class RequireParamTest(unittest.TestCase): - """ - Test the decorator for requiring parameters. - """ - - def setUp(self): - super().setUp() - self.server = RequireHttpService() - self.addCleanup(self.server.shutdown) - self.url = f"http://127.0.0.1:{self.server.port}" - - def test_require_get_param(self): - - # Expect success when we provide the required param - response = requests.get(self.url, params={"test_param": 2}) - assert response.status_code == 200 - - # Expect failure when we do not proivde the param - response = requests.get(self.url) - assert response.status_code == 400 - - # Expect failure when we provide an empty param - response = requests.get(self.url + "?test_param=") - assert response.status_code == 400 - - def test_require_post_param(self): - - # Expect success when we provide the required param - response = requests.post(self.url, data={"test_param": 2}) - assert response.status_code == 200 - - # Expect failure when we do not proivde the param - response = requests.post(self.url) - assert response.status_code == 400 - - # Expect failure when we provide an empty param - response = requests.post(self.url, data={"test_param": None}) - assert response.status_code == 400 diff --git a/common/djangoapps/terrain/stubs/tests/test_lti_stub.py b/common/djangoapps/terrain/stubs/tests/test_lti_stub.py deleted file mode 100644 index 1d04f47cd9..0000000000 --- a/common/djangoapps/terrain/stubs/tests/test_lti_stub.py +++ /dev/null @@ -1,98 +0,0 @@ -""" -Unit tests for stub LTI implementation. -""" - - -import unittest -from unittest.mock import Mock, patch - -import requests -from urllib.request import urlopen # pylint: disable=wrong-import-order - -from common.djangoapps.terrain.stubs.lti import StubLtiService - - -class StubLtiServiceTest(unittest.TestCase): - """ - A stub of the LTI provider that listens on a local - port and responds with pre-defined grade messages. - - Used for lettuce BDD tests in lms/courseware/features/lti.feature - """ - def setUp(self): - super().setUp() - self.server = StubLtiService() - self.uri = f'http://127.0.0.1:{self.server.port}/' - self.launch_uri = self.uri + 'correct_lti_endpoint' - self.addCleanup(self.server.shutdown) - self.payload = { - 'user_id': 'default_user_id', - 'roles': 'Student', - 'oauth_nonce': '', - 'oauth_timestamp': '', - 'oauth_consumer_key': 'test_client_key', - 'lti_version': 'LTI-1p0', - 'oauth_signature_method': 'HMAC-SHA1', - 'oauth_version': '1.0', - 'oauth_signature': '', - 'lti_message_type': 'basic-lti-launch-request', - 'oauth_callback': 'about:blank', - 'launch_presentation_return_url': '', - 'lis_outcome_service_url': 'http://localhost:8001/test_callback', - 'lis_result_sourcedid': '', - 'resource_link_id': '', - } - - def test_invalid_request_url(self): - """ - Tests that LTI server processes request with right program path but with wrong header. - """ - self.launch_uri = self.uri + 'wrong_lti_endpoint' - response = requests.post(self.launch_uri, data=self.payload) - assert b'Invalid request URL' in response.content - - def test_wrong_signature(self): - """ - Tests that LTI server processes request with right program - path and responses with incorrect signature. - """ - response = requests.post(self.launch_uri, data=self.payload) - assert b'Wrong LTI signature' in response.content - - @patch('common.djangoapps.terrain.stubs.lti.signature.verify_hmac_sha1', return_value=True) - def test_success_response_launch_lti(self, check_oauth): # lint-amnesty, pylint: disable=unused-argument - """ - Success lti launch. - """ - response = requests.post(self.launch_uri, data=self.payload) - assert b'This is LTI tool. Success.' in response.content - - @patch('common.djangoapps.terrain.stubs.lti.signature.verify_hmac_sha1', return_value=True) - def test_send_graded_result(self, verify_hmac): # pylint: disable=unused-argument - response = requests.post(self.launch_uri, data=self.payload) - assert b'This is LTI tool. Success.' in response.content - grade_uri = self.uri + 'grade' - with patch('common.djangoapps.terrain.stubs.lti.requests.post') as mocked_post: - mocked_post.return_value = Mock(content='Test response', status_code=200) - response = urlopen(grade_uri, data=b'') # lint-amnesty, pylint: disable=consider-using-with - assert b'Test response' in response.read() - - @patch('common.djangoapps.terrain.stubs.lti.signature.verify_hmac_sha1', return_value=True) - def test_lti20_outcomes_put(self, verify_hmac): # pylint: disable=unused-argument - response = requests.post(self.launch_uri, data=self.payload) - assert b'This is LTI tool. Success.' in response.content - grade_uri = self.uri + 'lti2_outcome' - with patch('common.djangoapps.terrain.stubs.lti.requests.put') as mocked_put: - mocked_put.return_value = Mock(status_code=200) - response = urlopen(grade_uri, data=b'') # lint-amnesty, pylint: disable=consider-using-with - assert b'LTI consumer (edX) responded with HTTP 200' in response.read() - - @patch('common.djangoapps.terrain.stubs.lti.signature.verify_hmac_sha1', return_value=True) - def test_lti20_outcomes_put_like_delete(self, verify_hmac): # pylint: disable=unused-argument - response = requests.post(self.launch_uri, data=self.payload) - assert b'This is LTI tool. Success.' in response.content - grade_uri = self.uri + 'lti2_delete' - with patch('common.djangoapps.terrain.stubs.lti.requests.put') as mocked_put: - mocked_put.return_value = Mock(status_code=200) - response = urlopen(grade_uri, data=b'') # lint-amnesty, pylint: disable=consider-using-with - assert b'LTI consumer (edX) responded with HTTP 200' in response.read() diff --git a/common/djangoapps/terrain/stubs/tests/test_video.py b/common/djangoapps/terrain/stubs/tests/test_video.py deleted file mode 100644 index 66332bdf0e..0000000000 --- a/common/djangoapps/terrain/stubs/tests/test_video.py +++ /dev/null @@ -1,48 +0,0 @@ -""" -Unit tests for Video stub server implementation. -""" - - -import unittest - -import requests -from django.conf import settings - -from common.djangoapps.terrain.stubs.video_source import VideoSourceHttpService - -HLS_MANIFEST_TEXT = """ -#EXTM3U -#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=264787,RESOLUTION=1280x720 -history_264kbit/history_264kbit.m3u8 -#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=328415,RESOLUTION=1920x1080 -history_328kbit/history_328kbit.m3u8 -#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=70750,RESOLUTION=640x360 -history_70kbit/history_70kbit.m3u8 -#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=148269,RESOLUTION=960x540 -history_148kbit/history_148kbit.m3u8 -#EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=41276,RESOLUTION=640x360 -history_41kbit/history_41kbit.m3u8 -""" - - -class StubVideoServiceTest(unittest.TestCase): - """ - Test cases for the video stub service. - """ - def setUp(self): - """ - Start the stub server. - """ - super().setUp() - self.server = VideoSourceHttpService() - self.server.config['root_dir'] = f'{settings.TEST_ROOT}/data/video' - self.addCleanup(self.server.shutdown) - - def test_get_hls_manifest(self): - """ - Verify that correct hls manifest is received. - """ - response = requests.get(f"http://127.0.0.1:{self.server.port}/hls/history.m3u8") - assert response.ok - assert response.text == HLS_MANIFEST_TEXT.lstrip() - assert response.headers['Access-Control-Allow-Origin'] == '*' diff --git a/common/djangoapps/terrain/stubs/tests/test_xqueue_stub.py b/common/djangoapps/terrain/stubs/tests/test_xqueue_stub.py deleted file mode 100644 index 5a4576817e..0000000000 --- a/common/djangoapps/terrain/stubs/tests/test_xqueue_stub.py +++ /dev/null @@ -1,173 +0,0 @@ -""" -Unit tests for stub XQueue implementation. -""" - - -import ast -import json -import unittest -from unittest import mock - -import requests - -from ..xqueue import StubXQueueService - - -class FakeTimer: - """ - Fake timer implementation that executes immediately. - """ - def __init__(self, delay, func): # lint-amnesty, pylint: disable=unused-argument - self.func = func - - def start(self): - self.func() - - -class StubXQueueServiceTest(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - def setUp(self): - super().setUp() - self.server = StubXQueueService() - self.url = f"http://127.0.0.1:{self.server.port}/xqueue/submit" - self.addCleanup(self.server.shutdown) - - # Patch the timer async calls - patcher = mock.patch('common.djangoapps.terrain.stubs.xqueue.post') - self.post = patcher.start() - self.addCleanup(patcher.stop) - - # Patch POST requests - patcher = mock.patch('common.djangoapps.terrain.stubs.xqueue.Timer') - timer = patcher.start() - timer.side_effect = FakeTimer - self.addCleanup(patcher.stop) - - def test_grade_request(self): - - # Post a submission to the stub XQueue - callback_url = 'http://127.0.0.1:8000/test_callback' - expected_header = self._post_submission( - callback_url, 'test_queuekey', 'test_queue', - json.dumps({ - 'student_info': 'test', - 'grader_payload': 'test', - 'student_response': 'test' - }) - ) - - # Check the response we receive - # (Should be the default grading response) - expected_body = json.dumps({'correct': True, 'score': 1, 'msg': '
    '}) - self._check_grade_response(callback_url, expected_header, expected_body) - - def test_configure_default_response(self): - - # Configure the default response for submissions to any queue - response_content = {'test_response': 'test_content'} - self.server.config['default'] = response_content - - # Post a submission to the stub XQueue - callback_url = 'http://127.0.0.1:8000/test_callback' - expected_header = self._post_submission( - callback_url, 'test_queuekey', 'test_queue', - json.dumps({ - 'student_info': 'test', - 'grader_payload': 'test', - 'student_response': 'test' - }) - ) - - # Check the response we receive - # (Should be the default grading response) - self._check_grade_response(callback_url, expected_header, json.dumps(response_content)) - - def test_configure_specific_response(self): - - # Configure the XQueue stub response to any submission to the test queue - response_content = {'test_response': 'test_content'} - self.server.config['This is only a test.'] = response_content - - # Post a submission to the XQueue stub - callback_url = 'http://127.0.0.1:8000/test_callback' - expected_header = self._post_submission( - callback_url, 'test_queuekey', 'test_queue', - json.dumps({'submission': 'This is only a test.'}) - ) - - # Check that we receive the response we configured - self._check_grade_response(callback_url, expected_header, json.dumps(response_content)) - - def test_multiple_response_matches(self): - - # Configure the XQueue stub with two responses that - # match the same submission - self.server.config['test_1'] = {'response': True} - self.server.config['test_2'] = {'response': False} - - with mock.patch('common.djangoapps.terrain.stubs.http.LOGGER') as logger: - - # Post a submission to the XQueue stub - callback_url = 'http://127.0.0.1:8000/test_callback' - self._post_submission( - callback_url, 'test_queuekey', 'test_queue', - json.dumps({'submission': 'test_1 and test_2'}) - ) - - # Expect that we do NOT receive a response - # and that an error message is logged - assert not self.post.called - assert logger.error.called - - def _post_submission(self, callback_url, lms_key, queue_name, xqueue_body): # lint-amnesty, pylint: disable=unused-argument - """ - Post a submission to the stub XQueue implementation. - `callback_url` is the URL at which we expect to receive a grade response - `lms_key` is the authentication key sent in the header - `queue_name` is the name of the queue in which to send put the submission - `xqueue_body` is the content of the submission - - Returns the header (a string) we send with the submission, which can - be used to validate the response we receive from the stub. - """ - - # Post a submission to the XQueue stub - grade_request = { - 'xqueue_header': json.dumps({ - 'lms_callback_url': callback_url, - 'lms_key': 'test_queuekey', - 'queue_name': 'test_queue' - }), - 'xqueue_body': xqueue_body - } - - resp = requests.post(self.url, data=grade_request) - - # Expect that the response is success - assert resp.status_code == 200 - - # Return back the header, so we can authenticate the response we receive - return grade_request['xqueue_header'] - - def _check_grade_response(self, callback_url, expected_header, expected_body): - """ - Verify that the stub sent a POST request back to us - with the expected data. - - `callback_url` is the URL we expect the stub to POST to - `expected_header` is the header (a string) we expect to receive with the grade. - `expected_body` is the content (a string) we expect to receive with the grade. - - Raises an `AssertionError` if the check fails. - """ - # Check the response posted back to us - # This is the default response - expected_callback_dict = { - 'xqueue_header': expected_header, - 'xqueue_body': expected_body, - } - # Check that the POST request was made with the correct params - assert self.post.call_args[1]['data']['xqueue_body'] == expected_callback_dict['xqueue_body'] - assert ast.literal_eval(self.post.call_args[1]['data']['xqueue_header']) ==\ - ast.literal_eval(expected_callback_dict['xqueue_header']) - assert self.post.call_args[0][0] == callback_url diff --git a/common/djangoapps/terrain/stubs/tests/test_youtube_stub.py b/common/djangoapps/terrain/stubs/tests/test_youtube_stub.py deleted file mode 100644 index 74c613dfe2..0000000000 --- a/common/djangoapps/terrain/stubs/tests/test_youtube_stub.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -Unit test for stub YouTube implementation. -""" - - -import unittest - -import requests - -from ..youtube import StubYouTubeService - - -class StubYouTubeServiceTest(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - def setUp(self): - super().setUp() - self.server = StubYouTubeService() - self.url = f"http://127.0.0.1:{self.server.port}/" - self.server.config['time_to_response'] = 0.0 - self.addCleanup(self.server.shutdown) - - def test_unused_url(self): - response = requests.get(self.url + 'unused_url') - assert b'Unused url' == response.content - - @unittest.skip('Failing intermittently due to inconsistent responses from YT. See TE-871') - def test_video_url(self): - response = requests.get( - self.url + 'test_youtube/OEoXaMPEzfM?v=2&alt=jsonc&callback=callback_func' - ) - - # YouTube metadata for video `OEoXaMPEzfM` states that duration is 116. - assert b'callback_func({"data": {"duration": 116, "message": "I\'m youtube.", "id": "OEoXaMPEzfM"}})' ==\ - response.content - - def test_transcript_url_equal(self): - response = requests.get( - self.url + 'test_transcripts_youtube/t__eq_exist' - ) - - assert ''.join(['', - '', - 'Equal transcripts']).encode('utf-8') == response.content - - def test_transcript_url_not_equal(self): - response = requests.get( - self.url + 'test_transcripts_youtube/t_neq_exist', - ) - - assert ''.join(['', - '', - 'Transcripts sample, different that on server', - '']).encode('utf-8') == response.content - - def test_transcript_not_found(self): - response = requests.get(self.url + 'test_transcripts_youtube/some_id') - assert 404 == response.status_code - - def test_reset_configuration(self): - - reset_config_url = self.url + 'del_config' - - # add some configuration data - self.server.config['test_reset'] = 'This is a reset config test' - - # reset server configuration - response = requests.delete(reset_config_url) - assert response.status_code == 200 - - # ensure that server config dict is empty after successful reset - assert not self.server.config diff --git a/common/djangoapps/terrain/stubs/video_source.py b/common/djangoapps/terrain/stubs/video_source.py deleted file mode 100644 index ffff090a62..0000000000 --- a/common/djangoapps/terrain/stubs/video_source.py +++ /dev/null @@ -1,60 +0,0 @@ -""" -Serve HTML5 video sources for acceptance tests -""" - - -import os -from contextlib import contextmanager -from logging import getLogger - -from six.moves.SimpleHTTPServer import SimpleHTTPRequestHandler - -from .http import StubHttpService - -LOGGER = getLogger(__name__) - - -class VideoSourceRequestHandler(SimpleHTTPRequestHandler): - """ - Request handler for serving video sources locally. - """ - def translate_path(self, path): - """ - Remove any extra parameters from the path. - For example /gizmo.mp4?1397160769634 - becomes /gizmo.mp4 - """ - root_dir = self.server.config.get('root_dir') - path = f'{root_dir}{path}' - return path.split('?')[0] - - def end_headers(self): - """ - This is required by hls.js to play hls videos. - """ - self.send_header('Access-Control-Allow-Origin', '*') - SimpleHTTPRequestHandler.end_headers(self) - - -class VideoSourceHttpService(StubHttpService): - """ - Simple HTTP server for serving HTML5 Video sources locally for tests - """ - HANDLER_CLASS = VideoSourceRequestHandler - - def __init__(self, port_num=0): - - @contextmanager - def _remember_cwd(): - """ - Files are automatically served from the current directory - so we need to change it, start the server, then set it back. - """ - curdir = os.getcwd() - try: - yield - finally: - os.chdir(curdir) - - with _remember_cwd(): - StubHttpService.__init__(self, port_num=port_num) diff --git a/common/djangoapps/terrain/stubs/xqueue.py b/common/djangoapps/terrain/stubs/xqueue.py deleted file mode 100644 index bb6b7ac69e..0000000000 --- a/common/djangoapps/terrain/stubs/xqueue.py +++ /dev/null @@ -1,226 +0,0 @@ -""" -Stub implementation of XQueue for acceptance tests. - -Configuration values: - "default" (dict): Default response to be sent to LMS as a grade for a submission - "" (dict): Grade response to return for submissions containing the text - "register_submission_url" (str): URL to send grader payloads when we receive a submission - -If no grade response is configured, a default response will be returned. -""" - - -import copy -import json -from threading import Timer - -from requests import post - -from openedx.core.djangolib.markup import HTML - -from .http import StubHttpRequestHandler, StubHttpService, require_params - - -class StubXQueueHandler(StubHttpRequestHandler): - """ - A handler for XQueue POST requests. - """ - - DEFAULT_RESPONSE_DELAY = 2 - DEFAULT_GRADE_RESPONSE = {'correct': True, 'score': 1, 'msg': ''} - - @require_params('POST', 'xqueue_body', 'xqueue_header') - def do_POST(self): - """ - Handle a POST request from the client - - Sends back an immediate success/failure response. - It then POSTS back to the client with grading results. - """ - msg = f"XQueue received POST request {self.post_dict} to path {self.path}" - self.log_message(msg) - - # Respond only to grading requests - if self._is_grade_request(): - - # If configured, send the grader payload to other services. - # TODO TNL-3906 - # self._register_submission(self.post_dict['xqueue_body']) - - try: - xqueue_header = json.loads(self.post_dict['xqueue_header']) - callback_url = xqueue_header['lms_callback_url'] - - except KeyError: - # If the message doesn't have a header or body, - # then it's malformed. Respond with failure - error_msg = "XQueue received invalid grade request" - self._send_immediate_response(False, message=error_msg) - - except ValueError: - # If we could not decode the body or header, - # respond with failure - error_msg = "XQueue could not decode grade request" - self._send_immediate_response(False, message=error_msg) - - else: - # Send an immediate response of success - # The grade request is formed correctly - self._send_immediate_response(True) - - # Wait a bit before POSTing back to the callback url with the - # grade result configured by the server - # Otherwise, the problem will not realize it's - # queued and it will keep waiting for a response indefinitely - delayed_grade_func = lambda: self._send_grade_response( - callback_url, xqueue_header, self.post_dict['xqueue_body'] - ) - - delay = self.server.config.get('response_delay', self.DEFAULT_RESPONSE_DELAY) - Timer(delay, delayed_grade_func).start() - - # If we get a request that's not to the grading submission - # URL, return an error - else: - self._send_immediate_response(False, message="Invalid request URL") - - def _send_immediate_response(self, success, message=""): - """ - Send an immediate success/failure message - back to the client - """ - - # Send the response indicating success/failure - response_str = json.dumps( - {'return_code': 0 if success else 1, 'content': message} - ) - - if self._is_grade_request(): - self.send_response( - 200, content=response_str, headers={'Content-type': 'text/plain'} - ) - self.log_message(f"XQueue: sent response {response_str}") - - else: - self.send_response(500) - - def _send_grade_response(self, postback_url, xqueue_header, xqueue_body_json): - """ - POST the grade response back to the client - using the response provided by the server configuration. - - Uses the server configuration to determine what response to send: - 1) Specific response for submissions containing matching text in `xqueue_body` - 2) Default submission configured by client - 3) Default submission - - `postback_url` is the URL the client told us to post back to - `xqueue_header` (dict) is the full header the client sent us, which we will send back - to the client so it can authenticate us. - `xqueue_body_json` (json-encoded string) is the body of the submission the client sent us. - """ - # First check if we have a configured response that matches the submission body - grade_response = None - - # This matches the pattern against the JSON-encoded xqueue_body - # This is very simplistic, but sufficient to associate a student response - # with a grading response. - # There is a danger here that a submission will match multiple response patterns. - # Rather than fail silently (which could cause unpredictable behavior in tests) - # we abort and log a debugging message. - for pattern, response in self.server.queue_responses: - - if pattern in xqueue_body_json: - if grade_response is None: - grade_response = response - - # Multiple matches, so abort and log an error - else: - self.log_error( - f"Multiple response patterns matched '{xqueue_body_json}'", - ) - return - - # Fall back to the default grade response configured for this queue, - # then to the default response. - if grade_response is None: - grade_response = self.server.config.get( - 'default', copy.deepcopy(self.DEFAULT_GRADE_RESPONSE) - ) - - # Wrap the message in
    tags to ensure that it is valid XML - if isinstance(grade_response, dict) and 'msg' in grade_response: - grade_response['msg'] = HTML("
    {0}
    ").format(grade_response['msg']) - - data = { - 'xqueue_header': json.dumps(xqueue_header), - 'xqueue_body': json.dumps(grade_response) - } - - post(postback_url, data=data) - self.log_message(f"XQueue: sent grading response {data} to {postback_url}") - - def _register_submission(self, xqueue_body_json): - """ - If configured, send the submission's grader payload to another service. - """ - url = self.server.config.get('register_submission_url') - - # If not configured, do not need to send anything - if url is not None: - - try: - xqueue_body = json.loads(xqueue_body_json) - except ValueError: - self.log_error( - f"Could not decode XQueue body as JSON: '{xqueue_body_json}'") - - else: - - # Retrieve the grader payload, which should be a JSON-encoded dict. - # We pass the payload directly to the service we are notifying, without - # inspecting the contents. - grader_payload = xqueue_body.get('grader_payload') - - if grader_payload is not None: - response = post(url, data={'grader_payload': grader_payload}) - if not response.ok: - self.log_error( - "Could register submission at URL '{}'. Status was {}".format( - url, response.status_code)) - - else: - self.log_message( - f"XQueue body is missing 'grader_payload' key: '{xqueue_body}'" - ) - - def _is_grade_request(self): - """ - Return a boolean indicating whether the requested URL indicates a submission. - """ - return 'xqueue/submit' in self.path - - -class StubXQueueService(StubHttpService): - """ - A stub XQueue grading server that responds to POST requests to localhost. - """ - - HANDLER_CLASS = StubXQueueHandler - NON_QUEUE_CONFIG_KEYS = ['default', 'register_submission_url'] - - @property - def queue_responses(self): - """ - Returns a list of (pattern, response) tuples, where `pattern` is a pattern - to match in the XQueue body, and `response` is a dictionary to return - as the response from the grader. - - Every configuration key is a queue name, - except for 'default' and 'register_submission_url' which have special meaning - """ - return list({ - key: value - for key, value in self.config.items() - if key not in self.NON_QUEUE_CONFIG_KEYS - }.items()) diff --git a/common/djangoapps/terrain/stubs/youtube.py b/common/djangoapps/terrain/stubs/youtube.py deleted file mode 100644 index 67cac950f9..0000000000 --- a/common/djangoapps/terrain/stubs/youtube.py +++ /dev/null @@ -1,172 +0,0 @@ -""" -Stub implementation of YouTube for acceptance tests. - - -To start this stub server on its own from Vagrant: - -1.) Locally, modify your Vagrantfile so that it contains: - - config.vm.network :forwarded_port, guest: 8031, host: 8031 - -2.) From within Vagrant dev environment do: - - cd common/djangoapps/terrain - python -m stubs.start youtube 8031 - -3.) Locally, try accessing http://localhost:8031/ and see that - you get "Unused url" message inside the browser. -""" - - -import json -import time -from collections import OrderedDict - -import requests -from six.moves.urllib.parse import urlparse - -from .http import StubHttpRequestHandler, StubHttpService - - -class StubYouTubeHandler(StubHttpRequestHandler): - """ - A handler for Youtube GET requests. - """ - - # Default number of seconds to delay the response to simulate network latency. - DEFAULT_DELAY_SEC = 0.5 - - def do_DELETE(self): # pylint: disable=invalid-name - """ - Allow callers to delete all the server configurations using the /del_config URL. - """ - if self.path in ("/del_config", "/del_config/"): - self.server.config = {} - self.log_message("Reset Server Configuration.") - self.send_response(200) - else: - self.send_response(404) - - def do_GET(self): - """ - Handle a GET request from the client and sends response back. - """ - self.log_message( - f"Youtube provider received GET request to path {self.path}" - ) - - if 'get_config' in self.path: - self.send_json_response(self.server.config) - - elif 'test_transcripts_youtube' in self.path: - - if 't__eq_exist' in self.path: - status_message = "".join([ - '', - '', - 'Equal transcripts' - ]).encode('utf-8') - - self.send_response( - 200, content=status_message, headers={'Content-type': 'application/xml'} - ) - - elif 't_neq_exist' in self.path: - status_message = "".join([ - '', - '', - 'Transcripts sample, different that on server', - '' - ]).encode('utf-8') - - self.send_response( - 200, content=status_message, headers={'Content-type': 'application/xml'} - ) - - else: - self.send_response(404) - - elif 'test_youtube' in self.path: - params = urlparse(self.path) - youtube_id = params.path.split('/').pop() - - if self.server.config.get('youtube_api_private_video'): - self._send_private_video_response(youtube_id, "I'm youtube private video.") # lint-amnesty, pylint: disable=too-many-function-args - else: - self._send_video_response(youtube_id, "I'm youtube.") - - elif 'get_youtube_api' in self.path: - # Delay the response to simulate network latency - time.sleep(self.server.config.get('time_to_response', self.DEFAULT_DELAY_SEC)) - if self.server.config.get('youtube_api_blocked'): - self.send_response(404, content=b'', headers={'Content-type': 'text/plain'}) - else: - # Get the response to send from YouTube. - # We need to do this every time because Google sometimes sends different responses - # as part of their own experiments, which has caused our tests to become "flaky" - self.log_message("Getting iframe api from youtube.com") - iframe_api_response = requests.get('https://www.youtube.com/iframe_api').content.strip(b"\n") - self.send_response(200, content=iframe_api_response, headers={'Content-type': 'text/html'}) - - else: - self.send_response( - 404, content=b"Unused url", headers={'Content-type': 'text/plain'} - ) - - def _send_video_response(self, youtube_id, message): - """ - Send message back to the client for video player requests. - Requires sending back callback id. - """ - # Delay the response to simulate network latency - time.sleep(self.server.config.get('time_to_response', self.DEFAULT_DELAY_SEC)) - - # Construct the response content - callback = self.get_params['callback'] - - data = OrderedDict({ - 'items': list( - OrderedDict({ - 'contentDetails': OrderedDict({ - 'id': youtube_id, - 'duration': 'PT2M20S', - }) - }) - ) - }) - response = f"{callback}({json.dumps(data)})".encode('utf-8') - - self.send_response(200, content=response, headers={'Content-type': 'text/html'}) - self.log_message(f"Youtube: sent response {message}") - - def _send_private_video_response(self, message): - """ - Send private video error message back to the client for video player requests. - """ - # Construct the response content - callback = self.get_params['callback'] - data = OrderedDict({ - "error": OrderedDict({ - "code": 403, - "errors": [ - { - "code": "ServiceForbiddenException", - "domain": "GData", - "internalReason": "Private video" - } - ], - "message": message, - }) - }) - response = f"{callback}({json.dumps(data)})".encode('utf-8') - - self.send_response(200, content=response, headers={'Content-type': 'text/html'}) - self.log_message(f"Youtube: sent response {message}") - - -class StubYouTubeService(StubHttpService): - """ - A stub Youtube provider server that responds to GET requests to localhost. - """ - - HANDLER_CLASS = StubYouTubeHandler diff --git a/common/djangoapps/third_party_auth/README.rst b/common/djangoapps/third_party_auth/README.rst index d2e1089eca..eae19bc79b 100644 --- a/common/djangoapps/third_party_auth/README.rst +++ b/common/djangoapps/third_party_auth/README.rst @@ -8,4 +8,4 @@ We make use of the `social-auth-app-django`_ as our backend library for this dja To enable this feature, check out the `third party authentication documentation`. .. _social-auth-app-django: https://github.com/python-social-auth/social-app-django -.. _third party authentication documentation: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/tpa/index.html +.. _third party authentication documentation: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/tpa/index.html diff --git a/common/djangoapps/third_party_auth/api/tests/test_views.py b/common/djangoapps/third_party_auth/api/tests/test_views.py index aea4c18367..6ff644f48e 100644 --- a/common/djangoapps/third_party_auth/api/tests/test_views.py +++ b/common/djangoapps/third_party_auth/api/tests/test_views.py @@ -2,10 +2,12 @@ Tests for the Third Party Auth REST API """ +import urllib from unittest.mock import patch import ddt -import six +from django.conf import settings +from django.contrib.auth import get_user_model from django.http import QueryDict from django.test.utils import override_settings from django.urls import reverse @@ -60,7 +62,7 @@ class TpaAPITestCase(ThirdPartyAuthTestMixin, APITestCase): # Create several users and link each user to Google and TestShib for username in LINKED_USERS: - make_superuser = (username == ADMIN_USERNAME) + make_superuser = username == ADMIN_USERNAME make_staff = (username == STAFF_USERNAME) or make_superuser user = UserFactory.create( username=username, @@ -213,15 +215,50 @@ class UserViewV2APITests(UserViewsMixin, TpaAPITestCase): Test the Third Party Auth User REST API """ - def make_url(self, identifier): + def setUp(self): # pylint: disable=arguments-differ + """ Create users for use in the tests """ + super().setUp() + admin_user = get_user_model().objects.get(username=ADMIN_USERNAME) + self.auth_token = f"JWT {generate_jwt(admin_user, is_restricted=False, scopes=None, filters=None)}" + + def make_url(self, params): """ Return the view URL, with the identifier provided """ return '?'.join([ reverse('third_party_auth_users_api_v2'), - six.moves.urllib.parse.urlencode(identifier) + urllib.parse.urlencode(params) ]) + @ddt.data( + ({}, 400, ["Must provide one of ['email', 'username']"]), + ({'username': ALICE_USERNAME}, 400, ["Must provide uid"]), + ( + {'username': 'invalid-user', 'uid': f'{ALICE_USERNAME}@gmail.com'}, + 404, + {f"Either user invalid-user or social auth record {ALICE_USERNAME}@gmail.com does not exist."} + ), + ( + {'username': ALICE_USERNAME, 'uid': 'invalid-uid'}, + 404, + {f"Either user {ALICE_USERNAME} or social auth record invalid-uid does not exist."} + ), + ({'username': ALICE_USERNAME, 'uid': f'{ALICE_USERNAME}@gmail.com'}, 204, None), + ) + @ddt.unpack + def test_delete_social_auth_record(self, identifier, expect_code, expect_data): + url = self.make_url(identifier) + response = self.client.delete(url, HTTP_AUTHORIZATION=self.auth_token) + assert response.status_code == expect_code + assert (response.data == expect_data) + + def test_unauthorized_delete_social_auth_record_call(self): + user = get_user_model().objects.get(username=CARL_USERNAME) + auth_token = f"JWT {generate_jwt(user, is_restricted=False, scopes=None, filters=None)}" + url = self.make_url({'username': ALICE_USERNAME, 'uid': f'{ALICE_USERNAME}@gmail.com'}) + response = self.client.delete(url, HTTP_AUTHORIZATION=auth_token) + assert response.status_code == 403 + @override_settings(EDX_API_KEY=VALID_API_KEY) @ddt.ddt @@ -377,11 +414,12 @@ class TestThirdPartyAuthUserStatusView(ThirdPartyAuthTestMixin, APITestCase): """ self.client.login(username=self.user.username, password=PASSWORD) response = self.client.get(self.url, content_type="application/json") + next_url = urllib.parse.quote(settings.ACCOUNT_MICROFRONTEND_URL, safe="") assert response.status_code == 200 assert (response.data == [{ 'accepts_logins': True, 'name': 'Google', 'disconnect_url': '/auth/disconnect/google-oauth2/?', - 'connect_url': '/auth/login/google-oauth2/?auth_entry=account_settings&next=%2Faccount%2Fsettings', + 'connect_url': f'/auth/login/google-oauth2/?auth_entry=account_settings&next={next_url}', 'connected': False, 'id': 'oa2-google-oauth2' }]) diff --git a/common/djangoapps/third_party_auth/api/views.py b/common/djangoapps/third_party_auth/api/views.py index 97d1a7d6db..c2b8b0dd6f 100644 --- a/common/djangoapps/third_party_auth/api/views.py +++ b/common/djangoapps/third_party_auth/api/views.py @@ -9,7 +9,6 @@ from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.db.models import Q from django.http import Http404 -from django.urls import reverse from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from rest_framework import exceptions, permissions, status, throttling @@ -66,6 +65,7 @@ class BaseUserView(APIView): identifier_kinds = ['email', 'username'] authentication_classes = ( + JwtAuthentication, # Users may want to view/edit the providers used for authentication before they've # activated their account, so we allow inactive users. BearerAuthenticationAllowInactiveUser, @@ -250,6 +250,42 @@ class UserViewV2(BaseUserView): identifier = self.get_identifier_for_requested_user(request) return self.do_get(request, identifier) + def delete(self, request): + """ + Delete given social auth record for a user. + + Args: + request (Request): The HTTP DELETE request + + Request Parameters: + email/username: Must provide one of 'email' or 'username'. If both are provided, + the username will be ignored. + uid: UID of the social auth record to delete + + Return: + JSON serialized list of the providers linked to this user after the delete operation. + + """ + identifier = self.get_identifier_for_requested_user(request) + uid = request.query_params.get("uid") + if not uid: + raise exceptions.ValidationError("Must provide uid") + + is_unprivileged = self.is_unprivileged_query(request, identifier) + + if is_unprivileged: + return Response(status=status.HTTP_403_FORBIDDEN) + + try: + UserSocialAuth.objects.get(**{"user__" + identifier.kind: identifier.value}, uid=uid).delete() + except UserSocialAuth.DoesNotExist: + return Response( + data={f"Either user {identifier.value} or social auth record {uid} does not exist."}, + status=status.HTTP_404_NOT_FOUND + ) + + return Response(status=status.HTTP_204_NO_CONTENT) + def get_identifier_for_requested_user(self, request): """ Return an identifier namedtuple for the requested user. @@ -425,7 +461,7 @@ class ThirdPartyAuthUserStatusView(APIView): state.provider.provider_id, pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS, # The url the user should be directed to after the auth process has completed. - redirect_url=reverse('account_settings'), + redirect_url=settings.ACCOUNT_MICROFRONTEND_URL, ), 'accepts_logins': state.provider.accepts_logins, # If the user is connected, sending a POST request to this url removes the connection diff --git a/common/djangoapps/third_party_auth/lti.py b/common/djangoapps/third_party_auth/lti.py index 3895c88866..496b7dcbbb 100644 --- a/common/djangoapps/third_party_auth/lti.py +++ b/common/djangoapps/third_party_auth/lti.py @@ -177,7 +177,7 @@ class LTIAuthBackend(BaseAuth): # As this must take constant time, do not use shortcutting operators such as 'and'. # Instead, use constant time operators such as '&', which is the bitwise and. - valid = (lti_consumer_valid) + valid = lti_consumer_valid valid = valid & (submitted_signature == computed_signature) valid = valid & (request.oauth_version == '1.0') valid = valid & (request.oauth_signature_method == 'HMAC-SHA1') diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index 8e688208af..ef1e6f887c 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -715,8 +715,18 @@ def login_analytics(strategy, auth_entry, current_partial=None, *args, **kwargs) """ Sends login info to Segment """ event_name = None + anonymous_id = "" + additional_params = {} + + try: + request = kwargs['request'] + anonymous_id = request.COOKIES.get('ajs_anonymous_id', "") + except: # pylint: disable=bare-except + pass + if auth_entry == AUTH_ENTRY_LOGIN: event_name = 'edx.bi.user.account.authenticated' + additional_params['anonymous_id'] = anonymous_id elif auth_entry in [AUTH_ENTRY_ACCOUNT_SETTINGS]: event_name = 'edx.bi.user.account.linked' @@ -724,7 +734,8 @@ def login_analytics(strategy, auth_entry, current_partial=None, *args, **kwargs) segment.track(kwargs['user'].id, event_name, { 'category': "conversion", 'label': None, - 'provider': kwargs['backend'].name + 'provider': kwargs['backend'].name, + **additional_params }) diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index 8f96235017..524cd64ff3 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -2,7 +2,6 @@ Base integration test for provider implementations. """ - import json import unittest from contextlib import contextmanager @@ -11,7 +10,7 @@ from unittest import mock import pytest from django import test from django.conf import settings -from django.contrib import auth +from django.contrib import auth, messages from django.contrib.auth import models as auth_models from django.contrib.messages.storage import fallback from django.contrib.sessions.backends import cache @@ -28,7 +27,6 @@ from openedx.core.djangoapps.user_authn.views.login_form import login_and_regist from openedx.core.djangoapps.user_authn.views.register import RegistrationView from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory -from openedx.core.djangoapps.user_api.accounts.settings_views import account_settings_context from common.djangoapps.student import models as student_models from common.djangoapps.student.tests.factories import UserFactory @@ -56,9 +54,9 @@ class HelperMixin: test_username (str): username to check the form initialization with. expected (str): expected cleaned username after the form initialization. """ - form_data['username'] = test_username + form_data["username"] = test_username form_field_data = self.provider.get_register_form_data(form_data) - assert form_field_data['username'] == expected + assert form_field_data["username"] == expected def assert_redirect_to_provider_looks_correct(self, response): """Asserts the redirect to the provider's site looks correct. @@ -70,9 +68,11 @@ class HelperMixin: example, more details about the format of the Location header. """ assert 302 == response.status_code - assert response.has_header('Location') + assert response.has_header("Location") - def assert_register_response_in_pipeline_looks_correct(self, response, pipeline_kwargs, required_fields): # lint-amnesty, pylint: disable=invalid-name + def assert_register_response_in_pipeline_looks_correct( + self, response, pipeline_kwargs, required_fields + ): # lint-amnesty, pylint: disable=invalid-name """Performs spot checks of the rendered register.html page. When we display the new account registration form after the user signs @@ -84,10 +84,7 @@ class HelperMixin: assertions in your test, override this method. """ # Check that the correct provider was selected. - self.assertContains( - response, - '"errorMessage": null' - ) + self.assertContains(response, '"errorMessage": null') self.assertContains( response, f'"currentProvider": "{self.provider.name}"', @@ -99,46 +96,68 @@ class HelperMixin: if prepopulated_form_data in required_fields: self.assertContains(response, form_field_data[prepopulated_form_data]) - def assert_register_form_populates_unicode_username_correctly(self, request): # lint-amnesty, pylint: disable=invalid-name + def _get_user_providers_state(self, request): + """ + Return provider user states and duplicated providers. + """ + data = { + "auth": {}, + } + data["duplicate_provider"] = pipeline.get_duplicate_provider(messages.get_messages(request)) + auth_states = pipeline.get_provider_user_states(request.user) + data["auth"]["providers"] = [ + { + "name": state.provider.name, + "connected": state.has_account, + } + for state in auth_states + if state.provider.display_for_login or state.has_account + ] + return data + + def assert_third_party_accounts_state(self, request, duplicate=False, linked=None): + """ + Asserts the user's third party account in the expected state. + + If duplicate is True, we expect data['duplicate_provider'] to contain + the duplicate provider backend name. If linked is passed, we conditionally + check that the provider is included in data['auth']['providers'] and + its connected state is correct. + """ + data = self._get_user_providers_state(request) + if duplicate: + assert data["duplicate_provider"] == self.provider.backend_name + else: + assert data["duplicate_provider"] is None + + if linked is not None: + expected_provider = [ + provider for provider in data["auth"]["providers"] if provider["name"] == self.provider.name + ][0] + assert expected_provider is not None + assert expected_provider["connected"] == linked + + def assert_register_form_populates_unicode_username_correctly( + self, request + ): # lint-amnesty, pylint: disable=invalid-name """ Check the registration form username field behaviour with unicode values. The field could be empty or prefilled depending on whether ENABLE_UNICODE_USERNAME feature is disabled/enabled. """ - unicode_username = 'Червона_Калина' - ascii_substring = 'untouchable' + unicode_username = "Червона_Калина" + ascii_substring = "untouchable" partial_unicode_username = unicode_username + ascii_substring - pipeline_kwargs = pipeline.get(request)['kwargs'] + pipeline_kwargs = pipeline.get(request)["kwargs"] - assert settings.FEATURES['ENABLE_UNICODE_USERNAME'] is False + assert settings.FEATURES["ENABLE_UNICODE_USERNAME"] is False - self._check_registration_form_username(pipeline_kwargs, unicode_username, '') + self._check_registration_form_username(pipeline_kwargs, unicode_username, "") self._check_registration_form_username(pipeline_kwargs, partial_unicode_username, ascii_substring) - with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_UNICODE_USERNAME': True}): + with mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_UNICODE_USERNAME": True}): self._check_registration_form_username(pipeline_kwargs, unicode_username, unicode_username) - # pylint: disable=invalid-name - def assert_account_settings_context_looks_correct(self, context, duplicate=False, linked=None): - """Asserts the user's account settings page context is in the expected state. - - If duplicate is True, we expect context['duplicate_provider'] to contain - the duplicate provider backend name. If linked is passed, we conditionally - check that the provider is included in context['auth']['providers'] and - its connected state is correct. - """ - if duplicate: - assert context['duplicate_provider'] == self.provider.backend_name - else: - assert context['duplicate_provider'] is None - - if linked is not None: - expected_provider = [ - provider for provider in context['auth']['providers'] if provider['name'] == self.provider.name - ][0] - assert expected_provider is not None - assert expected_provider['connected'] == linked - def assert_exception_redirect_looks_correct(self, expected_uri, auth_entry=None): """Tests middleware conditional redirection. @@ -147,49 +166,48 @@ class HelperMixin: """ exception_middleware = middleware.ExceptionMiddleware(get_response=lambda request: None) request, _ = self.get_request_and_strategy(auth_entry=auth_entry) - response = exception_middleware.process_exception( - request, exceptions.AuthCanceled(request.backend)) - location = response.get('Location') + response = exception_middleware.process_exception(request, exceptions.AuthCanceled(request.backend)) + location = response.get("Location") assert 302 == response.status_code - assert 'canceled' in location + assert "canceled" in location assert self.backend_name in location - assert location.startswith(expected_uri + '?') + assert location.startswith(expected_uri + "?") def assert_json_failure_response_is_inactive_account(self, response): """Asserts failure on /login for inactive account looks right.""" assert 400 == response.status_code - payload = json.loads(response.content.decode('utf-8')) + payload = json.loads(response.content.decode("utf-8")) context = { - 'platformName': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), - 'supportLink': configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK) + "platformName": configuration_helpers.get_value("PLATFORM_NAME", settings.PLATFORM_NAME), + "supportLink": configuration_helpers.get_value("SUPPORT_SITE_LINK", settings.SUPPORT_SITE_LINK), } - assert not payload.get('success') - assert 'inactive-user' in payload.get('error_code') - assert context == payload.get('context') + assert not payload.get("success") + assert "inactive-user" in payload.get("error_code") + assert context == payload.get("context") def assert_json_failure_response_is_missing_social_auth(self, response): """Asserts failure on /login for missing social auth looks right.""" assert 403 == response.status_code - payload = json.loads(response.content.decode('utf-8')) - assert not payload.get('success') - assert payload.get('error_code') == 'third-party-auth-with-no-linked-account' + payload = json.loads(response.content.decode("utf-8")) + assert not payload.get("success") + assert payload.get("error_code") == "third-party-auth-with-no-linked-account" def assert_json_failure_response_is_username_collision(self, response): """Asserts the json response indicates a username collision.""" assert 409 == response.status_code - payload = json.loads(response.content.decode('utf-8')) - assert not payload.get('success') - assert 'It looks like this username is already taken' == payload['username'][0]['user_message'] + payload = json.loads(response.content.decode("utf-8")) + assert not payload.get("success") + assert "It looks like this username is already taken" == payload["username"][0]["user_message"] def assert_json_success_response_looks_correct(self, response, verify_redirect_url): """Asserts the json response indicates success and redirection.""" assert 200 == response.status_code - payload = json.loads(response.content.decode('utf-8')) - assert payload.get('success') + payload = json.loads(response.content.decode("utf-8")) + assert payload.get("success") if verify_redirect_url: - assert pipeline.get_complete_url(self.provider.backend_name) == payload.get('redirect_url') + assert pipeline.get_complete_url(self.provider.backend_name) == payload.get("redirect_url") def assert_login_response_before_pipeline_looks_correct(self, response): """Asserts a GET of /login not in the pipeline looks correct.""" @@ -218,19 +236,19 @@ class HelperMixin: assert 302 == response.status_code # NOTE: Ideally we should use assertRedirects(), however it errors out due to the hostname, testserver, # not being properly set. This may be an issue with the call made by PSA, but we are not certain. - assert response.get('Location').endswith( + assert response.get("Location").endswith( expected_redirect_url or django_settings.SOCIAL_AUTH_LOGIN_REDIRECT_URL ) def assert_redirect_to_login_looks_correct(self, response): """Asserts a response would redirect to /login.""" assert 302 == response.status_code - assert '/login' == response.get('Location') + assert "/login" == response.get("Location") def assert_redirect_to_register_looks_correct(self, response): """Asserts a response would redirect to /register.""" assert 302 == response.status_code - assert '/register' == response.get('Location') + assert "/register" == response.get("Location") def assert_register_response_before_pipeline_looks_correct(self, response): """Asserts a GET of /register not in the pipeline looks correct.""" @@ -241,43 +259,41 @@ class HelperMixin: def assert_social_auth_does_not_exist_for_user(self, user, strategy): """Asserts a user does not have an auth with the expected provider.""" - social_auths = strategy.storage.user.get_social_auth_for_user( - user, provider=self.provider.backend_name) + social_auths = strategy.storage.user.get_social_auth_for_user(user, provider=self.provider.backend_name) assert 0 == len(social_auths) def assert_social_auth_exists_for_user(self, user, strategy): """Asserts a user has a social auth with the expected provider.""" - social_auths = strategy.storage.user.get_social_auth_for_user( - user, provider=self.provider.backend_name) + social_auths = strategy.storage.user.get_social_auth_for_user(user, provider=self.provider.backend_name) assert 1 == len(social_auths) assert self.backend_name == social_auths[0].provider def assert_logged_in_cookie_redirect(self, response): - """Verify that the user was redirected in order to set the logged in cookie. """ + """Verify that the user was redirected in order to set the logged in cookie.""" assert response.status_code == 302 - assert response['Location'] == pipeline.get_complete_url(self.provider.backend_name) - assert response.cookies[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME].value == 'true' + assert response["Location"] == pipeline.get_complete_url(self.provider.backend_name) + assert response.cookies[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME].value == "true" assert django_settings.EDXMKTG_USER_INFO_COOKIE_NAME in response.cookies @property def backend_name(self): - """ Shortcut for the backend name """ + """Shortcut for the backend name""" return self.provider.backend_name def get_registration_post_vars(self, overrides=None): """POST vars generated by the registration form.""" defaults = { - 'username': 'username', - 'name': 'First Last', - 'gender': '', - 'year_of_birth': '', - 'level_of_education': '', - 'goals': '', - 'honor_code': 'true', - 'terms_of_service': 'true', - 'password': 'password', - 'mailing_address': '', - 'email': 'user@email.com', + "username": "username", + "name": "First Last", + "gender": "", + "year_of_birth": "", + "level_of_education": "", + "goals": "", + "honor_code": "true", + "terms_of_service": "true", + "password": "password", + "mailing_address": "", + "email": "user@email.com", } if overrides: @@ -294,12 +310,13 @@ class HelperMixin: social_django.utils.strategy(). """ request = self.request_factory.get( - pipeline.get_complete_url(self.backend_name) + - '?redirect_state=redirect_state_value&code=code_value&state=state_value') + pipeline.get_complete_url(self.backend_name) + + "?redirect_state=redirect_state_value&code=code_value&state=state_value" + ) request.site = SiteFactory.create() request.user = auth_models.AnonymousUser() request.session = cache.SessionStore() - request.session[self.backend_name + '_state'] = 'state_value' + request.session[self.backend_name + "_state"] = "state_value" if auth_entry: request.session[pipeline.AUTH_ENTRY_KEY] = auth_entry @@ -312,7 +329,7 @@ class HelperMixin: def _get_login_post_request(self, strategy): """Gets a fully-configured login POST request given a strategy and pipeline.""" - request = self.request_factory.post(reverse('login_api')) + request = self.request_factory.post(reverse("login_api")) # Note: The shared GET request can't be used for login, which is now POST-only, # so this POST request is given a copy of all configuration from the GET request @@ -329,7 +346,7 @@ class HelperMixin: def _patch_edxmako_current_request(self, request): """Make ``request`` be the current request for edxmako template rendering.""" - with mock.patch('common.djangoapps.edxmako.request_context.get_current_request', return_value=request): + with mock.patch("common.djangoapps.edxmako.request_context.get_current_request", return_value=request): yield def get_user_by_email(self, strategy, email): @@ -337,11 +354,13 @@ class HelperMixin: return strategy.storage.user.user_model().objects.get(email=email) def set_logged_in_cookies(self, request): - """Simulate setting the marketing site cookie on the request. """ - request.COOKIES[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME] = 'true' - request.COOKIES[django_settings.EDXMKTG_USER_INFO_COOKIE_NAME] = json.dumps({ - 'version': django_settings.EDXMKTG_USER_INFO_COOKIE_VERSION, - }) + """Simulate setting the marketing site cookie on the request.""" + request.COOKIES[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME] = "true" + request.COOKIES[django_settings.EDXMKTG_USER_INFO_COOKIE_NAME] = json.dumps( + { + "version": django_settings.EDXMKTG_USER_INFO_COOKIE_VERSION, + } + ) def create_user_models_for_existing_account(self, strategy, email, password, username, skip_social_auth=False): """Creates user, profile, registration, and (usually) social auth. @@ -371,10 +390,10 @@ class HelperMixin: """ args = () kwargs = { - 'request': strategy.request, - 'backend': strategy.request.backend, - 'user': None, - 'response': self.get_response_data(), + "request": strategy.request, + "backend": strategy.request.backend, + "user": None, + "response": self.get_response_data(), } return strategy.authenticate(*args, **kwargs) @@ -386,6 +405,7 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin): currently less comprehensive. Some providers are tested with this, others with IntegrationTest. """ + # Provider information: PROVIDER_NAME = "override" PROVIDER_BACKEND = "override" @@ -399,8 +419,8 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin): super().setUp() self.request_factory = test.RequestFactory() - self.login_page_url = reverse('signin_user') - self.register_page_url = reverse('register_user') + self.login_page_url = reverse("signin_user") + self.register_page_url = reverse("register_user") patcher = testutil.patch_mako_templates() patcher.start() self.addCleanup(patcher.stop) @@ -415,47 +435,44 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin): try_login_response = self.client.get(provider_register_url) # The user should be redirected to the provider's login page: assert try_login_response.status_code == 302 - provider_response = self.do_provider_login(try_login_response['Location']) + provider_response = self.do_provider_login(try_login_response["Location"]) # We should be redirected to the register screen since this account is not linked to an edX account: assert provider_response.status_code == 302 - assert provider_response['Location'] == self.register_page_url + assert provider_response["Location"] == self.register_page_url register_response = self.client.get(self.register_page_url) tpa_context = register_response.context["data"]["third_party_auth"] - assert tpa_context['errorMessage'] is None + assert tpa_context["errorMessage"] is None # Check that the "You've successfully signed into [PROVIDER_NAME]" message is shown. - assert tpa_context['currentProvider'] == self.PROVIDER_NAME + assert tpa_context["currentProvider"] == self.PROVIDER_NAME # Check that the data (e.g. email) from the provider is displayed in the form: - form_data = register_response.context['data']['registration_form_desc'] - form_fields = {field['name']: field for field in form_data['fields']} - assert form_fields['email']['defaultValue'] == self.USER_EMAIL - assert form_fields['name']['defaultValue'] == self.USER_NAME - assert form_fields['username']['defaultValue'] == self.USER_USERNAME + form_data = register_response.context["data"]["registration_form_desc"] + form_fields = {field["name"]: field for field in form_data["fields"]} + assert form_fields["email"]["defaultValue"] == self.USER_EMAIL + assert form_fields["name"]["defaultValue"] == self.USER_NAME + assert form_fields["username"]["defaultValue"] == self.USER_USERNAME for field_name, value in extra_defaults.items(): - assert form_fields[field_name]['defaultValue'] == value + assert form_fields[field_name]["defaultValue"] == value registration_values = { - 'email': 'email-edited@tpa-test.none', - 'name': 'My Customized Name', - 'username': 'new_username', - 'honor_code': True, + "email": "email-edited@tpa-test.none", + "name": "My Customized Name", + "username": "new_username", + "honor_code": True, } # Now complete the form: - ajax_register_response = self.client.post( - reverse('user_api_registration'), - registration_values - ) + ajax_register_response = self.client.post(reverse("user_api_registration"), registration_values) assert ajax_register_response.status_code == 200 # Then the AJAX will finish the third party auth: continue_response = self.client.get(tpa_context["finishAuthUrl"]) # And we should be redirected to the dashboard: assert continue_response.status_code == 302 - assert continue_response['Location'] == reverse('dashboard') + assert continue_response["Location"] == reverse("dashboard") # Now check that we can login again, whether or not we have yet verified the account: self.client.logout() self._test_return_login(user_is_activated=False) self.client.logout() - self.verify_user_email('email-edited@tpa-test.none') + self.verify_user_email("email-edited@tpa-test.none") self._test_return_login(user_is_activated=True) def _test_login(self): @@ -468,27 +485,27 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin): try_login_response = self.client.get(provider_login_url) # The user should be redirected to the provider's login page: assert try_login_response.status_code == 302 - complete_response = self.do_provider_login(try_login_response['Location']) + complete_response = self.do_provider_login(try_login_response["Location"]) # We should be redirected to the login screen since this account is not linked to an edX account: assert complete_response.status_code == 302 - assert complete_response['Location'] == self.login_page_url + assert complete_response["Location"] == self.login_page_url login_response = self.client.get(self.login_page_url) tpa_context = login_response.context["data"]["third_party_auth"] - assert tpa_context['errorMessage'] is None + assert tpa_context["errorMessage"] is None # Check that the "You've successfully signed into [PROVIDER_NAME]" message is shown. - assert tpa_context['currentProvider'] == self.PROVIDER_NAME + assert tpa_context["currentProvider"] == self.PROVIDER_NAME # Now the user enters their username and password. # The AJAX on the page will log them in: ajax_login_response = self.client.post( - reverse('user_api_login_session', kwargs={'api_version': 'v1'}), - {'email': self.user.email, 'password': 'Password1234'} + reverse("user_api_login_session", kwargs={"api_version": "v1"}), + {"email": self.user.email, "password": "Password1234"}, ) assert ajax_login_response.status_code == 200 # Then the AJAX will finish the third party auth: continue_response = self.client.get(tpa_context["finishAuthUrl"]) # And we should be redirected to the dashboard: assert continue_response.status_code == 302 - assert continue_response['Location'] == reverse('dashboard') + assert continue_response["Location"] == reverse("dashboard") # Now check that we can login again: self.client.logout() @@ -502,9 +519,9 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin): raise NotImplementedError def _test_return_login(self, user_is_activated=True, previous_session_timed_out=False): - """ Test logging in to an account that is already linked. """ + """Test logging in to an account that is already linked.""" # Make sure we're not logged in: - dashboard_response = self.client.get(reverse('dashboard')) + dashboard_response = self.client.get(reverse("dashboard")) assert dashboard_response.status_code == 302 # The user goes to the login page, and sees a button to login with this provider: provider_login_url = self._check_login_page() @@ -512,22 +529,22 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin): try_login_response = self.client.get(provider_login_url) # The user should be redirected to the provider: assert try_login_response.status_code == 302 - login_response = self.do_provider_login(try_login_response['Location']) + login_response = self.do_provider_login(try_login_response["Location"]) # If the previous session was manually logged out, there will be one weird redirect # required to set the login cookie (it sticks around if the main session times out): if not previous_session_timed_out: assert login_response.status_code == 302 - assert login_response['Location'] == (self.complete_url + '?') + assert login_response["Location"] == (self.complete_url + "?") # And then we should be redirected to the dashboard: - login_response = self.client.get(login_response['Location']) + login_response = self.client.get(login_response["Location"]) assert login_response.status_code == 302 if user_is_activated: - url_expected = reverse('dashboard') + url_expected = reverse("dashboard") else: - url_expected = reverse('third_party_inactive_redirect') + '?next=' + reverse('dashboard') - assert login_response['Location'] == url_expected + url_expected = reverse("third_party_inactive_redirect") + "?next=" + reverse("dashboard") + assert login_response["Location"] == url_expected # Now we are logged in: - dashboard_response = self.client.get(reverse('dashboard')) + dashboard_response = self.client.get(reverse("dashboard")) assert dashboard_response.status_code == 200 def _check_login_page(self): @@ -545,22 +562,23 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin): return self._check_login_or_register_page(self.register_page_url, "registerUrl") def _check_login_or_register_page(self, url, url_to_return): - """ Shared logic for _check_login_page() and _check_register_page() """ + """Shared logic for _check_login_page() and _check_register_page()""" response = self.client.get(url) self.assertContains(response, self.PROVIDER_NAME) - context_data = response.context['data']['third_party_auth'] - provider_urls = {provider['id']: provider[url_to_return] for provider in context_data['providers']} + context_data = response.context["data"]["third_party_auth"] + provider_urls = {provider["id"]: provider[url_to_return] for provider in context_data["providers"]} assert self.PROVIDER_ID in provider_urls return provider_urls[self.PROVIDER_ID] @property def complete_url(self): - """ Get the auth completion URL for this provider """ - return reverse('social:complete', kwargs={'backend': self.PROVIDER_BACKEND}) + """Get the auth completion URL for this provider""" + return reverse("social:complete", kwargs={"backend": self.PROVIDER_BACKEND}) @unittest.skipUnless( - testutil.AUTH_FEATURES_KEY in django_settings.FEATURES, testutil.AUTH_FEATURES_KEY + ' not in settings.FEATURES') + testutil.AUTH_FEATURES_KEY in django_settings.FEATURES, testutil.AUTH_FEATURES_KEY + " not in settings.FEATURES" +) @django_utils.override_settings() # For settings reversion on a method-by-method basis. class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): """Abstract base class for provider integration tests.""" @@ -572,46 +590,51 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): # Actual tests, executed once per child. def test_canceling_authentication_redirects_to_login_when_auth_entry_login(self): - self.assert_exception_redirect_looks_correct('/login', auth_entry=pipeline.AUTH_ENTRY_LOGIN) + self.assert_exception_redirect_looks_correct("/login", auth_entry=pipeline.AUTH_ENTRY_LOGIN) def test_canceling_authentication_redirects_to_register_when_auth_entry_register(self): - self.assert_exception_redirect_looks_correct('/register', auth_entry=pipeline.AUTH_ENTRY_REGISTER) + self.assert_exception_redirect_looks_correct("/register", auth_entry=pipeline.AUTH_ENTRY_REGISTER) def test_canceling_authentication_redirects_to_account_settings_when_auth_entry_account_settings(self): self.assert_exception_redirect_looks_correct( - '/account/settings', auth_entry=pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS + "/account/settings", auth_entry=pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS ) def test_canceling_authentication_redirects_to_root_when_auth_entry_not_set(self): - self.assert_exception_redirect_looks_correct('/') + self.assert_exception_redirect_looks_correct("/") - @mock.patch('common.djangoapps.third_party_auth.pipeline.segment.track') + @mock.patch("common.djangoapps.third_party_auth.pipeline.segment.track") def test_full_pipeline_succeeds_for_linking_account(self, _mock_segment_track): # First, create, the GET request and strategy that store pipeline state, # configure the backend, and mock out wire traffic. get_request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) get_request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) get_request.user = self.create_user_models_for_existing_account( - strategy, 'user@example.com', 'password', self.get_username(), skip_social_auth=True) - partial_pipeline_token = strategy.session_get('partial_pipeline_token') + strategy, "user@example.com", "password", self.get_username(), skip_social_auth=True + ) + partial_pipeline_token = strategy.session_get("partial_pipeline_token") partial_data = strategy.storage.partial.load(partial_pipeline_token) # Instrument the pipeline to get to the dashboard with the full # expected state. - self.client.get( - pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) - actions.do_complete(get_request.backend, social_views._do_login, # pylint: disable=protected-access - request=get_request) + self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) + actions.do_complete( + get_request.backend, social_views._do_login, request=get_request # pylint: disable=protected-access + ) post_request = self._get_login_post_request(strategy) login_user(post_request) - actions.do_complete(post_request.backend, social_views._do_login, # pylint: disable=protected-access, no-member - request=post_request) + actions.do_complete( + post_request.backend, + social_views._do_login, # pylint: disable=protected-access, no-member + request=post_request, + ) # First we expect that we're in the unlinked state, and that there # really is no association in the backend. - self.assert_account_settings_context_looks_correct(account_settings_context(get_request), linked=False) + self.assert_third_party_accounts_state(get_request, linked=False) self.assert_social_auth_does_not_exist_for_user(get_request.user, strategy) # We should be redirected back to the complete page, setting @@ -630,16 +653,18 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): # Now we expect to be in the linked state, with a backend entry. self.assert_social_auth_exists_for_user(get_request.user, strategy) - self.assert_account_settings_context_looks_correct(account_settings_context(get_request), linked=True) + self.assert_third_party_accounts_state(get_request, linked=True) def test_full_pipeline_succeeds_for_unlinking_account(self): # First, create, the GET request and strategy that store pipeline state, # configure the backend, and mock out wire traffic. get_request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) get_request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) user = self.create_user_models_for_existing_account( - strategy, 'user@example.com', 'password', self.get_username()) + strategy, "user@example.com", "password", self.get_username() + ) self.assert_social_auth_exists_for_user(user, strategy) # We're already logged in, so simulate that the cookie is set correctly @@ -647,36 +672,37 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): # Instrument the pipeline to get to the dashboard with the full # expected state. - self.client.get( - pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) - actions.do_complete(get_request.backend, social_views._do_login, # pylint: disable=protected-access - request=get_request) + self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) + actions.do_complete( + get_request.backend, social_views._do_login, request=get_request # pylint: disable=protected-access + ) post_request = self._get_login_post_request(strategy) with self._patch_edxmako_current_request(post_request): login_user(post_request) - actions.do_complete(post_request.backend, social_views._do_login, user=user, # pylint: disable=protected-access, no-member - request=post_request) + actions.do_complete( + post_request.backend, + social_views._do_login, # pylint: disable=protected-access + user=user, # pylint: disable=no-member + request=post_request, + ) # Copy the user that was set on the post_request object back to the original get_request object. get_request.user = post_request.user # First we expect that we're in the linked state, with a backend entry. - self.assert_account_settings_context_looks_correct(account_settings_context(get_request), linked=True) + self.assert_third_party_accounts_state(get_request, linked=True) self.assert_social_auth_exists_for_user(get_request.user, strategy) # Fire off the disconnect pipeline to unlink. self.assert_redirect_after_pipeline_completes( actions.do_disconnect( - get_request.backend, - get_request.user, - None, - redirect_field_name=auth.REDIRECT_FIELD_NAME + get_request.backend, get_request.user, None, redirect_field_name=auth.REDIRECT_FIELD_NAME ) ) # Now we expect to be in the unlinked state, with no backend entry. - self.assert_account_settings_context_looks_correct(account_settings_context(get_request), linked=False) + self.assert_third_party_accounts_state(get_request, linked=False) self.assert_social_auth_does_not_exist_for_user(user, strategy) def test_linking_already_associated_account_raises_auth_already_associated(self): @@ -684,16 +710,18 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): # test_already_associated_exception_populates_dashboard_with_error. It # verifies the exception gets raised when we expect; the latter test # covers exception handling. - email = 'user@example.com' - password = 'password' + email = "user@example.com" + password = "password" username = self.get_username() _, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) backend = strategy.request.backend backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) linked_user = self.create_user_models_for_existing_account(strategy, email, password, username) unlinked_user = social_utils.Storage.user.create_user( - email='other_' + email, password=password, username='other_' + username) + email="other_" + email, password=password, username="other_" + username + ) self.assert_social_auth_exists_for_user(linked_user, strategy) self.assert_social_auth_does_not_exist_for_user(unlinked_user, strategy) @@ -711,42 +739,50 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): # covered in other tests. Using linked=True does, however, let us test # that the duplicate error has no effect on the state of the controls. get_request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) user = self.create_user_models_for_existing_account( - strategy, 'user@example.com', 'password', self.get_username()) + strategy, "user@example.com", "password", self.get_username() + ) self.assert_social_auth_exists_for_user(user, strategy) - self.client.get('/login') + self.client.get("/login") self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) - actions.do_complete(get_request.backend, social_views._do_login, # pylint: disable=protected-access - request=get_request) + actions.do_complete( + get_request.backend, social_views._do_login, request=get_request # pylint: disable=protected-access + ) post_request = self._get_login_post_request(strategy) with self._patch_edxmako_current_request(post_request): login_user(post_request) - actions.do_complete(post_request.backend, social_views._do_login, # pylint: disable=protected-access, no-member - user=user, request=post_request) + actions.do_complete( + post_request.backend, + social_views._do_login, # pylint: disable=protected-access, no-member + user=user, + request=post_request, + ) # Monkey-patch storage for messaging; pylint: disable=protected-access post_request._messages = fallback.FallbackStorage(post_request) middleware.ExceptionMiddleware(get_response=lambda request: None).process_exception( - post_request, - exceptions.AuthAlreadyAssociated(self.provider.backend_name, 'account is already in use.')) + post_request, exceptions.AuthAlreadyAssociated(self.provider.backend_name, "account is already in use.") + ) - self.assert_account_settings_context_looks_correct( - account_settings_context(post_request), duplicate=True, linked=True) + self.assert_third_party_accounts_state(post_request, duplicate=True, linked=True) - @mock.patch('common.djangoapps.third_party_auth.pipeline.segment.track') + @mock.patch("common.djangoapps.third_party_auth.pipeline.segment.track") def test_full_pipeline_succeeds_for_signing_in_to_existing_active_account(self, _mock_segment_track): # First, create, the GET request and strategy that store pipeline state, # configure the backend, and mock out wire traffic. get_request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) user = self.create_user_models_for_existing_account( - strategy, 'user@example.com', 'password', self.get_username()) - partial_pipeline_token = strategy.session_get('partial_pipeline_token') + strategy, "user@example.com", "password", self.get_username() + ) + partial_pipeline_token = strategy.session_get("partial_pipeline_token") partial_data = strategy.storage.partial.load(partial_pipeline_token) self.assert_social_auth_exists_for_user(user, strategy) @@ -754,19 +790,21 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): # Begin! Ensure that the login form contains expected controls before # the user starts the pipeline. - self.assert_login_response_before_pipeline_looks_correct(self.client.get('/login')) + self.assert_login_response_before_pipeline_looks_correct(self.client.get("/login")) # The pipeline starts by a user GETting /auth/login/. # Synthesize that request and check that it redirects to the correct # provider page. - self.assert_redirect_to_provider_looks_correct(self.client.get( - pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))) + self.assert_redirect_to_provider_looks_correct( + self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) + ) # Next, the provider makes a request against /auth/complete/ # to resume the pipeline. # pylint: disable=protected-access - self.assert_redirect_to_login_looks_correct(actions.do_complete(get_request.backend, social_views._do_login, - request=get_request)) + self.assert_redirect_to_login_looks_correct( + actions.do_complete(get_request.backend, social_views._do_login, request=get_request) + ) # At this point we know the pipeline has resumed correctly. Next we # fire off the view that displays the login form and posts it via JS. @@ -781,10 +819,16 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): # We should be redirected back to the complete page, setting # the "logged in" cookie for the marketing site. - self.assert_logged_in_cookie_redirect(actions.do_complete( - post_request.backend, social_views._do_login, post_request.user, None, # pylint: disable=protected-access, no-member - redirect_field_name=auth.REDIRECT_FIELD_NAME, request=post_request - )) + self.assert_logged_in_cookie_redirect( + actions.do_complete( + post_request.backend, + social_views._do_login, + post_request.user, + None, # pylint: disable=protected-access, no-member + redirect_field_name=auth.REDIRECT_FIELD_NAME, + request=post_request, + ) + ) # Set the cookie and try again self.set_logged_in_cookies(get_request) @@ -795,14 +839,16 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): self.assert_redirect_after_pipeline_completes( self.do_complete(strategy, get_request, partial_pipeline_token, partial_data, user) ) - self.assert_account_settings_context_looks_correct(account_settings_context(get_request)) + self.assert_third_party_accounts_state(get_request) def test_signin_fails_if_account_not_active(self): _, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) - user = self.create_user_models_for_existing_account(strategy, 'user@example.com', 'password', - self.get_username()) + user = self.create_user_models_for_existing_account( + strategy, "user@example.com", "password", self.get_username() + ) user.is_active = False user.save() @@ -813,25 +859,28 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): def test_signin_fails_if_no_account_associated(self): _, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) self.create_user_models_for_existing_account( - strategy, 'user@example.com', 'password', self.get_username(), skip_social_auth=True) + strategy, "user@example.com", "password", self.get_username(), skip_social_auth=True + ) post_request = self._get_login_post_request(strategy) self.assert_json_failure_response_is_missing_social_auth(login_user(post_request)) def test_signin_associates_user_if_oauth_provider_and_tpa_is_required(self): - username, email, password = self.get_username(), 'user@example.com', 'password' + username, email, password = self.get_username(), "user@example.com", "password" _, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) user = self.create_user_models_for_existing_account(strategy, email, password, username, skip_social_auth=True) with mock.patch( - 'common.djangoapps.third_party_auth.pipeline.get_associated_user_by_email_response', - return_value=[{'user': user}, True], + "common.djangoapps.third_party_auth.pipeline.get_associated_user_by_email_response", + return_value=[{"user": user}, True], ): strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) @@ -839,30 +888,37 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): self.assert_json_success_response_looks_correct(login_user(post_request), verify_redirect_url=True) def test_first_party_auth_trumps_third_party_auth_but_is_invalid_when_only_email_in_request(self): - self.assert_first_party_auth_trumps_third_party_auth(email='user@example.com') + self.assert_first_party_auth_trumps_third_party_auth(email="user@example.com") def test_first_party_auth_trumps_third_party_auth_but_is_invalid_when_only_password_in_request(self): - self.assert_first_party_auth_trumps_third_party_auth(password='password') + self.assert_first_party_auth_trumps_third_party_auth(password="password") def test_first_party_auth_trumps_third_party_auth_and_fails_when_credentials_bad(self): self.assert_first_party_auth_trumps_third_party_auth( - email='user@example.com', password='password', success=False) + email="user@example.com", password="password", success=False + ) def test_first_party_auth_trumps_third_party_auth_and_succeeds_when_credentials_good(self): self.assert_first_party_auth_trumps_third_party_auth( - email='user@example.com', password='password', success=True) + email="user@example.com", password="password", success=True + ) def test_pipeline_redirects_to_requested_url(self): - requested_redirect_url = 'foo' # something different from '/dashboard' - request, strategy = self.get_request_and_strategy(redirect_uri='social:complete') + requested_redirect_url = "foo" # something different from '/dashboard' + request, strategy = self.get_request_and_strategy(redirect_uri="social:complete") strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) request.session[pipeline.AUTH_REDIRECT_KEY] = requested_redirect_url - user = self.create_user_models_for_existing_account(strategy, 'user@foo.com', 'password', self.get_username()) + user = self.create_user_models_for_existing_account(strategy, "user@foo.com", "password", self.get_username()) self.set_logged_in_cookies(request) self.assert_redirect_after_pipeline_completes( - actions.do_complete(request.backend, social_views._do_login, user=user, request=request), # pylint: disable=protected-access + actions.do_complete( + request.backend, + social_views._do_login, # pylint: disable=protected-access + user=user, + request=request, + ), requested_redirect_url, ) @@ -870,44 +926,47 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): # First, create, the request and strategy that store pipeline state. # Mock out wire traffic. request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri="social:complete" + ) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) - partial_pipeline_token = strategy.session_get('partial_pipeline_token') + partial_pipeline_token = strategy.session_get("partial_pipeline_token") partial_data = strategy.storage.partial.load(partial_pipeline_token) # Begin! Grab the registration page and check the login control on it. - self.assert_register_response_before_pipeline_looks_correct(self.client.get('/register')) + self.assert_register_response_before_pipeline_looks_correct(self.client.get("/register")) # The pipeline starts by a user GETting /auth/login/. # Synthesize that request and check that it redirects to the correct # provider page. - self.assert_redirect_to_provider_looks_correct(self.client.get( - pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))) + self.assert_redirect_to_provider_looks_correct( + self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) + ) # Next, the provider makes a request against /auth/complete/. # pylint: disable=protected-access - self.assert_redirect_to_register_looks_correct(actions.do_complete(request.backend, social_views._do_login, - request=request)) + self.assert_redirect_to_register_looks_correct( + actions.do_complete(request.backend, social_views._do_login, request=request) + ) # At this point we know the pipeline has resumed correctly. Next we # fire off the view that displays the registration form. with self._patch_edxmako_current_request(request): self.assert_register_form_populates_unicode_username_correctly(request) self.assert_register_response_in_pipeline_looks_correct( - login_and_registration_form(strategy.request, initial_mode='register'), - pipeline.get(request)['kwargs'], - ['name', 'username', 'email'] + login_and_registration_form(strategy.request, initial_mode="register"), + pipeline.get(request)["kwargs"], + ["name", "username", "email"], ) # Next, we invoke the view that handles the POST. Not all providers # supply email. Manually add it as the user would have to; this # also serves as a test of overriding provider values. Always provide a # password for us to check that we override it properly. - overridden_password = strategy.request.POST.get('password') - email = 'new@example.com' + overridden_password = strategy.request.POST.get("password") + email = "new@example.com" - if not strategy.request.POST.get('email'): - strategy.request.POST = self.get_registration_post_vars({'email': email}) + if not strategy.request.POST.get("email"): + strategy.request.POST = self.get_registration_post_vars({"email": email}) # The user must not exist yet... with pytest.raises(auth_models.User.DoesNotExist): @@ -935,41 +994,44 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): self.assert_redirect_after_pipeline_completes( self.do_complete(strategy, request, partial_pipeline_token, partial_data, created_user) ) - # Now the user has been redirected to the dashboard. Their third party account should now be linked. + # Their third party account should now be linked. self.assert_social_auth_exists_for_user(created_user, strategy) - self.assert_account_settings_context_looks_correct(account_settings_context(request), linked=True) + self.assert_third_party_accounts_state(request, linked=True) def test_new_account_registration_assigns_distinct_username_on_collision(self): original_username = self.get_username() request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri="social:complete" + ) # Create a colliding username in the backend, then proceed with # assignment via pipeline to make sure a distinct username is created. - strategy.storage.user.create_user(username=self.get_username(), email='user@email.com', password='password') + strategy.storage.user.create_user(username=self.get_username(), email="user@email.com", password="password") backend = strategy.request.backend backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) # pylint: disable=protected-access response = actions.do_complete(backend, social_views._do_login, request=request) assert response.status_code == 302 - response = json.loads(create_account(strategy.request).content.decode('utf-8')) - assert response['username'] != original_username + response = json.loads(create_account(strategy.request).content.decode("utf-8")) + assert response["username"] != original_username def test_new_account_registration_fails_if_email_exists(self): request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri="social:complete" + ) backend = strategy.request.backend backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) # pylint: disable=protected-access - self.assert_redirect_to_register_looks_correct(actions.do_complete(backend, social_views._do_login, - request=request)) + self.assert_redirect_to_register_looks_correct( + actions.do_complete(backend, social_views._do_login, request=request) + ) with self._patch_edxmako_current_request(request): self.assert_register_response_in_pipeline_looks_correct( - login_and_registration_form(strategy.request, initial_mode='register'), - pipeline.get(request)['kwargs'], - ['name', 'username', 'email'] + login_and_registration_form(strategy.request, initial_mode="register"), + pipeline.get(request)["kwargs"], + ["name", "username", "email"], ) with self._patch_edxmako_current_request(strategy.request): @@ -979,18 +1041,18 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): self.assert_json_failure_response_is_username_collision(create_account(strategy.request)) def test_pipeline_raises_auth_entry_error_if_auth_entry_invalid(self): - auth_entry = 'invalid' + auth_entry = "invalid" assert auth_entry not in pipeline._AUTH_ENTRY_CHOICES # pylint: disable=protected-access - _, strategy = self.get_request_and_strategy(auth_entry=auth_entry, redirect_uri='social:complete') + _, strategy = self.get_request_and_strategy(auth_entry=auth_entry, redirect_uri="social:complete") with pytest.raises(pipeline.AuthEntryError): strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) def test_pipeline_assumes_login_if_auth_entry_missing(self): - _, strategy = self.get_request_and_strategy(auth_entry=None, redirect_uri='social:complete') + _, strategy = self.get_request_and_strategy(auth_entry=None, redirect_uri="social:complete") response = self.fake_auth_complete(strategy) - assert response.url == reverse('signin_user') + assert response.url == reverse("signin_user") def assert_first_party_auth_trumps_third_party_auth(self, email=None, password=None, success=None): """Asserts first party auth was used in place of third party auth. @@ -1004,33 +1066,35 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): one of username or password will be missing). """ _, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) self.create_user_models_for_existing_account( - strategy, email, password, self.get_username(), skip_social_auth=True) + strategy, email, password, self.get_username(), skip_social_auth=True + ) post_request = self._get_login_post_request(strategy) post_request.POST = dict(post_request.POST) if email: - post_request.POST['email'] = email + post_request.POST["email"] = email if password: - post_request.POST['password'] = 'bad_' + password if success is False else password + post_request.POST["password"] = "bad_" + password if success is False else password self.assert_pipeline_running(post_request) - payload = json.loads(login_user(post_request).content.decode('utf-8')) + payload = json.loads(login_user(post_request).content.decode("utf-8")) if success is None: # Request malformed -- just one of email/password given. - assert not payload.get('success') - assert 'There was an error receiving your login information' in payload.get('value') + assert not payload.get("success") + assert "There was an error receiving your login information" in payload.get("value") elif success: # Request well-formed and credentials good. - assert payload.get('success') + assert payload.get("success") else: # Request well-formed but credentials bad. - assert not payload.get('success') - assert 'incorrect' in payload.get('value') + assert not payload.get("success") + assert "incorrect" in payload.get("value") def get_response_data(self): """Gets a dict of response data of the form given by the provider. @@ -1064,8 +1128,13 @@ class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): if not user: user = request.user return actions.do_complete( - request.backend, social_views._do_login, user, None, # pylint: disable=protected-access - redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request, partial_token=partial_pipeline_token + request.backend, + social_views._do_login, # pylint: disable=protected-access + user, + None, + redirect_field_name=auth.REDIRECT_FIELD_NAME, + request=request, + partial_token=partial_pipeline_token, ) diff --git a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py index ec3efd8286..caddd325ba 100644 --- a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py +++ b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py @@ -2,7 +2,6 @@ Third_party_auth integration tests using a mock version of the TestShib provider """ - import datetime import json import logging @@ -27,16 +26,15 @@ from common.djangoapps.third_party_auth.saml import SapSuccessFactorsIdentityPro from common.djangoapps.third_party_auth.saml import log as saml_log from common.djangoapps.third_party_auth.tasks import fetch_saml_metadata from common.djangoapps.third_party_auth.tests import testutil, utils -from openedx.core.djangoapps.user_api.accounts.settings_views import account_settings_context from openedx.core.djangoapps.user_authn.views.login import login_user from openedx.features.enterprise_support.tests.factories import EnterpriseCustomerFactory from .base import IntegrationTestMixin -TESTSHIB_ENTITY_ID = 'https://idp.testshib.org/idp/shibboleth' -TESTSHIB_METADATA_URL = 'https://mock.testshib.org/metadata/testshib-providers.xml' -TESTSHIB_METADATA_URL_WITH_CACHE_DURATION = 'https://mock.testshib.org/metadata/testshib-providers-cache.xml' -TESTSHIB_SSO_URL = 'https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO' +TESTSHIB_ENTITY_ID = "https://idp.testshib.org/idp/shibboleth" +TESTSHIB_METADATA_URL = "https://mock.testshib.org/metadata/testshib-providers.xml" +TESTSHIB_METADATA_URL_WITH_CACHE_DURATION = "https://mock.testshib.org/metadata/testshib-providers-cache.xml" +TESTSHIB_SSO_URL = "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO" class SamlIntegrationTestUtilities: @@ -44,6 +42,7 @@ class SamlIntegrationTestUtilities: Class contains methods particular to SAML integration testing so that they can be separated out from the actual test methods. """ + PROVIDER_ID = "saml-testshib" PROVIDER_NAME = "TestShib" PROVIDER_BACKEND = "tpa-saml" @@ -67,51 +66,59 @@ class SamlIntegrationTestUtilities: self.addCleanup(httpretty.disable) # lint-amnesty, pylint: disable=no-member def metadata_callback(_request, _uri, headers): - """ Return a cached copy of TestShib's metadata by reading it from disk """ - return (200, headers, self.read_data_file('testshib_metadata.xml')) # lint-amnesty, pylint: disable=no-member + """Return a cached copy of TestShib's metadata by reading it from disk""" + return ( + 200, + headers, + self.read_data_file("testshib_metadata.xml"), + ) # lint-amnesty, pylint: disable=no-member - httpretty.register_uri(httpretty.GET, TESTSHIB_METADATA_URL, content_type='text/xml', body=metadata_callback) + httpretty.register_uri(httpretty.GET, TESTSHIB_METADATA_URL, content_type="text/xml", body=metadata_callback) def cache_duration_metadata_callback(_request, _uri, headers): """Return a cached copy of TestShib's metadata with a cacheDuration attribute""" - return (200, headers, self.read_data_file('testshib_metadata_with_cache_duration.xml')) # lint-amnesty, pylint: disable=no-member + return ( + 200, + headers, + self.read_data_file("testshib_metadata_with_cache_duration.xml"), + ) # lint-amnesty, pylint: disable=no-member httpretty.register_uri( httpretty.GET, TESTSHIB_METADATA_URL_WITH_CACHE_DURATION, - content_type='text/xml', - body=cache_duration_metadata_callback + content_type="text/xml", + body=cache_duration_metadata_callback, ) # Configure the SAML library to use the same request ID for every request. # Doing this and freezing the time allows us to play back recorded request/response pairs - uid_patch = patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.generate_unique_id', return_value='TESTID') + uid_patch = patch("onelogin.saml2.utils.OneLogin_Saml2_Utils.generate_unique_id", return_value="TESTID") uid_patch.start() self.addCleanup(uid_patch.stop) # lint-amnesty, pylint: disable=no-member self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded. def _freeze_time(self, timestamp): - """ Mock the current time for SAML, so we can replay canned requests/responses """ - now_patch = patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.now', return_value=timestamp) + """Mock the current time for SAML, so we can replay canned requests/responses""" + now_patch = patch("onelogin.saml2.utils.OneLogin_Saml2_Utils.now", return_value=timestamp) now_patch.start() self.addCleanup(now_patch.stop) # lint-amnesty, pylint: disable=no-member def _configure_testshib_provider(self, **kwargs): - """ Enable and configure the TestShib SAML IdP as a third_party_auth provider """ - fetch_metadata = kwargs.pop('fetch_metadata', True) - assert_metadata_updates = kwargs.pop('assert_metadata_updates', True) - kwargs.setdefault('name', self.PROVIDER_NAME) - kwargs.setdefault('enabled', True) - kwargs.setdefault('visible', True) + """Enable and configure the TestShib SAML IdP as a third_party_auth provider""" + fetch_metadata = kwargs.pop("fetch_metadata", True) + assert_metadata_updates = kwargs.pop("assert_metadata_updates", True) + kwargs.setdefault("name", self.PROVIDER_NAME) + kwargs.setdefault("enabled", True) + kwargs.setdefault("visible", True) kwargs.setdefault("backend_name", "tpa-saml") - kwargs.setdefault('slug', self.PROVIDER_IDP_SLUG) - kwargs.setdefault('entity_id', TESTSHIB_ENTITY_ID) - kwargs.setdefault('metadata_source', TESTSHIB_METADATA_URL) - kwargs.setdefault('icon_class', 'fa-university') - kwargs.setdefault('attr_email', 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6') # eduPersonPrincipalName - kwargs.setdefault('max_session_length', None) - kwargs.setdefault('send_to_registration_first', False) - kwargs.setdefault('skip_email_verification', False) + kwargs.setdefault("slug", self.PROVIDER_IDP_SLUG) + kwargs.setdefault("entity_id", TESTSHIB_ENTITY_ID) + kwargs.setdefault("metadata_source", TESTSHIB_METADATA_URL) + kwargs.setdefault("icon_class", "fa-university") + kwargs.setdefault("attr_email", "urn:oid:1.3.6.1.4.1.5923.1.1.1.6") # eduPersonPrincipalName + kwargs.setdefault("max_session_length", None) + kwargs.setdefault("send_to_registration_first", False) + kwargs.setdefault("skip_email_verification", False) saml_provider = self.configure_saml_provider(**kwargs) # pylint: disable=no-member if fetch_metadata: @@ -127,17 +134,17 @@ class SamlIntegrationTestUtilities: return saml_provider def do_provider_login(self, provider_redirect_url): - """ Mocked: the user logs in to TestShib and then gets redirected back """ + """Mocked: the user logs in to TestShib and then gets redirected back""" # The SAML provider (TestShib) will authenticate the user, then get the browser to POST a response: assert provider_redirect_url.startswith(TESTSHIB_SSO_URL) # lint-amnesty, pylint: disable=no-member saml_response_xml = utils.read_and_pre_process_xml( - os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'testshib_saml_response.xml') + os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "testshib_saml_response.xml") ) return self.client.post( # lint-amnesty, pylint: disable=no-member self.complete_url, # lint-amnesty, pylint: disable=no-member - content_type='application/x-www-form-urlencoded', + content_type="application/x-www-form-urlencoded", data=utils.prepare_saml_response_from_xml(saml_response_xml), ) @@ -150,16 +157,16 @@ class TestIndexExceptionTest(SamlIntegrationTestUtilities, IntegrationTestMixin, """ TOKEN_RESPONSE_DATA = { - 'access_token': 'access_token_value', - 'expires_in': 'expires_in_value', + "access_token": "access_token_value", + "expires_in": "expires_in_value", } USER_RESPONSE_DATA = { - 'lastName': 'lastName_value', - 'id': 'id_value', - 'firstName': 'firstName_value', - 'idp_name': 'testshib', - 'attributes': {'urn:oid:0.9.2342.19200300.100.1.1': [], 'name_id': '1'}, - 'session_index': '1', + "lastName": "lastName_value", + "id": "id_value", + "firstName": "firstName_value", + "idp_name": "testshib", + "attributes": {"urn:oid:0.9.2342.19200300.100.1.1": [], "name_id": "1"}, + "session_index": "1", } def test_index_error_from_empty_list_saml_attribute(self): @@ -169,7 +176,8 @@ class TestIndexExceptionTest(SamlIntegrationTestUtilities, IntegrationTestMixin, """ self.provider = self._configure_testshib_provider() request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) with self.assertRaises(IncorrectConfigurationException): request.backend.auth_complete = MagicMock(return_value=self.fake_auth_complete(strategy)) @@ -188,16 +196,16 @@ class TestKeyExceptionTest(SamlIntegrationTestUtilities, IntegrationTestMixin, t """ TOKEN_RESPONSE_DATA = { - 'access_token': 'access_token_value', - 'expires_in': 'expires_in_value', + "access_token": "access_token_value", + "expires_in": "expires_in_value", } USER_RESPONSE_DATA = { - 'lastName': 'lastName_value', - 'id': 'id_value', - 'firstName': 'firstName_value', - 'idp_name': 'testshib', - 'attributes': {'name_id': '1'}, - 'session_index': '1', + "lastName": "lastName_value", + "id": "id_value", + "firstName": "firstName_value", + "idp_name": "testshib", + "attributes": {"name_id": "1"}, + "session_index": "1", } def test_key_error_from_missing_saml_attributes(self): @@ -207,7 +215,8 @@ class TestKeyExceptionTest(SamlIntegrationTestUtilities, IntegrationTestMixin, t """ self.provider = self._configure_testshib_provider() request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) with self.assertRaises(IncorrectConfigurationException): request.backend.auth_complete = MagicMock(return_value=self.fake_auth_complete(strategy)) @@ -226,25 +235,23 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin """ TOKEN_RESPONSE_DATA = { - 'access_token': 'access_token_value', - 'expires_in': 'expires_in_value', + "access_token": "access_token_value", + "expires_in": "expires_in_value", } USER_RESPONSE_DATA = { - 'lastName': 'lastName_value', - 'id': 'id_value', - 'firstName': 'firstName_value', - 'idp_name': 'testshib', - 'attributes': {'urn:oid:0.9.2342.19200300.100.1.1': ['myself'], 'name_id': '1'}, - 'session_index': '1', + "lastName": "lastName_value", + "id": "id_value", + "firstName": "firstName_value", + "idp_name": "testshib", + "attributes": {"urn:oid:0.9.2342.19200300.100.1.1": ["myself"], "name_id": "1"}, + "session_index": "1", } - @patch('openedx.features.enterprise_support.api.enterprise_customer_for_request') - @patch('openedx.core.djangoapps.user_api.accounts.settings_views.enterprise_customer_for_request') - @patch('openedx.features.enterprise_support.utils.third_party_auth.provider.Registry.get') + @patch("openedx.features.enterprise_support.api.enterprise_customer_for_request") + @patch("openedx.features.enterprise_support.utils.third_party_auth.provider.Registry.get") def test_full_pipeline_succeeds_for_unlinking_testshib_account( self, mock_auth_provider, - mock_enterprise_customer_for_request_settings_view, mock_enterprise_customer_for_request, ): @@ -252,10 +259,12 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin # configure the backend, and mock out wire traffic. self.provider = self._configure_testshib_provider() request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) request.backend.auth_complete = MagicMock(return_value=self.fake_auth_complete(strategy)) user = self.create_user_models_for_existing_account( - strategy, 'user@example.com', 'password', self.get_username()) + strategy, "user@example.com", "password", self.get_username() + ) self.assert_social_auth_exists_for_user(user, strategy) request.user = user @@ -267,70 +276,67 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin enterprise_customer = EnterpriseCustomerFactory() assert EnterpriseCustomerUser.objects.count() == 0, "Precondition check: no link records should exist" EnterpriseCustomerUser.objects.link_user(enterprise_customer, user.email) - assert (EnterpriseCustomerUser.objects - .filter(enterprise_customer=enterprise_customer, user_id=user.id).count() == 1) - EnterpriseCustomerIdentityProvider.objects.get_or_create(enterprise_customer=enterprise_customer, - provider_id=self.provider.provider_id) + assert ( + EnterpriseCustomerUser.objects.filter(enterprise_customer=enterprise_customer, user_id=user.id).count() == 1 + ) + EnterpriseCustomerIdentityProvider.objects.get_or_create( + enterprise_customer=enterprise_customer, provider_id=self.provider.provider_id + ) enterprise_customer_data = { - 'uuid': enterprise_customer.uuid, - 'name': enterprise_customer.name, - 'identity_provider': 'saml-default', - 'identity_providers': [ + "uuid": enterprise_customer.uuid, + "name": enterprise_customer.name, + "identity_provider": "saml-default", + "identity_providers": [ { "provider_id": "saml-default", } ], } - mock_auth_provider.return_value.backend_name = 'tpa-saml' + mock_auth_provider.return_value.backend_name = "tpa-saml" mock_enterprise_customer_for_request.return_value = enterprise_customer_data - mock_enterprise_customer_for_request_settings_view.return_value = enterprise_customer_data # Instrument the pipeline to get to the dashboard with the full expected state. - self.client.get( - pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) + self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) - actions.do_complete(request.backend, social_views._do_login, # pylint: disable=protected-access - request=request) + actions.do_complete( + request.backend, social_views._do_login, request=request # pylint: disable=protected-access + ) with self._patch_edxmako_current_request(strategy.request): login_user(strategy.request) - actions.do_complete(request.backend, social_views._do_login, user=user, # pylint: disable=protected-access - request=request) + actions.do_complete( + request.backend, social_views._do_login, user=user, request=request # pylint: disable=protected-access + ) # First we expect that we're in the linked state, with a backend entry. - self.assert_account_settings_context_looks_correct(account_settings_context(request), linked=True) self.assert_social_auth_exists_for_user(request.user, strategy) FEATURES_WITH_ENTERPRISE_ENABLED = settings.FEATURES.copy() - FEATURES_WITH_ENTERPRISE_ENABLED['ENABLE_ENTERPRISE_INTEGRATION'] = True + FEATURES_WITH_ENTERPRISE_ENABLED["ENABLE_ENTERPRISE_INTEGRATION"] = True with patch.dict("django.conf.settings.FEATURES", FEATURES_WITH_ENTERPRISE_ENABLED): # Fire off the disconnect pipeline without the user information. actions.do_disconnect( - request.backend, - None, - None, - redirect_field_name=auth.REDIRECT_FIELD_NAME, - request=request + request.backend, None, None, redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request + ) + assert ( + EnterpriseCustomerUser.objects.filter(enterprise_customer=enterprise_customer, user_id=user.id).count() + != 0 ) - assert EnterpriseCustomerUser.objects\ - .filter(enterprise_customer=enterprise_customer, user_id=user.id).count() != 0 # Fire off the disconnect pipeline to unlink. self.assert_redirect_after_pipeline_completes( actions.do_disconnect( - request.backend, - user, - None, - redirect_field_name=auth.REDIRECT_FIELD_NAME, - request=request + request.backend, user, None, redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request ) ) # Now we expect to be in the unlinked state, with no backend entry. - self.assert_account_settings_context_looks_correct(account_settings_context(request), linked=False) + self.assert_third_party_accounts_state(request, linked=False) self.assert_social_auth_does_not_exist_for_user(user, strategy) - assert EnterpriseCustomerUser.objects\ - .filter(enterprise_customer=enterprise_customer, user_id=user.id).count() == 0 + assert ( + EnterpriseCustomerUser.objects.filter(enterprise_customer=enterprise_customer, user_id=user.id).count() + == 0 + ) def get_response_data(self): """Gets dict (string -> object) of merged data about the user.""" @@ -340,7 +346,7 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin def get_username(self): response_data = self.get_response_data() - return response_data.get('idp_name') + return response_data.get("idp_name") def test_login_before_metadata_fetched(self): self._configure_testshib_provider(fetch_metadata=False) @@ -350,18 +356,18 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin try_login_response = self.client.get(testshib_login_url) # The user should be redirected to back to the login page: assert try_login_response.status_code == 302 - assert try_login_response['Location'] == self.login_page_url + assert try_login_response["Location"] == self.login_page_url # When loading the login page, the user will see an error message: response = self.client.get(self.login_page_url) - self.assertContains(response, 'Authentication with TestShib is currently unavailable.') + self.assertContains(response, "Authentication with TestShib is currently unavailable.") def test_login(self): - """ Configure TestShib before running the login test """ + """Configure TestShib before running the login test""" self._configure_testshib_provider() self._test_login() def test_register(self): - """ Configure TestShib before running the register test """ + """Configure TestShib before running the register test""" self._configure_testshib_provider() self._test_register() @@ -374,17 +380,17 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin user=self.user, provider=self.PROVIDER_BACKEND, uid__startswith=self.PROVIDER_IDP_SLUG ) attributes = record.extra_data - assert attributes.get('urn:oid:1.3.6.1.4.1.5923.1.1.1.9') == ['Member@testshib.org', 'Staff@testshib.org'] - assert attributes.get('urn:oid:2.5.4.3') == ['Me Myself And I'] - assert attributes.get('urn:oid:0.9.2342.19200300.100.1.1') == ['myself'] - assert attributes.get('urn:oid:2.5.4.20') == ['555-5555'] + assert attributes.get("urn:oid:1.3.6.1.4.1.5923.1.1.1.9") == ["Member@testshib.org", "Staff@testshib.org"] + assert attributes.get("urn:oid:2.5.4.3") == ["Me Myself And I"] + assert attributes.get("urn:oid:0.9.2342.19200300.100.1.1") == ["myself"] + assert attributes.get("urn:oid:2.5.4.20") == ["555-5555"] # Phone number @ddt.data(True, False) def test_debug_mode_login(self, debug_mode_enabled): - """ Test SAML login logs with debug mode enabled or not """ + """Test SAML login logs with debug mode enabled or not""" self._configure_testshib_provider(debug_mode=debug_mode_enabled) - with patch.object(saml_log, 'info') as mock_log: + with patch.object(saml_log, "info") as mock_log: self._test_login() if debug_mode_enabled: # We expect that test_login() does two full logins, and each attempt generates two @@ -393,38 +399,37 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin expected_next_url = "/dashboard" (msg, action_type, idp_name, request_data, next_url, xml), _kwargs = mock_log.call_args_list[0] - assert msg.startswith('SAML login %s') - assert action_type == 'request' + assert msg.startswith("SAML login %s") + assert action_type == "request" assert idp_name == self.PROVIDER_IDP_SLUG self.assertDictContainsSubset( - {"idp": idp_name, "auth_entry": "login", "next": expected_next_url}, - request_data + {"idp": idp_name, "auth_entry": "login", "next": expected_next_url}, request_data ) assert next_url == expected_next_url - assert '`_ +`Events in the Tracking Logs `_ diff --git a/common/djangoapps/track/views/segmentio.py b/common/djangoapps/track/views/segmentio.py index 2ab5306232..8a35a0e421 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/djangoapps/util/memcache.py b/common/djangoapps/util/memcache.py index 2f7e6dc623..ce8c70219e 100644 --- a/common/djangoapps/util/memcache.py +++ b/common/djangoapps/util/memcache.py @@ -7,7 +7,6 @@ so that we can cache any keys, not just ones that memcache would ordinarily acce import hashlib from urllib.parse import quote_plus -from django.conf import settings from django.utils.encoding import smart_str @@ -15,10 +14,7 @@ def fasthash(string): """ Hashes `string` into a string representation of a 128-bit digest. """ - if settings.FEATURES.get("ENABLE_BLAKE2B_HASHING", False): - hash_obj = hashlib.new("blake2b", digest_size=16) - else: - hash_obj = hashlib.new("md4") + hash_obj = hashlib.new("blake2b", digest_size=16) hash_obj.update(string.encode('utf-8')) return hash_obj.hexdigest() diff --git a/common/djangoapps/util/storage.py b/common/djangoapps/util/storage.py new file mode 100644 index 0000000000..3474b07c07 --- /dev/null +++ b/common/djangoapps/util/storage.py @@ -0,0 +1,61 @@ +""" Utility functions related to django storages """ + +from typing import Optional +from django.conf import settings +from django.core.files.storage import default_storage, storages +from django.utils.module_loading import import_string + + +def resolve_storage_backend(storage_key: str, legacy_setting_key: str, options: Optional[dict] = None): + """ + Configures and returns a Django `Storage` instance, compatible with both Django 4 and Django 5. + Params: + storage_key = The key name saved in Django storages settings. + legacy_setting_key = The key name saved in Django settings. + options = Kwargs for the storage class. + Returns: + An instance of the configured storage backend. + Raises: + ImportError: If the specified storage class cannot be imported. + + Main goal: + Deprecate the use of `django.core.files.storage.get_storage_class`. + + How: + Replace `get_storage_class` with direct configuration logic, + ensuring backward compatibility with both Django 4 and Django 5 storage settings. + """ + + storage_path = getattr(settings, legacy_setting_key, None) + storages_config = getattr(settings, 'STORAGES', {}) + + if options is None: + options = {} + + if storage_key == "default": + # Use case 1: Default storage + # Works consistently across Django 4.2 and 5.x. + # In Django 4.2 and above, `default_storage` uses + # either `DEFAULT_FILE_STORAGE` or `STORAGES['default']`. + return default_storage + + if storage_key in storages_config: + # Use case 2: STORAGES is defined + # If STORAGES is present, we retrieve it through the storages API + # settings.py must define STORAGES like: + # STORAGES = { + # "default": {"BACKEND": "...", "OPTIONS": {...}}, + # "custom": {"BACKEND": "...", "OPTIONS": {...}}, + # } + # See: https://docs.djangoproject.com/en/5.2/ref/settings/#std-setting-STORAGES + return storages[storage_key] + + if not storage_path: + # Use case 3: No storage settings defined + # If no storage settings are defined anywhere, use the default storage + return default_storage + + # Use case 4: Legacy settings + # Fallback to import the storage_path (Obtained from django settings) manually + StorageClass = import_string(storage_path) + return StorageClass(**options) diff --git a/common/djangoapps/util/tests/test_memcache.py b/common/djangoapps/util/tests/test_memcache.py index 13f67f386a..0047e9fa66 100644 --- a/common/djangoapps/util/tests/test_memcache.py +++ b/common/djangoapps/util/tests/test_memcache.py @@ -3,15 +3,11 @@ Tests for memcache in util app """ -from django.conf import settings from django.core.cache import caches -from django.test import TestCase, override_settings +from django.test import TestCase from common.djangoapps.util.memcache import safe_key -BLAKE2B_ENABLED_FEATURES = settings.FEATURES.copy() -BLAKE2B_ENABLED_FEATURES["ENABLE_BLAKE2B_HASHING"] = True - class MemcacheTest(TestCase): """ @@ -55,20 +51,6 @@ class MemcacheTest(TestCase): # The key should now be valid assert self._is_valid_key(key), f'Failed for key length {length}' - @override_settings(FEATURES=BLAKE2B_ENABLED_FEATURES) - def test_safe_key_long_with_blake2b_enabled(self): - # Choose lengths close to memcached's cutoff (250) - for length in [248, 249, 250, 251, 252]: - - # Generate a key of that length - key = 'a' * length - - # Make the key safe - key = safe_key(key, '', '') - - # The key should now be valid - assert self._is_valid_key(key), f'Failed for key length {length}' - def test_long_key_prefix_version(self): # Long key @@ -83,34 +65,6 @@ class MemcacheTest(TestCase): key = safe_key('key', 'prefix', 'a' * 300) assert self._is_valid_key(key) - @override_settings(FEATURES=BLAKE2B_ENABLED_FEATURES) - def test_long_key_prefix_version_with_blake2b_enabled(self): - - # Long key - key = safe_key('a' * 300, 'prefix', 'version') - assert self._is_valid_key(key) - - # Long prefix - key = safe_key('key', 'a' * 300, 'version') - assert self._is_valid_key(key) - - # Long version - key = safe_key('key', 'prefix', 'a' * 300) - assert self._is_valid_key(key) - - def test_safe_key_unicode(self): - - for unicode_char in self.UNICODE_CHAR_CODES: - - # Generate a key with that character - key = chr(unicode_char) - - # Make the key safe - key = safe_key(key, '', '') - - # The key should now be valid - assert self._is_valid_key(key), f'Failed for unicode character {unicode_char}' - def test_safe_key_prefix_unicode(self): for unicode_char in self.UNICODE_CHAR_CODES: diff --git a/common/djangoapps/xblock_django/tests/test_commands.py b/common/djangoapps/xblock_django/tests/test_commands.py index 322ee9384f..8a558b6beb 100644 --- a/common/djangoapps/xblock_django/tests/test_commands.py +++ b/common/djangoapps/xblock_django/tests/test_commands.py @@ -64,7 +64,7 @@ def test_compile_xblock_translations(tmp_translations_dir): 'msgfmt', '--check-format', '-o', str(po_file.with_suffix('.mo')), str(po_file), ], 'Compiles the .po files' - js_file_text = get_javascript_i18n_file_path('done', 'tr').text() + js_file_text = get_javascript_i18n_file_path('done', 'tr').read_text() assert 'Merhaba' in js_file_text, 'Ensures the JavaScript catalog is compiled' assert 'TestingDoneXBlockI18n' in js_file_text, 'Ensures the namespace is used' assert 'gettext' in js_file_text, 'Ensures the gettext function is defined' diff --git a/common/djangoapps/xblock_django/translation.py b/common/djangoapps/xblock_django/translation.py index 72726221aa..66f9642682 100644 --- a/common/djangoapps/xblock_django/translation.py +++ b/common/djangoapps/xblock_django/translation.py @@ -101,7 +101,7 @@ def compile_xblock_js_messages(): xblock_conf_locale_dir = xmodule_api.get_python_locale_root() / xblock_module i18n_js_namespace = xblock_class.get_i18n_js_namespace() - for locale_dir in xblock_conf_locale_dir.listdir(): + for locale_dir in xblock_conf_locale_dir.iterdir(): locale_code = str(locale_dir.basename()) locale_messages_dir = locale_dir / 'LC_MESSAGES' js_translations_domain = None diff --git a/common/static/common/js/components/BlockBrowser/.eslintrc.js b/common/static/common/js/components/BlockBrowser/.eslintrc.js deleted file mode 100644 index 752a664a53..0000000000 --- a/common/static/common/js/components/BlockBrowser/.eslintrc.js +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = { - extends: '@edx/eslint-config', - root: true, - settings: { - 'import/resolver': { - webpack: { - config: 'webpack.dev.config.js', - }, - }, - }, - rules: { - indent: ['error', 4], - 'react/jsx-indent': ['error', 4], - 'react/jsx-indent-props': ['error', 4], - 'import/extensions': 'off', - 'import/no-unresolved': 'off', - }, -}; diff --git a/common/static/common/js/components/BlockBrowser/data/api/client.js b/common/static/common/js/components/BlockBrowser/data/api/client.js index 2f00eaa08c..d8af3e20b0 100644 --- a/common/static/common/js/components/BlockBrowser/data/api/client.js +++ b/common/static/common/js/components/BlockBrowser/data/api/client.js @@ -1,5 +1,4 @@ import Cookies from 'js-cookie'; -import 'whatwg-fetch'; const COURSE_BLOCKS_API = '/api/courses/v1/blocks/'; diff --git a/common/static/css/vendor/hint.css b/common/static/css/vendor/hint.css new file mode 100644 index 0000000000..30602ade69 --- /dev/null +++ b/common/static/css/vendor/hint.css @@ -0,0 +1,283 @@ +/*! Hint.css - v1.3.1 - 2013-11-23 +* http://kushagragour.in/lab/hint/ +* Copyright (c) 2013 Kushagra Gour; Licensed MIT */ + +/*-------------------------------------*\ + HINT.css - A CSS tooltip library +\*-------------------------------------*/ +/** + * HINT.css is a tooltip library made in pure CSS. + * + * Source: https://github.com/chinchang/hint.css + * Demo: http://kushagragour.in/lab/hint/ + * + * Release under The MIT License + * + */ +/** + * source: hint-core.scss + * + * Defines the basic styling for the tooltip. + * Each tooltip is made of 2 parts: + * 1) body (:after) + * 2) arrow (:before) + * + * Classes added: + * 1) hint + */ +.hint, [data-hint] { + position: relative; + display: inline-block; + /** + * tooltip arrow + */ + /** + * tooltip body + */ } + .hint:before, .hint:after, [data-hint]:before, [data-hint]:after { + position: absolute; + -webkit-transform: translate3d(0, 0, 0); + -moz-transform: translate3d(0, 0, 0); + transform: translate3d(0, 0, 0); + visibility: hidden; + opacity: 0; + z-index: 1000000; + pointer-events: none; + -webkit-transition: 0.3s ease; + -moz-transition: 0.3s ease; + transition: 0.3s ease; } + .hint:hover:before, .hint:hover:after, .hint:focus:before, .hint:focus:after, [data-hint]:hover:before, [data-hint]:hover:after, [data-hint]:focus:before, [data-hint]:focus:after { + visibility: visible; + opacity: 1; } + .hint:before, [data-hint]:before { + content: ''; + position: absolute; + background: transparent; + border: 6px solid transparent; + z-index: 1000001; } + .hint:after, [data-hint]:after { + content: attr(data-hint); + background: #383838; + color: white; + text-shadow: 0 -1px 0px black; + padding: 8px 10px; + font-size: 12px; + line-height: 12px; + white-space: nowrap; + box-shadow: 4px 4px 8px rgba(0, 0, 0, 0.3); } + +/** + * source: hint-position.scss + * + * Defines the positoning logic for the tooltips. + * + * Classes added: + * 1) hint--top + * 2) hint--bottom + * 3) hint--left + * 4) hint--right + */ +/** + * set default color for tooltip arrows + */ +.hint--top:before { + border-top-color: #383838; } + +.hint--bottom:before { + border-bottom-color: #383838; } + +.hint--left:before { + border-left-color: #383838; } + +.hint--right:before { + border-right-color: #383838; } + +/** + * top tooltip + */ +.hint--top:before { + margin-bottom: -12px; } +.hint--top:after { + margin-left: -18px; } +.hint--top:before, .hint--top:after { + bottom: 100%; + left: 50%; } +.hint--top:hover:after, .hint--top:hover:before, .hint--top:focus:after, .hint--top:focus:before { + -webkit-transform: translateY(-8px); + -moz-transform: translateY(-8px); + transform: translateY(-8px); } + +/** + * bottom tooltip + */ +.hint--bottom:before { + margin-top: -12px; } +.hint--bottom:after { + margin-left: -18px; } +.hint--bottom:before, .hint--bottom:after { + top: 100%; + left: 50%; } +.hint--bottom:hover:after, .hint--bottom:hover:before, .hint--bottom:focus:after, .hint--bottom:focus:before { + -webkit-transform: translateY(8px); + -moz-transform: translateY(8px); + transform: translateY(8px); } + +/** + * right tooltip + */ +.hint--right:before { + margin-left: -12px; + margin-bottom: -6px; } +.hint--right:after { + margin-bottom: -14px; } +.hint--right:before, .hint--right:after { + left: 100%; + bottom: 50%; } +.hint--right:hover:after, .hint--right:hover:before, .hint--right:focus:after, .hint--right:focus:before { + -webkit-transform: translateX(8px); + -moz-transform: translateX(8px); + transform: translateX(8px); } + +/** + * left tooltip + */ +.hint--left:before { + margin-right: -12px; + margin-bottom: -6px; } +.hint--left:after { + margin-bottom: -14px; } +.hint--left:before, .hint--left:after { + right: 100%; + bottom: 50%; } +.hint--left:hover:after, .hint--left:hover:before, .hint--left:focus:after, .hint--left:focus:before { + -webkit-transform: translateX(-8px); + -moz-transform: translateX(-8px); + transform: translateX(-8px); } + +/** + * source: hint-color-types.scss + * + * Contains tooltips of various types based on color differences. + * + * Classes added: + * 1) hint--error + * 2) hint--warning + * 3) hint--info + * 4) hint--success + * + */ +/** + * Error + */ +.hint--error:after { + background-color: #b34e4d; + text-shadow: 0 -1px 0px #592726; } +.hint--error.hint--top:before { + border-top-color: #b34e4d; } +.hint--error.hint--bottom:before { + border-bottom-color: #b34e4d; } +.hint--error.hint--left:before { + border-left-color: #b34e4d; } +.hint--error.hint--right:before { + border-right-color: #b34e4d; } + +/** + * Warning + */ +.hint--warning:after { + background-color: #c09854; + text-shadow: 0 -1px 0px #6c5328; } +.hint--warning.hint--top:before { + border-top-color: #c09854; } +.hint--warning.hint--bottom:before { + border-bottom-color: #c09854; } +.hint--warning.hint--left:before { + border-left-color: #c09854; } +.hint--warning.hint--right:before { + border-right-color: #c09854; } + +/** + * Info + */ +.hint--info:after { + background-color: #3986ac; + text-shadow: 0 -1px 0px #193b4d; } +.hint--info.hint--top:before { + border-top-color: #3986ac; } +.hint--info.hint--bottom:before { + border-bottom-color: #3986ac; } +.hint--info.hint--left:before { + border-left-color: #3986ac; } +.hint--info.hint--right:before { + border-right-color: #3986ac; } + +/** + * Success + */ +.hint--success:after { + background-color: #458746; + text-shadow: 0 -1px 0px #1a321a; } +.hint--success.hint--top:before { + border-top-color: #458746; } +.hint--success.hint--bottom:before { + border-bottom-color: #458746; } +.hint--success.hint--left:before { + border-left-color: #458746; } +.hint--success.hint--right:before { + border-right-color: #458746; } + +/** + * source: hint-always.scss + * + * Defines a persisted tooltip which shows always. + * + * Classes added: + * 1) hint--always + * + */ +.hint--always:after, .hint--always:before { + opacity: 1; + visibility: visible; } +.hint--always.hint--top:after, .hint--always.hint--top:before { + -webkit-transform: translateY(-8px); + -moz-transform: translateY(-8px); + transform: translateY(-8px); } +.hint--always.hint--bottom:after, .hint--always.hint--bottom:before { + -webkit-transform: translateY(8px); + -moz-transform: translateY(8px); + transform: translateY(8px); } +.hint--always.hint--left:after, .hint--always.hint--left:before { + -webkit-transform: translateX(-8px); + -moz-transform: translateX(-8px); + transform: translateX(-8px); } +.hint--always.hint--right:after, .hint--always.hint--right:before { + -webkit-transform: translateX(8px); + -moz-transform: translateX(8px); + transform: translateX(8px); } + +/** + * source: hint-rounded.scss + * + * Defines rounded corner tooltips. + * + * Classes added: + * 1) hint--rounded + * + */ +.hint--rounded:after { + border-radius: 4px; } + +/** + * source: hint-effects.scss + * + * Defines various transition effects for the tooltips. + * + * Classes added: + * 1) hint--bounce + * + */ +.hint--bounce:before, .hint--bounce:after { + -webkit-transition: opacity 0.3s ease, visibility 0.3s ease, -webkit-transform 0.3s cubic-bezier(0.71, 1.7, 0.77, 1.24); + -moz-transition: opacity 0.3s ease, visibility 0.3s ease, -moz-transform 0.3s cubic-bezier(0.71, 1.7, 0.77, 1.24); + transition: opacity 0.3s ease, visibility 0.3s ease, transform 0.3s cubic-bezier(0.71, 1.7, 0.77, 1.24); } + diff --git a/common/static/data/geoip/GeoLite2-Country.mmdb b/common/static/data/geoip/GeoLite2-Country.mmdb index 4404d37420..f30dc00c6f 100644 Binary files a/common/static/data/geoip/GeoLite2-Country.mmdb and b/common/static/data/geoip/GeoLite2-Country.mmdb differ diff --git a/common/static/sass/_builtin-block-variables.scss b/common/static/sass/_builtin-block-variables.scss index e232cb57d5..f848e11774 100644 --- a/common/static/sass/_builtin-block-variables.scss +++ b/common/static/sass/_builtin-block-variables.scss @@ -63,7 +63,6 @@ --shadow-l1: $shadow-l1; --sidebar-color: $sidebar-color; --small-font-size: $small-font-size; - --static-path: $static-path; --submitted: $submitted; --success: $success; --tmg-f2: $tmg-f2; diff --git a/common/templates/xblock_v2/xblock_iframe.html b/common/templates/xblock_v2/xblock_iframe.html index 8b733373bd..cd3096aa46 100644 --- a/common/templates/xblock_v2/xblock_iframe.html +++ b/common/templates/xblock_v2/xblock_iframe.html @@ -1,6 +1,7 @@ - + + @@ -14,6 +15,8 @@ {% endif %} + + @@ -81,8 +113,14 @@ href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"> - - + + + + + + + + diff --git a/common/test/data/scoreable/about/overview.html b/common/test/data/scoreable/about/overview.html index d554b6a018..4c4e94bb9f 100644 --- a/common/test/data/scoreable/about/overview.html +++ b/common/test/data/scoreable/about/overview.html @@ -37,7 +37,7 @@

    What web browser should I use?

    The Open edX platform works best with current versions of Chrome, Firefox or Safari, or with Internet Explorer version 9 and above.

    -

    See our list of supported browsers for the most up-to-date information.

    +

    See our list of supported browsers for the most up-to-date information.

    diff --git a/common/test/data/toy/static/python_lib.zip b/common/test/data/toy/static/python_lib.zip new file mode 100644 index 0000000000..5854e82b68 Binary files /dev/null and b/common/test/data/toy/static/python_lib.zip differ diff --git a/common/test/pytest.ini b/common/test/pytest.ini index 5df48c9325..d93a742172 100644 --- a/common/test/pytest.ini +++ b/common/test/pytest.ini @@ -15,10 +15,6 @@ filterwarnings = ignore:.*You can remove default_app_config.*:PendingDeprecationWarning # ABC deprecation Warning comes from libsass ignore:Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated.*:DeprecationWarning:sass - # declare_namespace Warning comes from XBlock https://github.com/openedx/XBlock/issues/641 - # and also due to dependency: https://github.com/PyFilesystem/pyfilesystem2 - ignore:Deprecated call to `pkg_resources.declare_namespace.*:DeprecationWarning - ignore:.*pkg_resources is deprecated as an API.*:DeprecationWarning ignore:'etree' is deprecated. Use 'xml.etree.ElementTree' instead.:DeprecationWarning:wiki norecursedirs = .cache diff --git a/conf/locale/config.yaml b/conf/locale/config.yaml index 6faf3188be..0c906bec2b 100644 --- a/conf/locale/config.yaml +++ b/conf/locale/config.yaml @@ -26,7 +26,6 @@ ignore_dirs: # Directories that only contain tests. - common/test - test_root - - '*/terrain' - '*/spec' - '*/tests' - '*/djangoapps/*/features' @@ -54,3 +53,42 @@ ignore_dirs: third_party: - wiki - edx_proctoring_proctortrack + + +# How should .po files be segmented? See i18n/segment.py for details. Strings +# that are only found in a particular segment are segregated into that .po file +# so that translators can focus on separate parts of the product. +# +# We segregate Studio so we can provide new languages for LMS without having to +# also translate the Studio strings. LMS needs the strings from lms/* and +# common/*, so those will stay in the main .po file. +segment: + django-partial.po: # This .po file.. + django-studio.po: # produces this .po file.. + - cms/* # by segregating strings from these files. + # Anything that doesn't match a pattern stays in the original file. + djangojs-partial.po: + djangojs-studio.po: + - cms/* + mako.po: + mako-studio.po: + - cms/* + underscore.po: + underscore-studio.po: + - cms/* + +# How should the generate step merge files? +generate_merge: + django.po: + - django-partial.po + - django-studio.po + - mako.po + - mako-studio.po + - wiki.po + - edx_proctoring_proctortrack.po + djangojs.po: + - djangojs-partial.po + - djangojs-studio.po + - djangojs-account-settings-view.po + - underscore.po + - underscore-studio.po diff --git a/docs/concepts/extension_points.rst b/docs/concepts/extension_points.rst index d4e802baec..ac70edc432 100644 --- a/docs/concepts/extension_points.rst +++ b/docs/concepts/extension_points.rst @@ -69,12 +69,12 @@ If you want to provide learners with new content experiences within courses, opt For a more detailed comparison of content integration options, see `Options for Extending the edX Platform`_ in the *Open edX Developer's Guide*. -.. _XBlock tutorial: https://edx.readthedocs.io/projects/xblock-tutorial/en/latest/ -.. _as a consumer: https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/exercises_tools/lti_component.html -.. _as a provider: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/lti/ -.. _Options for Extending the edX Platform: https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/extending_platform/extending.html -.. _custom JavaScript application: https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/extending_platform/javascript.html -.. _external grader documentation: https://edx.readthedocs.io/projects/open-edx-ca/en/latest/exercises_tools/external_graders.html +.. _XBlock tutorial: https://docs.openedx.org/projects/xblock/en/latest/xblock-tutorial/index.html +.. _as a consumer: https://docs.openedx.org/en/latest/educators/navigation/components_activities.html#lti-component +.. _as a provider: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/lti/index.html +.. _Options for Extending the edX Platform: https://docs.openedx.org/en/latest/developers/references/developer_guide/extending_platform/extending.html +.. _custom JavaScript application: https://docs.openedx.org/en/latest/educators/references/course_development/exercise_tools/custom_javascript.html +.. _external grader documentation: https://docs.openedx.org/en/latest/educators/concepts/exercise_tools/about_external_graders.html .. _You can follow this guide to install and enable custom TinyMCE plugins: extensions/tinymce_plugins.rst @@ -139,10 +139,10 @@ Here are the different integration points that python plugins can use: - This decorator allows overriding any function or method by pointing to an alternative implementation in settings. Read the |pluggable_override docstring|_ to learn more. * - Open edX Events - Adopt, Stable - - Events are part of the greater Hooks Extension Framework for open extension of edx-platform. Events are a stable way for plugin developers to react to learner or author events. They are defined by a `separate events library`_ that developers can include in their requirements to develop and test the code without creating a dependency on this large repo. For more information see the `hooks guide`_. + - Events are part of the greater Hooks Extension Framework for open extension of edx-platform. Events are a stable way for plugin developers to react to learner or author events. They are defined by a `separate events library`_ that developers can include in their requirements to develop and test the code without creating a dependency on this large repo. For more information see the `Hooks Extension Framework docs`_ or for more detailed documentation about Open edX Events, see the `Open edX Events documentation`_. * - Open edX Filters - Adopt, Stable - - Filters are also part of Hooks Extension Framework for open extension of edx-platform. Filters are a flexible way for plugin developers to modify learner or author application flows. They are defined by a `separate filters library`_ that developers can include in their requirements to develop and test the code without creating a dependency on this large repo. For more information see the `hooks guide`_. + - Filters are also part of Hooks Extension Framework for open extension of edx-platform. Filters are a flexible way for plugin developers to modify learner or author application flows. They are defined by a `separate filters library`_ that developers can include in their requirements to develop and test the code without creating a dependency on this large repo. For more information see the `Hooks Extension Framework docs`_ or for more detailed documentation about Open edX Filters, see the `Open edX Filters documentation`_. .. _Application: https://docs.djangoproject.com/en/3.0/ref/applications/ .. _Django app plugin documentation: https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/plugins/README.rst @@ -150,7 +150,7 @@ Here are the different integration points that python plugins can use: .. _course tabs documentation: https://openedx.atlassian.net/wiki/spaces/AC/pages/30965919/Adding+a+new+course+tab .. |course_tools.py| replace:: ``course_tools.py`` .. _course_tools.py: https://github.com/openedx/edx-platform/blob/master/openedx/features/course_experience/course_tools.py -.. _Adding Custom Fields to the Registration Page: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/customize_registration_page.html +.. _Adding Custom Fields to the Registration Page: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/customize_registration_page.html .. |learning_context.py| replace:: ``learning_context.py`` .. _learning_context.py: https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/xblock/learning_context/learning_context.py .. |UserPartition docstring| replace:: ``UserPartition`` docstring @@ -159,7 +159,9 @@ Here are the different integration points that python plugins can use: .. _pluggable_override docstring: https://github.com/openedx/edx-django-utils/blob/master/edx_django_utils/plugins/pluggable_override.py .. _separate events library: https://github.com/eduNEXT/openedx-events/ .. _separate filters library: https://github.com/eduNEXT/openedx-filters/ -.. _hooks guide: https://github.com/openedx/edx-platform/blob/master/docs/guides/hooks/index.rst +.. _Hooks Extension Framework docs: https://docs.openedx.org/en/latest/developers/concepts/hooks_extension_framework.html +.. _Open edX Events documentation: https://docs.openedx.org/projects/openedx-events/en/latest/ +.. _Open edX Filters documentation: https://docs.openedx.org/projects/openedx-filters/en/latest/ Platform Look & Feel ==================== @@ -187,8 +189,8 @@ In addition, Open edX operators will be able to replace entire MFEs with complet .. |example edx theme| replace:: example ``edx`` theme .. _example edx theme: https://github.com/openedx/paragon/tree/master/scss/edx -.. _Changing Themes for an Open edX Site: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/changing_appearance/theming/ -.. _Overriding Brand Specific Elements: https://edx.readthedocs.io/projects/edx-developer-docs/en/latest/developers_guide/micro_frontends_in_open_edx.html#overriding-brand-specific-elements +.. _Changing Themes for an Open edX Site: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/changing_appearance/theming/index.html +.. _Overriding Brand Specific Elements: https://github.com/openedx/brand-openedx Custom frontends **************** diff --git a/docs/concepts/frontend/javascript.rst b/docs/concepts/frontend/javascript.rst index 4c0104a927..286cbaef77 100644 --- a/docs/concepts/frontend/javascript.rst +++ b/docs/concepts/frontend/javascript.rst @@ -1,10 +1,16 @@ JavaScript in edx-platform ========================== +All frontend code (JavaScript) has been deprecated in edx-platform, in favor of +MFEs. See ADR 0023-frontend-code-and-eslint-removal.rst for details. + +This documentation is being left in place until all of the JavaScript code +has been removed. + ES2015 ------ -All new JavaScript code in edx-platform should be written in ES2015. +All JavaScript code in edx-platform should be written in ES2015. ES2015 is not a framework or library -- rather, it is the latest and greatest revision of the JavaScript language itself, natively supported in all modern browsers and engines. Think of it as JavaScript's @@ -34,13 +40,7 @@ Adding a New ES2015 Module ~~~~~~~~~~~~~~~~~~~~~~~~~~ Don't mix ES2015 and ES5 modules within directories. If necessary, -create a new directory just for your new file. If you create a new -directory, run the following from edx-platform root to copy over an -appropriate eslint config: - -:: - - cp cms/static/js/features_jsx/.eslintrc.js path/to/your/directory +create a new directory just for your new file. Give your new file an UpperCamelCase filename, such as ``MyAwesomeModule.js``. If it is a React module, use the ``.jsx`` diff --git a/docs/concepts/testing/testing.rst b/docs/concepts/testing/testing.rst index 9d448afd5b..5e4248523c 100644 --- a/docs/concepts/testing/testing.rst +++ b/docs/concepts/testing/testing.rst @@ -1,4 +1,3 @@ -####### Testing ####### @@ -7,7 +6,7 @@ Testing :depth: 3 Overview -======== +******** We maintain two kinds of tests: unit tests and integration tests. @@ -26,10 +25,10 @@ tests. Most of our tests are unit tests or integration tests. Test Types ----------- +========== Unit Tests -~~~~~~~~~~ +---------- - Each test case should be concise: setup, execute, check, and teardown. If you find yourself writing tests with many steps, @@ -38,18 +37,18 @@ Unit Tests - As a rule of thumb, your unit tests should cover every code branch. -- Mock or patch external dependencies. We use the voidspace `Mock Library`_. +- Mock or patch external dependencies using `unittest.mock`_ functions. - We unit test Python code (using `unittest`_) and Javascript (using `Jasmine`_) -.. _Mock Library: http://www.voidspace.org.uk/python/mock/ +.. _unittest.mock: https://docs.python.org/3/library/unittest.mock.html .. _unittest: http://docs.python.org/2/library/unittest.html .. _Jasmine: http://jasmine.github.io/ Integration Tests -~~~~~~~~~~~~~~~~~ +----------------- - Test several units at the same time. Note that you can still mock or patch dependencies that are not under test! For example, you might test that @@ -67,7 +66,7 @@ Integration Tests .. _Django test client: https://docs.djangoproject.com/en/dev/topics/testing/overview/ Test Locations --------------- +============== - Python unit and integration tests: Located in subpackages called ``tests``. For example, the tests for the ``capa`` package are @@ -80,14 +79,29 @@ Test Locations the test for ``src/views/module.js`` should be written in ``spec/views/module_spec.js``. -Running Tests -============= +Factories +========= -**Unless otherwise mentioned, all the following commands should be run from inside the lms docker container.** +Many tests delegate set-up to a "factory" class. For example, there are +factories for creating courses, problems, and users. This encapsulates +set-up logic from tests. +Factories are often implemented using `FactoryBoy`_. + +In general, factories should be located close to the code they use. For +example, the factory for creating problem XML definitions is located in +``xmodule/capa/tests/response_xml_factory.py`` because the +``capa`` package handles problem XML. + +.. _FactoryBoy: https://readthedocs.org/projects/factoryboy/ Running Python Unit tests -------------------------- +************************* + +The following commands need to be run within a Python environment in +which requirements/edx/testing.txt has been installed. If you are using a +Docker-based Open edX distribution, then you probably will want to run these +commands within the LMS and/or CMS Docker containers. We use `pytest`_ to run Python tests. Pytest is a testing framework for python and should be your goto for local Python unit testing. @@ -97,16 +111,16 @@ Pytest (and all of the plugins we use with it) has a lot of options. Use `pytest Running Python Test Subsets -~~~~~~~~~~~~~~~~~~~~~~~~~~~ +=========================== When developing tests, it is often helpful to be able to really just run one single test without the overhead of PIP installs, UX builds, etc. Various ways to run tests using pytest:: - pytest path/test_m­odule.py # Run all tests in a module. - pytest path/test_m­odule.p­y:­:te­st_func # Run a specific test within a module. - pytest path/test_m­odule.p­y:­:Te­stC­las­s # Run all tests in a class - pytest path/test_m­odule.p­y:­:Te­stC­las­s::­tes­t_m­ethod # Run a specific method of a class. + pytest path/test_module.py # Run all tests in a module. + pytest path/test_module.py::test_func # Run a specific test within a module. + pytest path/test_module.py::TestClass # Run all tests in a class + pytest path/test_module.py::TestClass::test_method # Run a specific method of a class. pytest path/testing/ # Run all tests in a directory. For example, this command runs a single python unit test file:: @@ -114,7 +128,7 @@ For example, this command runs a single python unit test file:: pytest xmodule/tests/test_stringify.py Note - -edx-platorm has multiple services (lms, cms) in it. The environment for each service is different enough that we run some tests in both environments in Github Actions. +edx-platorm has multiple services (lms, cms) in it. The environment for each service is different enough that we run some tests in both environments in Github Actions. To test in each of these environments (especially for tests in "common" and "xmodule" directories), you will need to test in each seperately. To specify that the tests are run with the relevant service as root, Add --rootdir flag at end of your pytest call and specify the env to test in:: @@ -139,7 +153,7 @@ Various tools like ddt create tests with very complex names, rather than figurin pytest xmodule/tests/test_stringify.py --collectonly Testing with migrations -*********************** +======================= For the sake of speed, by default the python unit test database tables are created directly from apps' models. If you want to run the tests @@ -149,7 +163,7 @@ against a database created by applying the migrations instead, use the pytest test --create-db --migrations Debugging a test -~~~~~~~~~~~~~~~~ +================ There are various ways to debug tests in Python and more specifically with pytest: @@ -173,7 +187,7 @@ There are various ways to debug tests in Python and more specifically with pytes How to output coverage locally -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +============================== These are examples of how to run a single test and get coverage:: @@ -212,242 +226,83 @@ Use this command to generate an HTML report:: The report is then saved in reports/xmodule/cover/index.html -To run tests for stub servers, for example for `YouTube stub server`_, you can -run one of these commands:: - pytest --ds=cms.env.test common/djangoapps/terrain/stubs/tests/test_youtube_stub.py +Handling flaky unit tests +========================= -.. _YouTube stub server: https://github.com/openedx/edx-platform/blob/master/common/djangoapps/terrain/stubs/tests/test_youtube_stub.py +See this `confluence document `_. -Debugging Unittest Flakiness -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +Running JavaScript Unit Tests +***************************** -As we move over to running our unittests with Jenkins Pipelines and pytest-xdist, -there are new ways for tests to flake, which can sometimes be difficult to debug. -If you run into flakiness, check (and feel free to contribute to) this -`confluence document `__ for help. +Before running Javascript unit tests, you will need to be running Firefox or Chrome in a place visible to edx-platform. +If you are using Tutor Dev to run edx-platform, then you can do so by installing and enabling the +``test-legacy-js`` plugin from `openedx-tutor-plugins`_, and then rebuilding +the ``openedx-dev`` image:: -Running Javascript Unit Tests ------------------------------ + tutor plugins install https://github.com/openedx/openedx-tutor-plugins/tree/main/plugins/tutor-contrib-test-legacy-js + tutor plugins enable test-legacy-js + tutor images build openedx-dev -Before running Javascript unit tests, you will need to be running Firefox or Chrome in a place visible to edx-platform. If running this in devstack, you can run ``make dev.up.firefox`` or ``make dev.up.chrome``. Firefox is the default browser for the tests, so if you decide to use Chrome, you will need to prefix the test command with ``SELENIUM_BROWSER=chrome SELENIUM_HOST=edx.devstack.chrome`` (if using devstack). +.. _openedx-tutor-plugins: https://github.com/openedx/openedx-tutor-plugins/ -We use Jasmine to run JavaScript unit tests. To run all the JavaScript -tests:: +We use Jasmine (via Karma) to run most JavaScript unit tests. We use Jest to +run a small handful of additional JS unit tests. You can use the ``npm run +test*`` commands to run them:: - paver test_js + npm run test-karma # Run all Jasmine+Karma tests. + npm run test-jest # Run all Jest tests. + npm run test # Run both of the above. -To run a specific set of JavaScript tests and print the results to the -console, run these commands:: +The Karma tests are further broken down into three types depending on how the +JavaScript it is testing is built:: - paver test_js_run -s lms - paver test_js_run -s cms - paver test_js_run -s cms-squire - paver test_js_run -s xmodule - paver test_js_run -s xmodule-webpack - paver test_js_run -s common - paver test_js_run -s common-requirejs + npm run test-karma-vanilla # Our very oldest JS, which doesn't even use RequireJS + npm run test-karma-require # Old JS that uses RequireJS + npm run test-karma-webpack # Slightly "newer" JS which is built with Webpack -To run JavaScript tests in a browser, run these commands:: +Unfortunately, at the time of writing, the build for the ``test-karma-webpack`` +tests is broken. The tests are excluded from ``npm run test-karma`` as to not +fail CI. We `may fix this one day`_. - paver test_js_dev -s lms - paver test_js_dev -s cms - paver test_js_dev -s cms-squire - paver test_js_dev -s xmodule - paver test_js_dev -s xmodule-webpack - paver test_js_dev -s common - paver test_js_dev -s common-requirejs +.. _may fix this one day: https://github.com/openedx/edx-platform/issues/35956 -Debugging Specific Javascript Tests -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +To run all Karma+Jasmine tests for a particular top-level edx-platform folder, +you can run:: -The best way to debug individual tests is to run the test suite in the browser and -use your browser's Javascript debugger. The debug page will allow you to select -an individual test and only view the results of that test. + npm run test-cms + npm run test-lms + npm run test-xmodule + npm run test-common +Finally, if you want to pass any options to the underlying ``node`` invocation +for Karma+Jasmine tests, you can run one of these specific commands, and put +your arguments after the ``--`` separator:: -Debugging Tests in a Browser -~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + npm run test-cms-vanilla -- --your --args --here + npm run test-cms-require -- --your --args --here + npm run test-cms-webpack -- --your --args --here + npm run test-lms-webpack -- --your --args --here + npm run test-xmodule-vanilla -- --your --args --here + npm run test-xmodule-webpack -- --your --args --here + npm run test-common-vanilla -- --your --args --here + npm run test-common-require -- --your --args --here -To debug these tests on devstack in a local browser: -* first run the appropriate test_js_dev command from above -* open http://localhost:19876/debug.html in your host system's browser of choice -* this will run all the tests and show you the results including details of any failures -* you can click on an individually failing test and/or suite to re-run it by itself -* you can now use the browser's developer tools to debug as you would any other JavaScript code +Code Quality +************ -Note: the port is also output to the console that you ran the tests from if you find that easier. +We use several tools to analyze code quality. The full set of them is:: -These paver commands call through to Karma. For more -info, see `karma-runner.github.io `__. + mypy $PATHS... + pycodestyle $PATHS... + pylint $PATHS... + lint-imports + scripts/verify-dunder-init.sh + make xsslint + make pii_check + make check_keywords -Testing internationalization with dummy translations ----------------------------------------------------- - -Any text you add to the platform should be internationalized. To generate translations for your new strings, run the following command:: - - paver i18n_dummy - -This command generates dummy translations for each dummy language in the -platform and puts the dummy strings in the appropriate language files. -You can then preview the dummy languages on your local machine and also in your sandbox, if and when you create one. - -The dummy language files that are generated during this process can be -found in the following locations:: - - conf/locale/{LANG_CODE} - -There are a few JavaScript files that are generated from this process. You can find those in the following locations:: - - lms/static/js/i18n/{LANG_CODE} - cms/static/js/i18n/{LANG_CODE} - -Do not commit the ``.po``, ``.mo``, ``.js`` files that are generated -in the above locations during the dummy translation process! - -Test Coverage and Quality -------------------------- - -Viewing Test Coverage -~~~~~~~~~~~~~~~~~~~~~ - -We currently collect test coverage information for Python -unit/integration tests. - -To view test coverage: - -1. Run the test suite with this command:: - - paver test - -2. Generate reports with this command:: - - paver coverage - -3. Reports are located in the ``reports`` folder. The command generates - HTML and XML (Cobertura format) reports. - -Python Code Style Quality -~~~~~~~~~~~~~~~~~~~~~~~~~ - -To view Python code style quality (including PEP 8 and pylint violations) run this command:: - - paver run_quality - -More specific options are below. - -- These commands run a particular quality report:: - - paver run_pep8 - paver run_pylint - -- This command runs a report, and sets it to fail if it exceeds a given number - of violations:: - - paver run_pep8 --limit=800 - -- The ``run_quality`` uses the underlying diff-quality tool (which is packaged - with `diff-cover`_). With that, the command can be set to fail if a certain - diff threshold is not met. For example, to cause the process to fail if - quality expectations are less than 100% when compared to master (or in other - words, if style quality is worse than what is already on master):: - - paver run_quality --percentage=100 - -- Note that 'fixme' violations are not counted with run\_quality. To - see all 'TODO' lines, use this command:: - - paver find_fixme --system=lms - - ``system`` is an optional argument here. It defaults to - ``cms,lms,common``. - -.. _diff-cover: https://github.com/Bachmann1234/diff-cover - - -JavaScript Code Style Quality -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -To view JavaScript code style quality run this command:: - - paver run_eslint - -- This command also comes with a ``--limit`` switch, this is an example of that switch:: - - paver run_eslint --limit=50000 - - -Code Complexity Tools -===================== - -Tool(s) available for evaluating complexity of edx-platform code: - - -- `plato `__ for JavaScript code - complexity. Several options are available on the command line; see - documentation. Below, the following command will produce an HTML report in a - subdirectory called "jscomplexity":: - - plato -q -x common/static/js/vendor/ -t common -e .eslintrc.json -r -d jscomplexity common/static/js/ - -Other Testing Tips -================== - -Connecting to Browser ---------------------- - -If you want to see the browser being automated for JavaScript, -you can connect to the container running it via VNC. - -+------------------------+----------------------+ -| Browser | VNC connection | -+========================+======================+ -| Firefox (Default) | vnc://0.0.0.0:25900 | -+------------------------+----------------------+ -| Chrome (via Selenium) | vnc://0.0.0.0:15900 | -+------------------------+----------------------+ - -On macOS, enter the VNC connection string in Safari to connect via VNC. The VNC -passwords for both browsers are randomly generated and logged at container -startup, and can be found by running ``make vnc-passwords``. - -Most tests are run in Firefox by default. To use Chrome for tests that normally -use Firefox instead, prefix the test command with -``SELENIUM_BROWSER=chrome SELENIUM_HOST=edx.devstack.chrome`` - -Factories ---------- - -Many tests delegate set-up to a "factory" class. For example, there are -factories for creating courses, problems, and users. This encapsulates -set-up logic from tests. - -Factories are often implemented using `FactoryBoy`_. - -In general, factories should be located close to the code they use. For -example, the factory for creating problem XML definitions is located in -``xmodule/capa/tests/response_xml_factory.py`` because the -``capa`` package handles problem XML. - -.. _FactoryBoy: https://readthedocs.org/projects/factoryboy/ - -Running Tests on Paver Scripts ------------------------------- - -To run tests on the scripts that power the various Paver commands, use the following command:: - - pytest pavelib - -Testing using queue servers ---------------------------- - -When testing problems that use a queue server on AWS (e.g. -sandbox-xqueue.edx.org), you'll need to run your server on your public IP, like so:: - - ./manage.py lms runserver 0.0.0.0:8000 - -When you connect to the LMS, you need to use the public ip. Use -``ifconfig`` to figure out the number, and connect e.g. to -``http://18.3.4.5:8000/`` +Where ``$PATHS...`` is a list of folders and files to analyze, or nothing if +you would like to analyze the entire codebase (which can take a while). diff --git a/docs/conf.py b/docs/conf.py index 7352195cbb..0e6fa560d5 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -55,7 +55,6 @@ release = '' # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom # ones. extensions = [ - 'sphinx.ext.autodoc', 'sphinx.ext.coverage', 'sphinx.ext.doctest', 'sphinx.ext.graphviz', @@ -68,8 +67,23 @@ extensions = [ 'sphinx_design', 'code_annotations.contrib.sphinx.extensions.featuretoggles', 'code_annotations.contrib.sphinx.extensions.settings', + # 'autoapi.extension', # Temporarily disabled + 'sphinx_reredirects', ] +# Temporarily disabling autoapi_dirs and the AutoAPI extension due to performance issues. +# This will unblock ReadTheDocs builds and will be revisited for optimization. +# autoapi_type = 'python' +# autoapi_dirs = ['../lms/djangoapps', '../openedx/core/djangoapps', "../openedx/features"] +# +# autoapi_ignore = [ +# '*/migrations/*', +# '*/tests/*', +# '*.pyc', +# '__init__.py', +# '**/xblock_serializer/data.py', +# ] + # Rediraffe related settings. rediraffe_redirects = "redirects.txt" rediraffe_branch = 'origin/master' @@ -98,7 +112,6 @@ templates_path = ['_templates'] # You can specify multiple suffix as a list of string: # # source_suffix = ['.rst', '.md'] -source_suffix = '.rst' # The master toctree document. master_doc = 'index' @@ -274,16 +287,9 @@ if os.environ.get("READTHEDOCS", "") == "True": # Example configuration for intersphinx: refer to the Python standard library. intersphinx_mapping = { - 'django': ('https://docs.djangoproject.com/en/1.11/', 'https://docs.djangoproject.com/en/1.11/_objects/'), + 'django': ('https://docs.djangoproject.com/en/4.2/', 'https://docs.djangoproject.com/en/4.2/_objects/'), } -# Mock out these external modules during code import to avoid errors -autodoc_mock_imports = [ - 'MySQLdb', - 'django_mysql', - 'pymongo', -] - # Start building a map of the directories relative to the repository root to # run sphinx-apidoc against and the directories under "docs" in which to store # the generated *.rst files @@ -298,16 +304,23 @@ modules = { # 'xmodule': 'references/docstrings/xmodule', } +# Mapping permanently moved pages to appropriate new location outside of edx-platform +# with by sphinx-reredirects extension redirects. +# More information: https://documatt.com/sphinx-reredirects/usage.html + +redirects = { + 'hooks/events': 'https://docs.openedx.org/projects/openedx-events/en/latest/', + 'hooks/filters': 'https://docs.openedx.org/projects/openedx-filters/en/latest/', + 'hooks/index': 'https://docs.openedx.org/en/latest/developers/concepts/hooks_extension_framework.html', +} + def update_settings_module(service='lms'): """ Set the "DJANGO_SETTINGS_MODULE" environment variable appropriately for the module sphinx-apidoc is about to be run on. """ - if os.environ.get('EDX_PLATFORM_SETTINGS') == 'devstack_docker': - settings_module = f'{service}.envs.devstack_docker' - else: - settings_module = f'{service}.envs.devstack' + settings_module = f'{service}.envs.devstack' os.environ['DJANGO_SETTINGS_MODULE'] = settings_module diff --git a/docs/concepts/frontend/static_assets.rst b/docs/decisions/0000-static-asset-plan.rst similarity index 90% rename from docs/concepts/frontend/static_assets.rst rename to docs/decisions/0000-static-asset-plan.rst index 89e7a64f44..00fcc0726a 100644 --- a/docs/concepts/frontend/static_assets.rst +++ b/docs/decisions/0000-static-asset-plan.rst @@ -1,6 +1,22 @@ -####################################### -edx-platform Static Asset Pipeline Plan -####################################### +0. edx-platform Static Asset Pipeline Plan +########################################## + +Status +****** + +Accepted ~2017 +Partially adopted 2017-2024 + +This was an old plan for modernizing Open edX's frontend assets. We've +retroactively turned it into an ADR because it has some valuable insights. +Although most of these improvements weren't applied as written, these ideas +(particularly, separating Python concerns from frontend tooling concerns) were +applied to both legacy edx-platform assets as well as the Micro-Frontend +framework that was developed 2017-2019. + +Context, Decision, Consequences +******************************* + Static asset handling in edx-platform has evolved in a messy way over the years. This has led to a lot of complexity and inconsistencies. This is a proposal for @@ -9,20 +25,9 @@ this is not a detailed guide for how to write React or Bootstrap code. This is instead going to talk about conventions for how we arrange, extract, and compile static assets. -Big Open Questions (TODO) -************************* - -This document is a work in progress, as the design for some of this is still in -flux, particularly around extensibility. - -* Pluggable third party apps and Webpack packaging. -* Keep the Django i18n mechanism? -* Stance on HTTP/2 and bundling granularity. -* Optimizing theme assets. -* Tests Requirements -************ +============ Any proposed solution must support: @@ -35,7 +40,7 @@ Any proposed solution must support: * Other kinds of pluggability??? Assumptions -*********** +=========== Some assumptions/opinions that this proposal is based on: @@ -54,8 +59,8 @@ Some assumptions/opinions that this proposal is based on: * It should be possible to pre-build static assets and deploy them onto S3 or similar. -Where We Are Today -****************** +Where We Are Today (2017) +========================= We have a static asset pipeline that is mostly driven by Django's built-in staticfiles finders and the collectstatic process. We use the popular @@ -95,9 +100,9 @@ places (typically ``/edx/var/edxapp/staticfiles`` for LMS and ``/edx/var/edxapp/staticfiles/studio`` for Studio) and can be collected separately. However in practice they're always run together because we deploy them from the same commits and to the same servers. - + Django vs. Webpack Conventions -****************************** +============================== The Django convention for having an app with bundled assets is to namespace them locally with the app name so that they get their own directories when they are @@ -112,7 +117,7 @@ the root of edx-platform, which would specify all bundles in the project. TODO: The big, "pluggable Webpack components" question. Proposed Repo Structure -*********************** +======================= All assets that are in common spaces like ``common/static``, ``lms/static``, and ``cms/static`` would be moved to be under the Django apps that they are a @@ -122,7 +127,7 @@ part of and follow the Django naming convention (e.g. any client-side templates will be put in ``static/{appname}/templates``. Proposed Compiled Structure -*************************** +=========================== This is meant to be a sample of the different types of things we'd have, not a full list: @@ -150,14 +155,14 @@ full list: /theming/themes/open-edx /red-theme /edx.org - + # XBlocks still collect their assets into a common space (/xmodule goes away) # We consider this to be the XBlock Runtime's app, and it collects static # assets from installed XBlocks. /xblock Django vs. Webpack Roles -************************ +======================== Rule of thumb: Django/Python still serves static assets, Webpack processes and optimizes them. diff --git a/docs/decisions/0001-courses-in-lms.rst b/docs/decisions/0001-courses-in-lms.rst index f4f931aaa0..3c191a464e 100644 --- a/docs/decisions/0001-courses-in-lms.rst +++ b/docs/decisions/0001-courses-in-lms.rst @@ -21,8 +21,8 @@ In the LMS, the following technologies can be used to access course content and .. _edX DDD Ubiquitous Language: https://openedx.atlassian.net/wiki/spaces/AC/pages/188032048/edX+DDD+Ubiquitous+Language .. _Course Overviews: https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/content/course_overviews/__init__.py -.. _Course Blocks: https://openedx.atlassian.net/wiki/display/EDUCATOR/Course+Blocks -.. _Modulestore: https://edx.readthedocs.io/projects/edx-developer-guide/en/latest/modulestores/index.html +.. _Course Blocks: https://openedx.atlassian.net/wiki/spaces/AC/pages/158321366/Course+Blocks+aka+xblocks+components +.. _Modulestore: https://docs.openedx.org/projects/edx-platform/en/latest/references/docs/xmodule/modulestore/docs/overview.html Decisions ========= diff --git a/docs/decisions/0002-inter-app-apis.rst b/docs/decisions/0002-inter-app-apis.rst index 434ce17b3c..15cd43b83c 100644 --- a/docs/decisions/0002-inter-app-apis.rst +++ b/docs/decisions/0002-inter-app-apis.rst @@ -34,7 +34,7 @@ Decisions part of the app's domain API), then test-relevant Python APIs should be defined/exported in an intentional Python module called "api_for_tests.py". -Exmaples +Examples ~~~~~~~~ As a reference example, see the Python APIs exposed by the grades app in the diff --git a/docs/decisions/0004-managing-django-settings.rst b/docs/decisions/0004-managing-django-settings.rst index 6da93a7616..5f747d5d77 100644 --- a/docs/decisions/0004-managing-django-settings.rst +++ b/docs/decisions/0004-managing-django-settings.rst @@ -93,4 +93,4 @@ Cons: the public repository. * This wouldn't solve the problem where we would still try to "inherit" from other settings files and make it harder to read the current value of any given setting. -This alternative gives us a lot more power but it's power that we don't actually need. Building limitiations into what settings can be helps us keep them simple and understandable. +This alternative gives us a lot more power but it's power that we don't actually need. Building limitations into what settings can be helps us keep them simple and understandable. diff --git a/docs/decisions/0005-studio-lms-subdomain-boundaries.rst b/docs/decisions/0005-studio-lms-subdomain-boundaries.rst index 1090e3f0bb..34aa774cd2 100644 --- a/docs/decisions/0005-studio-lms-subdomain-boundaries.rst +++ b/docs/decisions/0005-studio-lms-subdomain-boundaries.rst @@ -74,7 +74,7 @@ of Content Groups to Cohorts. While this might sound a little cumbersome, it actually allows for a cleaner separation of concerns. Content Groups describe what the content is: restricted -copyright, advanced material, labratory exercises, etc. Cohorts describe who is +copyright, advanced material, laboratory exercises, etc. Cohorts describe who is consuming that material: on campus students, alumni, the general MOOC audience, etc. The Content Group is an Authoring decision based on the properties of the content itself. The Cohort mapping is a policy decision about the Learning diff --git a/docs/decisions/0021-fixing-quality-and-js-checks.rst b/docs/decisions/0021-fixing-quality-and-js-checks.rst new file mode 100644 index 0000000000..7d80039a8c --- /dev/null +++ b/docs/decisions/0021-fixing-quality-and-js-checks.rst @@ -0,0 +1,143 @@ +Fixing the Quality and JS checks +################################ + +Status +****** + +Accepted + +Implemented by https://github.com/openedx/edx-platform/pull/35159 + +Context +******* + +edx-platform PRs need to pass a series of CI checks before merging, including +but not limited to: a CLA check, various unit tests, and various code quality +tests. Of these checks, two checks were implemented using the "Paver" Python +package, a scripting library `which we have been trying to move off of`_. These +two checks and their steps were: + +* **Check: Quality others** + + * **pii_check**: Ensure that Django models have PII annotations as + described in `OEP-30`_, with a minimum threshold of **94.5%** of models + annotated. + * **stylelint**: Statically check sass stylesheets for common errors. + * **pep8**: Run pycodestyle against Python code. + * **eslint**: Statically check javascript code for common errors. + * **xsslint**: Check python & javascript for xss vulnerabilities. + * **check_keywords**: Compare Django model field names against a denylist of + reserved keywords. + +* **Check: JS** + + * **test-js**: Run javascript unit tests. + * **coverage-js**: Check that javascript test coverage has not dropped. + +As we worked to reimplement these checks without Paver, we unfortunately +noticed that four of those steps had bugs in their implementations, and thus +had not been enforcing what they promised to: + +* **pii_check**: Instead of just checking the result of the underlying + code_annotations command, this check wrote an annotations report to a file, + and then used regex to parse the report and determine whether the check + should pass. However, the check failed to validate that the generation of the + report itself was successful. So, when malformed annotations were introduced + to the edx-proctoring repository, which edx-platform installs, the check + began silently passing. + +* **stylelint**: At some point, the `stylelint` binary stopped being available + on the runner's `$PATH`. Rather than causing the Quality Others check to + fail, the Paver code quietly ignored the shell error, and considered the + empty stylelint report file to indicate that there were not linting + violations. + +* **test-js**: There are eight suites within test-js. Six of them work fine. + But three of them--specifically the suites that test code built by Webpack-- + have not been running for some unknown amount of time. The Webpack test build + has been failing without signalling that the test suite should fail, + both preventing the tests from runnning and preventing anyone from noticing + that the tests weren't running. + +* **coverage-js**: This check tried to use `diff-cover` in order to compare the + coverage report on the current branch with the coverage report on the master + branch. However, the coverage report does not exist on the master branch, and + it's not clear when it ever did. The coverage-js step failed to validate that + `diff-cover` ran successfully, and instead of raising an error, it allowed + the JS check to pass. + +Decision & Consequences +*********************** + +pii_check +========= + +We `fixed the malformed annotations`_ in edx-proctoring, allowing the pii_check +to once again check model coverage. We have ensured that any future failure of +the code_annotations command (due to, for example, future malformed +annotations) will cause the pii_check step and the overall Quality Others check +to fail. We have stopped trying to parse the result of the annotations report +in CI, as this was and is completely unneccessary. + +In order to keep "Quality others" passing on the edx-platform master branch, we +lowered the PII annotation coverage threshold to reflect the percentage of +then-annotated models: **71.6%**. After a timeboxed effort to add missing +annotations and expand the annotation allowlist as appropriate, we have managed +to raise the threshold to **85.3%**. It is not clear whether we will put in +further effort to raise the annotation threshold back to 95%. + +This was all already `announced on the forums`_. + +stylelint +========= + +We have removed the **stylelint** step entirely from the "Quality Others" +check. Sass code in the edx-platform repository will no longer be subject to +any static analysis. + +test-js +======= + +We have stopped running these Webpack-based suites in CI: + +* ``npm run test-lms-webpack`` +* ``npm run test-cms-webpack`` +* ``npm run test-xmodule-webpack`` + +We have created a new edx-platform backlog issue for +`fixing and re-enabling these suites`_. +It is not clear whether we will prioritize that issue, or instead prioritize +deprecation and removal of the code that those suites were supposed to be +testing. + +coverage-js +=========== + +We will remove the **coverage-js** step entirely from the "JS" check. +JavaScript code in the edx-platform repository will no longer be subject to any +unit test coverage checking. + +Rejected Alternatives +********************* + +* While it would be ideal to raise the pii_check threshold to 94.5% or even + 100%, we do not have the resources to promise this. + +* It would also be nice to institute a "racheting" mechanism for the PII + annotation coverage threshold. That is, every commit to master could save the + coverage percentage to a persisted artifact, allowing subsequent PRs to + ensure that the pii_check never returns lower than the current threshold. We + will put this in the Aximprovements backlog, but we cannot commit to + implementing it right now. + +* We will not fix or apply amnestly in order to re-enable stlylint or + coverage-js. That could take significant effort, which we believe would be + better spent completing the migration off of this legacy Sass and JS and onto + our modern React frontends. + + +.. _fixing and re-enabling these suites: https://github.com/openedx/edx-platform/issues/35956 +.. _which we have been trying to move off of: https://github.com/openedx/edx-platform/issues/34467 +.. _announced on the forums: https://discuss.openedx.org/t/checking-pii-annotations-with-a-lower-coverage-threshold/14254 +.. _OEP-30: https://docs.openedx.org/projects/openedx-proposals/en/latest/architectural-decisions/oep-0030-arch-pii-markup-and-auditing.html +.. _fix the malformed annotations: https://github.com/openedx/edx-proctoring/issues/1241 diff --git a/docs/decisions/0022-settings-simplification.rst b/docs/decisions/0022-settings-simplification.rst new file mode 100644 index 0000000000..ed10beeedc --- /dev/null +++ b/docs/decisions/0022-settings-simplification.rst @@ -0,0 +1,362 @@ +Django settings simplification +############################## + +Status +****** + +Accepted + +Implementation tracked by: https://github.com/openedx/edx-platform/issues/36215 + +Context +******* + +OEP-45 declares that sites will configure each IDA's (indepently-deployable +application's) Django settings with an ``_CFG`` YAML file, parsed and +loaded by a single upstream-provided ``DJANGO_SETTINGS_MODULE``. This contrasts +with the Django convention, which is that sites override Django settings using +their own ``DJANGO_SETTINGS_MODULE``. The rationale was that all Open edX +setting customization can be reasonably specified in YAML; therefore, it is +operationally safer to avoid using a custom ``DJANGO_SETTINGS_MODULE``, and it +is operationally desirable for all operation modes to execute the same Python +module for configuration. This was `briefly discussed in the oep-45 review +`_. + +For example, in theory, the upstream production LMS config might be named +``lms/settings/settings.py`` and work like this: + +* import ``lms/settings/required.py``, which declares settings that must be + overridden. +* import ``lms/settings/defaults.py``, which defines reasonable defaults for + all other settings. +* load ``/openedx/config/lms.yml``, which should override every setting + declared in required.py and override some settings defined in defaults.py. +* apply some minimal merging and/or conditional logic to handle yaml values + which are not simple overrides (e.g., ``FEATURES``, which needs to be + merged). + +The upstream production CMS config would exist in parallel. + +However, as of Sumac, we do not know of any site other than edx.org that +successfully uses only YAML files for configuration. Furthermore, the +upstream-provided ``DJANGO_SETTINGS_MODULE`` which loads these yaml files +(``lms/envs/production.py``) is not simple: it declares defaults, imports from +other Django settings modules, sets more defaults, handles dozens of special +cases, and has a special Open-edX-specific "derived settings" mechanism to +handle settings that depend on other settings. + +Tutor does provide YAML files, but *it also has custom production and +development settings files*! The result is that we have multiple layers of +indirection between edx-platform's common base settings, and the Django +settings rendered into the actual community-supported Open edX distribution +(Tutor). Specifically, production edx-platform configuration currently works +like this: + +* ``lms/envs/tutor/production.py``... + + * is generated by Tutor from the template + ``tutor/templates/apps/openedx/settings/lms/production.py``, + + * which derives + ``tutor/templates/apps/openedx/settings/partials/common_lms.py``, + + * which derives + ``tutor/templates/apps/openedx/settings/partials/common_all.py``; + + * and uses templates vars from Tutor configuration (``config.yml``), + + * and invokes hooks from any enabled Tutor plugins; + + * it imports ``lms/envs/production.py``, + + * which imports ``lms/envs/common.py``, + + * which sets production-inappropriate defaults; + + * it sets more defaults, some of them edX.org-specific; + + * it loads ``/openedx/config/lms.yml``... + + * which is generated by Tutor from template + ``tutor/templates/apps/openedx/config/lms.env.yml`` + + * which derives + ``tutor/templates/apps/openedx/config/partials/auth.yml``; + + * it reverts some of ``lms.yml`` with new "defaults"; + + * and it uses certain values from ``/openedx/config/lms.yml`` to + conditionally override more settings and update certain dictionary + settings, in a way which is not documented. + +* ``cms/envs/tutor/production.py``... + + * is generated by Tutor from the template + ``tutor/templates/apps/openedx/settings/cms/production.py``, + + * which derives + ``tutor/templates/apps/openedx/settings/partials/common_cms.py``, + + * which derives + ``tutor/templates/apps/openedx/settings/partials/common_all.py``; + + * and uses templates vars from Tutor configuration (``config.yml``), + + * and invokes hooks from any enabled Tutor plugins; + + * it imports ``cms/envs/production.py``, + + * it imports ``cms/envs/common.py``, which sets production-inappropriate + defaults, + + * and which imports ``lms/envs/common.py``, which also sets + production-inappropriate defaults; + + * it sets more defaults, some of the edX.org-specific; + + * it loads ``/openedx/config/cms.yml``... + + * which is generated by Tutor from template + ``tutor/templates/apps/openedx/config/cms.env.yml`` + + * which derives + ``tutor/templates/apps/openedx/config/partials/auth.yml``; + + * it reverts some of ``/openedx/config/cms.yml`` with new "defaults"; + + * and it uses certain values from ``/openedx/config/cms.yml`` to + conditionally override more settings and update certain dictionary + settings, in a way which is not documented. + +This is very difficult to reason about. Configuration complexity is frequently +cited as a chief area of pain for Open edX developers and operators. +Discussions in the Named Release Planning and Build-Test-Release Working Groups +frequently are encumbered with confusion and uncertainty of what the default +settings are in edx-platform, how they differ from Tutor's default settings, +what settings can be overriden, and how to do so. Only a minority of developers +and operators fully understand the configuration logic described above +end-to-end; even for those that do, following this override chain for any given +Django setting is time-consuming and error-prone. CAT-1 bugs and high-severity +security vulnerabilities have arisen due to misunderstanding of how +edx-platform Django settings are rendered. + +Developers are frequently instructed that if they need to override a Django +setting, the preferred way to do so is to "make a Tutor plugin". This is a +large amount of prior knowledge, boilerplate, and indirection, all required +to simply do something which Django provides out-of-the-box via a custom +``DJANGO_SETTINGS_MODULE``. + +Finally, it is worth nothing that all the complexity and toil exists alongside +other edx-platform configuration methods, such as Waffle, configuration models, +site configuration, XBlock configuration, and entry points. Those configuration +pathways are outside of the scope of this ADR, but are mentioned to demonstrate +the distressing level of complexity that developers and operators face when +working with the platform. + +Decision & Consequences +*********************** + +Overview +======== + +We orient edx-platform towards using standard Django settings configuration +patterns. Specifically, we will make it easy for operators to override settings +by supplying a custom ``DJANGO_SETTINGS_MODULE``. + +Moving towards this goals will need to be an iterative and careful process, +and it's likely that some aspects of the target structure or plan (described +below) will need to updated along the way. Nonetheless, once it becomes clear +that we are landing on a solid settings structure for edx-platform, we'll +propose an OEP-45 update to generalize the structure to all deployable Open edX +Django applications. + +Finally, based on what we learn throughout this process, our OEP-45 propsal +will either recommend to: + +1. Drop support for the ``_CFG`` YAML files, or + +2. Simplify the ``_CFG`` YAML schema, document it, and clarify that it + is an optional alternative to ``DJANGO_SETTINGS_MODULE`` rather than the + required/preferred configuration method. + +At the time, we do not have enough information whether option 1 or 2 would be +more beneficial overall to the community. +`The discussion on this sub-decisision will continue on this GitHub issue `_. + +Target settings structure for edx-platform +========================================== + +* ``openedx/envs/common.py``: Define as much shared configuration between LMS + and CMS as possible, including: (a) where possible, annotated definitions of + edx-platform-specific settings with *reasonable, production-ready* defaults; + (b) otherwise, annotated definitions of edx-platform-specific settings (like + secrets) with *obviously-wrong* defaults, ensuring they aren't used in + production; and (c) reasonable production-ready overrides of third-party + settings, ideally with explanatory comments (but not annotations). When a + particular setting's default should depend on the *final* value of another + setting, the former should be assigned to a + ``Derived(...)`` value, where ``...`` is a computation based on the latter. + + * ``lms/envs/common.py``: Extend ``openedx/envs/common.py`` to create, as + much as possible, a production-ready settings file for the LMS. These + extension may include: (a) annotated definitions of LMS-specific settings + with production-ready defaults; (b) annotated definitions of LMS-specific + settings with obviously-wrong defaults; and (c) LMS-specific + overrides of settings defined in ``openedx/envs/common.py`` and of + third-party settings, ideally with explanatory comments (but not + annotations). Again, ``Derived`` settings can be used as appropriate. This + will be the default settings file for running LMS management commands, + although tools can override this (as usual) by specifying a + ``DJANGO_SETTINGS_MODULE``. + + * ``lms/envs/test.py``: Override LMS settings for unit tests. Should work + in a local venv as well as in CI. Needs to invoke ``derive_settings`` in + order to render all previously-defined ``Derived`` settings. + + * ``lms/envs/yaml.py`` (only if we decide to retain YAML support): + Loads overrides from a YAML File at ``LMS_CFG``, plus some well-defined + special handling for mergable values like ``FEATURES``. This is adapted + from and replaces lms/envs/production.py. It will invoke + ``derive_settings``. + + * ``/lms_prod.py`` (example path): Open edX sites that do not + use ``lms/envs/yaml.py`` will instead have to have their + ``DJANGO_SETTINGS_MODULE`` environment variable pointed at a custom + settings module, derived from ``lms/envs/common.py``. It is important + that this module both (i) replaces the obviously-wrong settings with + appropriate production settings, and (ii) invokes ``derive_settings`` to + render all previously-defined ``Derived`` settings. If we decide not to + retain YAML support, then *every* Open edX deployment will need to be + pointed at such a custom settings module settings file, either maintained + by the operator or by a downstream tool (like Tutor). + + * ``lms/envs/dev.py``: Override LMS settings so that it can run + "bare metal" directly on a developer's local machine using debug-friendly + settings. Will use ``local.openedx.io`` (which resolves to 127.0.0.1) as + a base domain, which should be suitable for downstream tools as well. It + will invoke ``derive_settings``. + + * ``/lms_dev.py`` (example path): In order to + run the LMS, downstream tools (like Tutor, and 2U's devstack) will + need to separately maintain their own custom settings module derived + from ``lms/envs/dev.py``, and point their + ``DJANGO_SETTINGS_MODULE`` environment variable at this module. + + * ``cms/envs/common.py`` + + * ``cms/envs/test.py`` + + * ``cms/envs/yaml.py`` (only if we decide to retain YAML support) + + * ``/cms_prod.py`` (example path) + + * ``cms/envs/dev.py`` + + * ``/cms_dev.py`` (example path) + +Plan of action +============== + +These steps are non-breaking unless noted. + +* Introduce a dump_settings management command so that we can more easily + validate changes (or lack thereof) to the terminal edx-platform settings + modules. + +* Improve edx-platform's API for + deriving settings, as we are about to depend on it significantly more than we + currently do. This is a potentially BREAKING CHANGE to any downstream + settings files which imported from ``openedx.core.lib.derived``. + +* Remove redundant overrides in (cms,lms)/envs/production.py. Use Derived + settings defaults to further simplify the module without changing its output. + +* Create openedx/envs/common.py, ensuring that any annotations defined in it + are included in the edx-platform reference docs build. Move settings which + are shared between (cms,lms)/envs/common.py into openedx/envs/common.py. This + may be iteratively done across multiple PRs. + +* Find the best production-ready defaults between both + (lms,cms)/envs/production.py and Tutor's production.pys, and "bubble" them up + to (openedx,cms,lms)/common.py. Keep (lms,cms)/envs/production.py unchanged + through this process. This is a BREAKING CHANGE for any operator that derives + from (lms,cms)/envs/common.py directly. Most operators derive from + (lms,cms)/envs/production.py, so we do not expect this to affect many sites, + if any. + +* Develop (cms,lms)/envs/dev based off of (cms,lms)/envs/common.py. + Iterate until we can run "bare metal" development server for LMS and CMS + using these settings. + +* Deprecate and remove (cms,lms)/envs/devstack.py. This is a BREAKING CHANGE to + downstream development tools (like Tutor and 2U's devstack), as they will + now either need to maintain local copies of these modules, or "rebase" + themselves onto (lms,cms)/envs/dev.py. + +* Propose and, if accepted, implement an update to OEP-45 (Configuring and + Operating Open edX). `Progress on this update is tracked here`_. As mentioned + in the Decision section, based on (a) what we learn from previous steps and + (b) discussion in `"Should we continue to support YAML settings?" `_ + this update will either: + + 1. Revoke the OEP-45 sections regarding YAML. Deprecate and remove + (cms,lms)/envs/production.py. This is a BREAKING CHANGE for tools and + providers that use these settings modules, as they will either need to + maintain local copies of these modules, or "rebase" their internal + settings modules onto (cms,lms)/envs/common.py. Update operator + documenation as needed. + + 2. Update OEP-45 to clarify that YAML configuration is + optional. Operators can opt out of YAML by deriving directly from + (cms,lms)/envs/common.py, or they can opt into YAML by using + (cms,lms)/envs/yaml.py. Document a simplified YAML schema in OEP-45. + There will be several well-communicated BREAKING CHANGES in YAML behavior + in order to achieve the simplified schema. Furthermore, the rename of + (cms,lms)/envs/production.py to (cms,lms)/envs/yaml.py will be a BREAKING + CHANGE. + +* Create tickets to achieve a similar OEP-45-compliant settings structure in + any IDAs (independently-deployable applications) which exist in the openedx + GitHub organization, such as the Credentials service. + +.. _Progress on this update is tracked here: https://github.com/openedx/open-edx-proposals/issues/587 + +Alternatives Considered +*********************** + +One alternative settings structure +================================== + + +Here is an alternate structure that would de-dupe any shared LMS/CMS dev & test +logic by creating more shared modules within openedx/envs folder. Although +DRYer, this structure would increase the total number of edx-platform files and +potentially encourage more LMS-CMS coupling. So, we will not pursue this +structure, but will keep it in mind as an alternative if we enounter +difficulties with the plan laid out in this ADR. + +* ``openedx/envs/common.py`` + + * ``lms/envs/prod.py`` + + * ``/lms_prod.py`` + + * ``cms/envs/prod.py`` + + * ``/cms_prod.py`` + + * ``openedx/envs/test.py`` + + * ``lms/envs/test.py`` + + * ``cms/envs/test.py`` + + * ``openedx/envs/dev.py`` + + * ``lms/envs/dev.py`` + + * ``/lms_dev.py`` + + * ``cms/envs/dev.py`` + + * ``/cms_dev.py`` diff --git a/docs/decisions/0023-frontend-code-and-eslint-removal.rst b/docs/decisions/0023-frontend-code-and-eslint-removal.rst new file mode 100644 index 0000000000..3e683b1d9a --- /dev/null +++ b/docs/decisions/0023-frontend-code-and-eslint-removal.rst @@ -0,0 +1,48 @@ +Frontend code and ESLint removal +################################ + +Status +****** + +Accepted + +Context +******* + +Over many years work has been underway to extract frontend code from +edx-platform, to be replaced by MFEs. + +Additionally, as of March 2025, edx-platform had more than 700 violations in +ESLint. + +For more details on the MFE replacement, see: + +- Top-level issue for edx-platform: https://github.com/openedx/edx-platform/issues/31620 +- The parent issue of the above issue, which includes IDA frontends: https://github.com/openedx/wg-frontend/issues/156 +- Details of the replacement MFEs are noted in the `MFE Rewrite Tracker`_. + +.. _MFE Rewrite Tracker: https://openedx.atlassian.net/wiki/spaces/COMM/pages/4262363137/MFE+Rewrite+Tracker + +Decision +******** + +Over these years of work, it was decided that all frontend code should +ultimately be removed from edx-platform. Until this time, there has not yet +been a single ADR or DEPR to capture this decision. + +This decision record is to document this past decision. It is ok to add +additional links or details over time to clarify how this extraction will be +accomplished, or to one day celebrate its completion. + +Additionally, it has been decided to preemptively remove ESLint. This will +ensure that engineers can stay focused on higher priority work, rather than +spending time fixing linting issues in JavaScript that will simply be removed. +This removal is important because github has started posting these violations +in github comments that make this work seem like a priority. At the very least, +these are annoying messages that clutter up PRs. + +Consequences +************ + +We will continue to replace all frontend code in edx-platform with an +appropriate set of MFEs. diff --git a/docs/docs_settings.py b/docs/docs_settings.py index d5164edfa1..0e01116390 100644 --- a/docs/docs_settings.py +++ b/docs/docs_settings.py @@ -38,6 +38,7 @@ INSTALLED_APPS.extend( "cms.djangoapps.xblock_config.apps.XBlockConfig", "lms.djangoapps.lti_provider", "openedx.core.djangoapps.content.search", + "openedx.core.djangoapps.content_staging", ] ) diff --git a/docs/hooks/events.rst b/docs/hooks/events.rst deleted file mode 100644 index bccb98e56a..0000000000 --- a/docs/hooks/events.rst +++ /dev/null @@ -1,261 +0,0 @@ -Open edX Events -=============== - -How to use ----------- - -Using openedx-events in your code is very straight forward. We can consider the -two possible cases, sending or receiving an event. - - -Receiving events -^^^^^^^^^^^^^^^^ - -This is one of the most common use cases for plugins. The edx-platform will send -an event and you want to react to it in your plugin. - -For this you need to: - -1. Include openedx-events in your dependencies. -2. Connect your receiver functions to the signals being sent. - -Connecting signals can be done using regular django syntax: - -.. code-block:: python - - from openedx_events.learning.signals import SESSION_LOGIN_COMPLETED - - @receiver(SESSION_LOGIN_COMPLETED) - # your receiver function here - - -Or at the apps.py - -.. code-block:: python - - "signals_config": { - "lms.djangoapp": { - "relative_path": "your_module_name", - "receivers": [ - { - "receiver_func_name": "your_receiver_function", - "signal_path": "openedx_events.learning.signals.SESSION_LOGIN_COMPLETED", - }, - ], - } - }, - -In case you are listening to an event in the edx-platform repo, you can directly -use the django syntax since the apps.py method will not be available without the -plugin. - - -Sending events -^^^^^^^^^^^^^^ - -Sending events requires you to import both the event definition as well as the -attr data classes that encapsulate the event data. - -.. code-block:: python - - from openedx_events.learning.data import UserData, UserPersonalData - from openedx_events.learning.signals import STUDENT_REGISTRATION_COMPLETED - - STUDENT_REGISTRATION_COMPLETED.send_event( - user=UserData( - pii=UserPersonalData( - username=user.username, - email=user.email, - name=user.profile.name, - ), - id=user.id, - is_active=user.is_active, - ), - ) - -You can do this both from the edx-platform code as well as from an openedx -plugin. - - -Testing events -^^^^^^^^^^^^^^ - -Testing your code in CI, specially for plugins is now possible without having to -import the complete edx-platform as a dependency. - -To test your functions you need to include the openedx-events library in your -testing dependencies and make the signal connection in your test case. - -.. code-block:: python - - from openedx_events.learning.signals import STUDENT_REGISTRATION_COMPLETED - - def test_your_receiver(self): - STUDENT_REGISTRATION_COMPLETED.connect(your_function) - STUDENT_REGISTRATION_COMPLETED.send_event( - user=UserData( - pii=UserPersonalData( - username='test_username', - email='test_email@example.com', - name='test_name', - ), - id=1, - is_active=True, - ), - ) - - # run your assertions - - -Changes in the openedx-events library that are not compatible with your code -should break this kind of test in CI and let you know you need to upgrade your -code. - - -Live example -^^^^^^^^^^^^ - -For a complete and detailed example you can see the `openedx-events-2-zapier`_ -plugin. This is a fully functional plugin that connects to -``STUDENT_REGISTRATION_COMPLETED`` and ``COURSE_ENROLLMENT_CREATED`` and sends -the relevant information to zapier.com using a webhook. - -.. _openedx-events-2-zapier: https://github.com/eduNEXT/openedx-events-2-zapier - - -Index of Events ------------------ - -This list contains the events currently being sent by edx-platform. The provided -links target both the definition of the event in the openedx-events library as -well as the trigger location in this same repository. - - -Learning Events -^^^^^^^^^^^^^^^ - -.. list-table:: - :widths: 35 50 20 - - * - *Name* - - *Type* - - *Date added* - - * - `STUDENT_REGISTRATION_COMPLETED `_ - - org.openedx.learning.student.registration.completed.v1 - - `2022-06-14 `_ - - * - `SESSION_LOGIN_COMPLETED `_ - - org.openedx.learning.auth.session.login.completed.v1 - - `2022-06-14 `_ - - * - `COURSE_ENROLLMENT_CREATED `_ - - org.openedx.learning.course.enrollment.created.v1 - - `2022-06-14 `_ - - * - `COURSE_ENROLLMENT_CHANGED `_ - - org.openedx.learning.course.enrollment.changed.v1 - - `2022-06-14 `_ - - * - `COURSE_UNENROLLMENT_COMPLETED `_ - - org.openedx.learning.course.unenrollment.completed.v1 - - `2022-06-14 `_ - - * - `CERTIFICATE_CREATED `_ - - org.openedx.learning.certificate.created.v1 - - `2022-06-14 `_ - - * - `CERTIFICATE_CHANGED `_ - - org.openedx.learning.certificate.changed.v1 - - `2022-06-14 `_ - - * - `CERTIFICATE_REVOKED `_ - - org.openedx.learning.certificate.revoked.v1 - - `2022-06-14 `_ - - * - `COHORT_MEMBERSHIP_CHANGED `_ - - org.openedx.learning.cohort_membership.changed.v1 - - `2022-06-14 `_ - - * - `COURSE_DISCUSSIONS_CHANGED `_ - - org.openedx.learning.discussions.configuration.changed.v1 - - `2022-06-14 `_ - - -Content Authoring Events -^^^^^^^^^^^^^^^^^^^^^^^^ - -.. list-table:: - :widths: 35 50 20 - - * - *Name* - - *Type* - - *Date added* - - * - `COURSE_CATALOG_INFO_CHANGED `_ - - org.openedx.content_authoring.course.catalog_info.changed.v1 - - `2022-08-24 `_ - - * - `XBLOCK_PUBLISHED `_ - - org.openedx.content_authoring.xblock.published.v1 - - `2022-12-06 `_ - - * - `XBLOCK_DELETED `_ - - org.openedx.content_authoring.xblock.deleted.v1 - - `2022-12-06 `_ - - * - `XBLOCK_DUPLICATED `_ - - org.openedx.content_authoring.xblock.duplicated.v1 - - `2022-12-06 `_ - - * - `XBLOCK_CREATED `_ - - org.openedx.content_authoring.xblock.created.v1 - - 2023-07-20 - - * - `XBLOCK_UPDATED `_ - - org.openedx.content_authoring.xblock.updated.v1 - - 2023-07-20 - - * - `COURSE_CREATED `_ - - org.openedx.content_authoring.course.created.v1 - - 2023-07-20 - - * - `CONTENT_LIBRARY_CREATED `_ - - org.openedx.content_authoring.content_library.created.v1 - - 2023-07-20 - - * - `CONTENT_LIBRARY_UPDATED `_ - - org.openedx.content_authoring.content_library.updated.v1 - - 2023-07-20 - - * - `CONTENT_LIBRARY_DELETED `_ - - org.openedx.content_authoring.content_library.deleted.v1 - - 2023-07-20 - - * - `LIBRARY_BLOCK_CREATED `_ - - org.openedx.content_authoring.library_block.created.v1 - - 2023-07-20 - - * - `LIBRARY_BLOCK_UPDATED `_ - - org.openedx.content_authoring.library_block.updated.v1 - - 2023-07-20 - - * - `LIBRARY_BLOCK_DELETED `_ - - org.openedx.content_authoring.library_block.deleted.v1 - - 2023-07-20 - - * - `LIBRARY_COLLECTION_CREATED `_ - - org.openedx.content_authoring.content_library.collection.created.v1 - - 2024-08-23 - - * - `LIBRARY_COLLECTION_UPDATED `_ - - org.openedx.content_authoring.content_library.collection.updated.v1 - - 2024-08-23 - - * - `LIBRARY_COLLECTION_DELETED `_ - - org.openedx.content_authoring.content_library.collection.deleted.v1 - - 2024-08-23 - - * - `CONTENT_OBJECT_ASSOCIATIONS_CHANGED `_ - - org.openedx.content_authoring.content.object.associations.changed.v1 - - 2024-09-06 diff --git a/docs/hooks/filters.rst b/docs/hooks/filters.rst deleted file mode 100644 index b2ce68fc14..0000000000 --- a/docs/hooks/filters.rst +++ /dev/null @@ -1,191 +0,0 @@ -Open edX Filters -================ - -How to use ----------- - -Using openedx-filters in your code is very straight forward. We can consider the -two possible cases: - -Configuring a filter -^^^^^^^^^^^^^^^^^^^^ - -Implement pipeline steps -************************ - -Let's say you want to consult student's information with a third party service -before generating the students certificate. This is a common use case for filters, -where the functions part of the filter's pipeline will perform the consulting tasks and -decide the execution flow for the application. These functions are the pipeline steps, -and can be implemented in an installable Python library: - -.. code-block:: python - - # Step implementation taken from openedx-filters-samples plugin - from openedx_filters import PipelineStep - from openedx_filters.learning.filters import CertificateCreationRequested - - class StopCertificateCreation(PipelineStep): - - def run_filter(self, user, course_id, mode, status): - # Consult third party service and check if continue - # ... - # User not in third party service, denied certificate generation - raise CertificateCreationRequested.PreventCertificateCreation( - "You can't generate a certificate from this site." - ) - -There's two key components to the implementation: - -1. The filter step must be a subclass of ``PipelineStep``. - -2. The ``run_filter`` signature must match the filters definition, eg., -the previous step matches the method's definition in CertificateCreationRequested. - -Attach/hook pipeline to filter -****************************** - -After implementing the pipeline steps, we have to tell the certificate creation -filter to execute our pipeline. - -.. code-block:: python - - OPEN_EDX_FILTERS_CONFIG = { - "org.openedx.learning.certificate.creation.requested.v1": { - "fail_silently": False, - "pipeline": [ - "openedx_filters_samples.samples.pipeline.StopCertificateCreation" - ] - }, - } - -Triggering a filter -^^^^^^^^^^^^^^^^^^^ - -In order to execute a filter in your own plugin/library, you must install the -plugin where the steps are implemented and also, ``openedx-filters``. - -.. code-block:: python - - # Code taken from lms/djangoapps/certificates/generation_handler.py - from openedx_filters.learning.filters import CertificateCreationRequested - - try: - self.user, self.course_id, self.mode, self.status = CertificateCreationRequested.run_filter( - user=self.user, course_id=self.course_id, mode=self.mode, status=self.status, - ) - except CertificateCreationRequested.PreventCertificateCreation as exc: - raise CertificateGenerationNotAllowed(str(exc)) from exc - -Testing filters' steps -^^^^^^^^^^^^^^^^^^^^^^ - -It's pretty straightforward to test your pipeline steps, you'll need to include the -``openedx-filters`` library in your testing dependencies and configure them in your test case. - -.. code-block:: python - - from openedx_filters.learning.filters import CertificateCreationRequested - - @override_settings( - OPEN_EDX_FILTERS_CONFIG={ - "org.openedx.learning.certificate.creation.requested.v1": { - "fail_silently": False, - "pipeline": [ - "openedx_filters_samples.samples.pipeline.StopCertificateCreation" - ] - } - } - ) - def test_certificate_creation_requested_filter(self): - """ - Test filter triggered before the certificate creation process starts. - - Expected results: - - The pipeline step configured for the filter raises PreventCertificateCreation - when the conditions are met. - """ - with self.assertRaises(CertificateCreationRequested.PreventCertificateCreation): - CertificateCreationRequested.run_filter( - user=self.user, course_key=self.course_key, mode="audit", - ) - - # run your assertions - -Changes in the ``openedx-filters`` library that are not compatible with your code -should break this kind of test in CI and let you know you need to upgrade your code. -The main limitation while testing filters' steps it's their arguments, as they are edxapp -memory objects, but that can be solved in CI using Python mocks. - -Live example -^^^^^^^^^^^^ - -For filter steps samples you can visit the `openedx-filters-samples`_ plugin, where -you can find minimal steps exemplifying the different ways on how to use -``openedx-filters``. - -.. _openedx-filters-samples: https://github.com/eduNEXT/openedx-filters-samples - - -Index of Filters ------------------ - -This list contains the filters currently being executed by edx-platform. The provided -links target both the definition of the filter in the openedx-filters library as -well as the trigger location in this same repository. - - -.. list-table:: - :widths: 35 50 20 - - * - *Name* - - *Type* - - *Date added* - - * - `StudentRegistrationRequested `_ - - org.openedx.learning.student.registration.requested.v1 - - `2022-06-14 `_ - - * - `StudentLoginRequested `_ - - org.openedx.learning.student.login.requested.v1 - - `2022-06-14 `_ - - * - `CourseEnrollmentStarted `_ - - org.openedx.learning.course.enrollment.started.v1 - - `2022-06-14 `_ - - * - `CourseUnenrollmentStarted `_ - - org.openedx.learning.course.unenrollment.started.v1 - - `2022-06-14 `_ - - * - `CertificateCreationRequested `_ - - org.openedx.learning.certificate.creation.requested.v1 - - `2022-06-14 `_ - - * - `CertificateRenderStarted `_ - - org.openedx.learning.certificate.render.started.v1 - - `2022-06-14 `_ - - * - `CohortChangeRequested `_ - - org.openedx.learning.cohort.change.requested.v1 - - `2022-06-14 `_ - - * - `CohortAssignmentRequested `_ - - org.openedx.learning.cohort.assignment.requested.v1 - - `2022-06-14 `_ - - * - `CourseAboutRenderStarted `_ - - org.openedx.learning.course_about.render.started.v1 - - `2022-06-14 `_ - - * - `DashboardRenderStarted `_ - - org.openedx.learning.dashboard.render.started.v1 - - `2022-06-14 `_ - - * - `VerticalBlockChildRenderStarted `_ - - org.openedx.learning.veritical_block_child.render.started.v1 - - `2022-08-18 `_ - - * - `VerticalBlockRenderCompleted `_ - - org.openedx.learning.veritical_block.render.completed.v1 - - `2022-02-18 `_ diff --git a/docs/hooks/index.rst b/docs/hooks/index.rst deleted file mode 100644 index 99cb25133c..0000000000 --- a/docs/hooks/index.rst +++ /dev/null @@ -1,50 +0,0 @@ -Open edX Hooks Extension Framework -================================== - -To sustain the growth of the Open edX ecosystem, the business rules of the -platform must be open for extension following the open-closed principle. This -framework allows developers to do just that without needing to fork and modify -the main edx-platform repository. - - -Context -------- - -Hooks are predefined places in the edx-platform core where externally defined -functions can take place. In some cases, those functions can alter what the user -sees or experiences in the platform. Other cases are informative only. All cases -are meant to be extended using Open edX plugins and configuration. - -Hooks can be of two types, events and filters. Events are in essence signals, in -that they are sent in specific application places and whose listeners can extend -functionality. On the other hand Filters are passed data and can act on it -before this data is put back in the original application flow. In order to allow -extension developers to use the Events and Filters definitions on their plugins, -both kinds of hooks are defined in lightweight external libraries. - -* openedx-filters (`guide <./filters.rst>`_, `source code `_) -* openedx-events (`guide <./events.rst>`_, `source code `_) - -Hooks are designed with stability in mind. The main goal is that developers can -use them to change the functionality of the platform as needed and still be able -to migrate to newer open releases with very little to no development effort. In -the case of the events, this is detailed in the `versioning ADR`_ and the -`payload ADR`_. - -A longer description of the framework and it's history can be found in `OEP 50`_. - -.. _OEP 50: https://open-edx-proposals.readthedocs.io/en/latest/oep-0050-hooks-extension-framework.html -.. _versioning ADR: https://github.com/eduNEXT/openedx-events/blob/main/docs/decisions/0002-events-naming-and-versioning.rst -.. _payload ADR: https://github.com/eduNEXT/openedx-events/blob/main/docs/decisions/0003-events-payload.rst - -On the technical side events are implemented through django signals which makes -them run in the same python process as the lms or cms. Furthermore, events block -the running process. Listeners of an event are encouraged to monitor the -performance or use alternative arch patterns such as receiving the event and -defer to launching async tasks than do the slow processing. - -On the other hand, filters are implemented using a pipeline mechanism, that executes -a list of functions called ``steps`` configured through Django settings. Each -pipeline step receives a dictionary with data, process it and returns an output. During -this process, they can alter the application execution flow by halting the process -or modifying their input arguments. diff --git a/docs/index.rst b/docs/index.rst index 8d89969398..14d7597ac3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -14,12 +14,13 @@ locations. * User documentation and a more general Developer's Guide can be read on `Open edX ReadTheDocs`_. The source for these guides can be found in the - `edx-documentation`_ repository. + `docs.openedx.org`_ repository. .. _edx-platform docs directory: https://github.com/openedx/edx-platform/tree/master/docs .. _Developer Documentation Index: https://openedx.atlassian.net/wiki/spaces/DOC/overview .. _Open edX Development space: https://openedx.atlassian.net/wiki/spaces/COMM/overview -.. _Open edX ReadTheDocs: http://docs.edx.org/ +.. _Open edX ReadTheDocs: http://docs.openedx.org/ +.. _Hooks Extensions Framework: https://docs.openedx.org/en/latest/developers/concepts/hooks_extension_framework.html .. toctree:: :maxdepth: 1 @@ -32,7 +33,6 @@ locations. how-tos/index references/index concepts/index - hooks/index extensions/tinymce_plugins .. grid:: 1 2 2 2 @@ -80,18 +80,22 @@ locations. :class-card: sd-shadow-md sd-p-2 :class-footer: sd-border-0 - * :doc:`hooks/index` + * `Hooks Extensions Framework`_ * :doc:`extensions/tinymce_plugins` +++ - .. button-ref:: hooks/index + .. button-link:: https://docs.openedx.org/en/latest/developers/concepts/hooks_extension_framework.html :color: primary :outline: :expand: + Hooks Extensions Framework + Change History ************** +* April 2025: Documentation moved to docs.openedx.org + * Jun 30, 2023 * Added API, Feature Toggle and Settings docs. @@ -109,4 +113,5 @@ Change History * November 3, 2014: The documentation for several sub-projects were moved into `edx-documentation`_. +.. _docs.openedx.org: https://github.com/openedx/docs.openedx.org .. _edx-documentation: https://github.com/openedx/edx-documentation diff --git a/docs/lms-openapi.yaml b/docs/lms-openapi.yaml index 4d318f8a2b..457255d7e4 100644 --- a/docs/lms-openapi.yaml +++ b/docs/lms-openapi.yaml @@ -3469,8 +3469,58 @@ paths: /dashboard/v0/programs/{enterprise_uuid}/: get: operationId: dashboard_v0_programs_read - description: Return a list of a enterprise learner's all enrolled programs with - their progress. + summary: For an enterprise learner, get list of enrolled programs with progress. + description: |- + **Example Request** + + GET /api/dashboard/v1/programs/{enterprise_uuid}/ + + **Parameters** + + * `enterprise_uuid`: UUID of an enterprise customer. + + **Example Response** + + [ + { + "uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c", + "title": "Demonstration Program", + "type": "MicroMasters", + "banner_image": { + "large": { + "url": "http://example.com/images/foo.large.jpg", + "width": 1440, + "height": 480 + }, + "medium": { + "url": "http://example.com/images/foo.medium.jpg", + "width": 726, + "height": 242 + }, + "small": { + "url": "http://example.com/images/foo.small.jpg", + "width": 435, + "height": 145 + }, + "x-small": { + "url": "http://example.com/images/foo.x-small.jpg", + "width": 348, + "height": 116 + } + }, + "authoring_organizations": [ + { + "key": "example" + } + ], + "progress": { + "uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c", + "completed": 0, + "in_progress": 0, + "not_started": 2 + } + } + ] parameters: [] responses: '200': @@ -3485,7 +3535,118 @@ paths: /dashboard/v0/programs/{program_uuid}/progress_details/: get: operationId: dashboard_v0_programs_progress_details_list - description: Retrieves progress details of a user in a specified program. + summary: Retrieves progress details of a learner in a specified program. + description: |- + **Example Request** + + GET api/dashboard/v1/programs/{program_uuid}/progress_details/ + + **Parameters** + + * `program_uuid`: A string representation of the uuid of the program. + + **Response Values** + + If the request for information about the program is successful, an HTTP 200 "OK" response + is returned. + + The HTTP 200 response has the following values. + + * `urls`: Urls to enroll/purchase a course or view program record. + + * `program_data`: Holds meta information about the program. + + * `course_data`: Learner's progress details for all courses in the program (in-progress/remaining/completed). + + * `certificate_data`: Details about learner's certificates status for all courses in the program and the + program itself. + + * `industry_pathways`: Industry pathways for the program, comes under additional credit opportunities. + + * `credit_pathways`: Credit pathways for the program, comes under additional credit opportunities. + + **Example Response** + + { + "urls": { + "program_listing_url": "/dashboard/programs/", + "track_selection_url": "/course_modes/choose/", + "commerce_api_url": "/api/commerce/v1/baskets/", + "buy_button_url": "http://example.com/basket/add/?", + "program_record_url": "https://example.com/records/programs/8675309" + }, + "program_data": { + "uuid": "a156a6e2-de91-4ce7-947a-888943e6b12a", + "title": "Demonstration Program", + "subtitle": "", + "type": "MicroMasters", + "status": "active", + "marketing_slug": "demo-program", + "marketing_url": "micromasters/demo-program", + "authoring_organizations": [], + "card_image_url": "http://example.com/asset-v1:DemoX+Demo_Course.jpg", + "is_program_eligible_for_one_click_purchase": false, + "pathway_ids": [ + 1, + 2 + ], + "is_learner_eligible_for_one_click_purchase": false, + "skus": ["AUD90210"], + }, + "course_data": { + "uuid": "a156a6e2-de91-4ce7-947a-888943e6b12a", + "completed": [], + "in_progress": [], + "not_started": [ + { + "key": "example+DemoX", + "uuid": "fe1a9ad4-a452-45cd-80e5-9babd3d43f96", + "title": "Demonstration Course", + "course_runs": [], + "entitlements": [], + "owners": [], + "image": "", + "short_description": "", + "type": "457f07ec-a78f-45b4-ba09-5fb176520d8a", + } + ], + }, + "certificate_data": [{ + "type": "course", + "title": "Demo Course", + 'url': "/certificates/8675309", + }], + "industry_pathways": [ + { + "id": 2, + "uuid": "1b8fadf1-f6aa-4282-94e3-325b922a027f", + "name": "Demo Industry Pathway", + "org_name": "example", + "email": "example@example.com", + "description": "Sample demo industry pathway", + "destination_url": "http://example.edu/online/pathways/example-methods", + "pathway_type": "industry", + "program_uuids": [ + "a156a6e2-de91-4ce7-947a-888943e6b12a" + ] + } + ], + "credit_pathways": [ + { + "id": 1, + "uuid": "86b9701a-61e6-48a2-92eb-70a824521c1f", + "name": "Demo Credit Pathway", + "org_name": "example", + "email": "example@example.com", + "description": "Sample demo credit pathway!", + "destination_url": "http://example.edu/online/pathways/example-thinking", + "pathway_type": "credit", + "program_uuids": [ + "a156a6e2-de91-4ce7-947a-888943e6b12a" + ] + } + ] + } parameters: [] responses: '200': @@ -5025,6 +5186,8 @@ paths: GET /api/enrollment/v1/enrollments?course_id={course_id} + GET /api/enrollment/v1/enrollments?course_ids={course_id},{course_id},{course_id} + GET /api/enrollment/v1/enrollments?username={username},{username},{username} GET /api/enrollment/v1/enrollments?course_id={course_id}&username={username} @@ -5036,6 +5199,10 @@ paths: * course_id: Filters the result to course enrollments for the course corresponding to the given course ID. The value must be URL encoded. Optional. + * course_ids: List of comma-separated course IDs. Filters the result to course enrollments + for the courses corresponding to the given course IDs. Course IDs could be course run IDs + or course IDs. The value must be URL encoded. Optional. + * username: List of comma-separated usernames. Filters the result to the course enrollments of the given users. Optional. @@ -5145,26 +5312,6 @@ paths: Override the list method to expire records that are past the policy and requested via the API before returning those records. parameters: - - name: uuid - in: query - description: uuid - required: false - type: string - - name: user - in: query - description: user - required: false - type: string - - name: course_uuid - in: query - description: course_uuid - required: false - type: string - - name: expired_at__isnull - in: query - description: expired_at__isnull - required: false - type: string - name: page in: query description: A page number within the paginated result set. @@ -5351,16 +5498,6 @@ paths: operationId: experiments_v0_data_list description: '' parameters: - - name: experiment_id - in: query - description: experiment_id - required: false - type: string - - name: key - in: query - description: key - required: false - type: string - name: page in: query description: A page number within the paginated result set. @@ -5493,16 +5630,6 @@ paths: operationId: experiments_v0_key-value_list description: '' parameters: - - name: experiment_id - in: query - description: experiment_id - required: false - type: string - - name: key - in: query - description: key - required: false - type: string - name: page in: query description: A page number within the paginated result set. @@ -5847,7 +5974,7 @@ paths: operationId: grades_v1_submission_history_read description: |- Get submission history details. This submission history is related to only - ProblemBlock and it doesn't support LibraryContentBlock or ContentLibraries + ProblemBlock and it doesn't support LegacyLibraryContentBlock or ContentLibraries as of now. **Usecases**: @@ -6245,6 +6372,198 @@ paths: tags: - learner_home parameters: [] + /learner_home/v1/programs/{enterprise_uuid}/: + get: + operationId: learner_home_v1_programs_read + summary: For an enterprise learner, get list of enrolled programs with progress. + description: |- + **Example Request** + + GET /api/dashboard/v1/programs/{enterprise_uuid}/ + + **Parameters** + + * `enterprise_uuid`: UUID of an enterprise customer. + + **Example Response** + + [ + { + "uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c", + "title": "Demonstration Program", + "type": "MicroMasters", + "banner_image": { + "large": { + "url": "http://example.com/images/foo.large.jpg", + "width": 1440, + "height": 480 + }, + "medium": { + "url": "http://example.com/images/foo.medium.jpg", + "width": 726, + "height": 242 + }, + "small": { + "url": "http://example.com/images/foo.small.jpg", + "width": 435, + "height": 145 + }, + "x-small": { + "url": "http://example.com/images/foo.x-small.jpg", + "width": 348, + "height": 116 + } + }, + "authoring_organizations": [ + { + "key": "example" + } + ], + "progress": { + "uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c", + "completed": 0, + "in_progress": 0, + "not_started": 2 + } + } + ] + parameters: [] + responses: + '200': + description: '' + tags: + - learner_home + parameters: + - name: enterprise_uuid + in: path + required: true + type: string + /learner_home/v1/programs/{program_uuid}/progress_details/: + get: + operationId: learner_home_v1_programs_progress_details_list + summary: Retrieves progress details of a learner in a specified program. + description: |- + **Example Request** + + GET api/dashboard/v1/programs/{program_uuid}/progress_details/ + + **Parameters** + + * `program_uuid`: A string representation of the uuid of the program. + + **Response Values** + + If the request for information about the program is successful, an HTTP 200 "OK" response + is returned. + + The HTTP 200 response has the following values. + + * `urls`: Urls to enroll/purchase a course or view program record. + + * `program_data`: Holds meta information about the program. + + * `course_data`: Learner's progress details for all courses in the program (in-progress/remaining/completed). + + * `certificate_data`: Details about learner's certificates status for all courses in the program and the + program itself. + + * `industry_pathways`: Industry pathways for the program, comes under additional credit opportunities. + + * `credit_pathways`: Credit pathways for the program, comes under additional credit opportunities. + + **Example Response** + + { + "urls": { + "program_listing_url": "/dashboard/programs/", + "track_selection_url": "/course_modes/choose/", + "commerce_api_url": "/api/commerce/v1/baskets/", + "buy_button_url": "http://example.com/basket/add/?", + "program_record_url": "https://example.com/records/programs/8675309" + }, + "program_data": { + "uuid": "a156a6e2-de91-4ce7-947a-888943e6b12a", + "title": "Demonstration Program", + "subtitle": "", + "type": "MicroMasters", + "status": "active", + "marketing_slug": "demo-program", + "marketing_url": "micromasters/demo-program", + "authoring_organizations": [], + "card_image_url": "http://example.com/asset-v1:DemoX+Demo_Course.jpg", + "is_program_eligible_for_one_click_purchase": false, + "pathway_ids": [ + 1, + 2 + ], + "is_learner_eligible_for_one_click_purchase": false, + "skus": ["AUD90210"], + }, + "course_data": { + "uuid": "a156a6e2-de91-4ce7-947a-888943e6b12a", + "completed": [], + "in_progress": [], + "not_started": [ + { + "key": "example+DemoX", + "uuid": "fe1a9ad4-a452-45cd-80e5-9babd3d43f96", + "title": "Demonstration Course", + "course_runs": [], + "entitlements": [], + "owners": [], + "image": "", + "short_description": "", + "type": "457f07ec-a78f-45b4-ba09-5fb176520d8a", + } + ], + }, + "certificate_data": [{ + "type": "course", + "title": "Demo Course", + 'url': "/certificates/8675309", + }], + "industry_pathways": [ + { + "id": 2, + "uuid": "1b8fadf1-f6aa-4282-94e3-325b922a027f", + "name": "Demo Industry Pathway", + "org_name": "example", + "email": "example@example.com", + "description": "Sample demo industry pathway", + "destination_url": "http://example.edu/online/pathways/example-methods", + "pathway_type": "industry", + "program_uuids": [ + "a156a6e2-de91-4ce7-947a-888943e6b12a" + ] + } + ], + "credit_pathways": [ + { + "id": 1, + "uuid": "86b9701a-61e6-48a2-92eb-70a824521c1f", + "name": "Demo Credit Pathway", + "org_name": "example", + "email": "example@example.com", + "description": "Sample demo credit pathway!", + "destination_url": "http://example.edu/online/pathways/example-thinking", + "pathway_type": "credit", + "program_uuids": [ + "a156a6e2-de91-4ce7-947a-888943e6b12a" + ] + } + ] + } + parameters: [] + responses: + '200': + description: '' + tags: + - learner_home + parameters: + - name: program_uuid + in: path + required: true + type: string /learning_sequences/v1/course_outline/{course_key_str}: get: operationId: learning_sequences_v1_course_outline_read @@ -7047,6 +7366,25 @@ paths: in: path required: true type: string + /mobile/{api_version}/users/{username}/enrollments_status/: + get: + operationId: mobile_users_enrollments_status_list + description: Gets user's enrollments status. + parameters: [] + responses: + '200': + description: '' + tags: + - mobile + parameters: + - name: api_version + in: path + required: true + type: string + - name: username + in: path + required: true + type: string /notifications/: get: operationId: notifications_list @@ -7115,6 +7453,18 @@ paths: tags: - notifications parameters: [] + /notifications/configurations/: + get: + operationId: notifications_configurations_list + description: API view for getting the aggregate notification preferences for + the current user. + parameters: [] + responses: + '200': + description: '' + tags: + - notifications + parameters: [] /notifications/configurations/{course_key_string}: get: operationId: notifications_configurations_read @@ -7290,6 +7640,17 @@ paths: in: path required: true type: string + /notifications/preferences/update-all/: + post: + operationId: notifications_preferences_update-all_create + description: Update all notification preferences for the current user. + parameters: [] + responses: + '201': + description: '' + tags: + - notifications + parameters: [] /notifications/preferences/update/{username}/{patch}/: get: operationId: notifications_preferences_update_read @@ -8538,6 +8899,15 @@ paths: description: '' tags: - third_party_auth + delete: + operationId: third_party_auth_v0_users_delete + description: Delete given social auth record for a user. + parameters: [] + responses: + '204': + description: '' + tags: + - third_party_auth parameters: [] /third_party_auth/v0/users/{username}: get: @@ -8634,10 +9004,23 @@ paths: /user/v1/accounts: get: operationId: user_v1_accounts_list + summary: Return a list of user details objects description: |- - GET /api/user/v1/accounts?username={username1,username2} - GET /api/user/v1/accounts?email={user_email} (Staff Only) - GET /api/user/v1/accounts?lms_user_id={lms_user_id} (Staff Only) + **Example Requests** + + GET /api/user/v1/accounts?usernames={username1,username2}[?view=shared] + + GET /api/user/v1/accounts?email={user_email} (staff only) + + GET /api/user/v1/accounts?lms_user_id={user_email} (staff only) + + **Responses** + + If no user exists with the specified username, or email, an HTTP 404 "Not Found" response is returned. + + If the user makes the request for her own account, or makes a request for another account and has "is_staff" access, an HTTP 200 "OK" response is returned. + + The response consists of a list of one or more user objects, in the same format as is returned for `GET /user/v1/accounts/{username}`. parameters: [] responses: '200': @@ -8899,12 +9282,34 @@ paths: /user/v1/accounts/search_emails: post: operationId: user_v1_accounts_search_emails + summary: Return information about users associated with a list of email addresses description: |- + **Example Requests** + POST /api/user/v1/accounts/search_emails - Content Type: "application/json" - { - "emails": ["edx@example.com", "staff@example.com"] - } + + { + "emails": ["edx@example.com", "staff@example.com"] + } + + **Response** + + If no `emails` key is present in the request, or the user does not have "is_staff" access, an HTTP 404 "Not Found" response is returned. + + If the has "is_staff" access, an HTTP 200 "OK" response is returned. The response contains the following values. + + [ + { + "username": "edx", + "email": "edx@example.com", + "id": 3, + }, + { + "username": "staff", + "email": "staff@example.com", + "id": 8, + } + ] parameters: [] responses: '201': @@ -8943,7 +9348,69 @@ paths: /user/v1/accounts/{username}: get: operationId: user_v1_accounts_read - description: GET /api/user/v1/accounts/{username}/ + summary: Retrieve a single detailed user object + description: |- + **Example Requests** + + GET /api/user/v1/accounts/{username}/ + + **Response** + + If no user exists with the specified username, or email, an HTTP 404 "Not Found" response is returned. + + If the user makes the request for her own account, or makes a request for another account and has "is_staff" access, an HTTP 200 "OK" response is returned. The response contains the following values. + + * `id`: numerical lms user id in db + * `activation_key`: auto-genrated activation key when signed up via email + * `bio`: null or textual representation of user biographical information ("about me"). + * `country`: An ISO 3166 country code or null. + * `date_joined`: The date the account was created, in the string format provided by datetime. For example, "2014-08-26T17:52:11Z". + * `last_login`: The latest date the user logged in, in the string datetime format. + * `email`: Email address for the user. New email addresses must be confirmed via a confirmation email, so GET does not reflect the change until the address has been confirmed. + * `secondary_email`: A secondary email address for the user. Unlike the email field, GET will reflect the latest update to this field even if changes have yet to be confirmed. + * `verified_name`: Approved verified name of the learner present in name affirmation plugin + * `extended_profile`: A list of objects with the keys `field_name` and `field_value`, returning any populated `extended_profile_fields` configured in the **Site Configuration** + * `gender`: One of the following values: + * null + * "f" + * "m" + * "o" + * `goals`: The textual representation of the user's goals, or null. + * `is_active`: Boolean representation of whether a user is active. + * `language`: The user's preferred language, or null. + * `language_proficiencies`: Array of language preferences. Each preference is a JSON object with the following keys: + * "code": string ISO 639-1 language code e.g. "en". + * `level_of_education`: One of the following values: + * "p": PhD or Doctorate + * "m": Master's or professional degree + * "b": Bachelor's degree + * "a": Associate's degree + * "hs": Secondary/high school + * "jhs": Junior secondary/junior high/middle school + * "el": Elementary/primary school + * "none": None + * "o": Other + * null: The user did not enter a value + * `mailing_address`: The textual representation of the user's mailing address, or null. + * `name`: The full name of the user. + * `profile_image`: A JSON representation of a user's profile image information. This representation has the following keys. + * "has_image": Boolean indicating whether the user has a profile image. + * "image_url_*": Absolute URL to various sizes of a user's profile image, where '*' matches a representation of the corresponding image size, such as 'small', 'medium', 'large', and 'full'. These are configurable via PROFILE_IMAGE_SIZES_MAP. + * `requires_parental_consent`: True if the user is a minor requiring parental consent. + * `social_links`: Array of social links, sorted alphabetically by "platform". Each preference is a JSON object with the following keys: + * "platform": A particular social platform, ex: 'facebook' + * "social_link": The link to the user's profile on the particular platform + * `username`: The username associated with the account. + * `year_of_birth`: The year the user was born, as an integer, or null. + * `account_privacy`: The user's setting for sharing her personal profile. Possible values are "all_users", "private", or "custom". If "custom", the user has selectively chosen a subset of shareable fields to make visible to others via the User Preferences API. + * `phone_number`: The phone number for the user. String of numbers with an optional `+` sign at the start. + * `pending_name_change`: If the user has an active name change request, returns the requested name. + + For all text fields, plain text instead of HTML is supported. The data is stored exactly as specified. Clients must HTML escape rendered values to avoid script injections. + + If a user who does not have "is_staff" access requests account information for a different user, only a subset of these fields is returned. The returned fields depend on the `ACCOUNT_VISIBILITY_CONFIGURATION` configuration setting and the visibility preference of the user for whom data is requested. + + A user can view which account fields they have shared with other users by requesting their own username and providing the "view=shared" URL parameter. parameters: [] responses: '200': @@ -8955,9 +9422,43 @@ paths: - user patch: operationId: user_v1_accounts_partial_update - summary: PATCH /api/user/v1/accounts/{username}/ - description: Note that this implementation is the "merge patch" implementation - proposed in + summary: Update user account or profile information + description: |- + **Example Requests** + + + Content-Type: application/merge-patch+json + + PATCH /api/user/v1/accounts/{username} + + **Request Body** + + { + "level_of_education": "m", + "extended_profile": + [ + {"field_name": "favorite_beatle", "field_value": {"name": "ringo"}}, + {"field_name": "conlangs_spoken", "field_value":["Láadan", "Rikchik", "Lojban"]} + ] + } + + **Notes regarding `social_links`** + + Requested updates to social_links are automatically merged with previously set links. That is, any newly introduced platforms are add to the previous list. Updated links to pre-existing platforms replace their values in the previous list. Pre-existing platforms can be removed by setting the value of the social_link to an empty string (""). + + **Response Values for PATCH** + + Users can only modify their own account information. If the requesting user does not have the specified username and has staff access, the request returns an HTTP 403 "Forbidden" response. If the requesting user does not have staff access, the request returns an HTTP 404 "Not Found" response to avoid revealing the existence of the account. + + If no user exists with the specified username, an HTTP 404 "Not Found" response is returned. + + If "application/merge-patch+json" is not the specified content type, a 415 "Unsupported Media Type" response is returned. + + If validation errors prevent the update, this method returns a 400 "Bad Request" response that includes a "field_errors" field that lists all error messages. This will happen if an attempt is made to edit any read-only fields. + + If a failure at the time of the update prevents the update, a 400 "Bad Request" error is returned. The JSON collection contains specific errors. + + If the update is successful, updated user account data is returned. parameters: [] responses: '200': @@ -9119,11 +9620,22 @@ paths: /user/v1/me: get: operationId: user_v1_get - description: GET /api/user/v1/me + summary: Return an authenticated user's username + description: |- + **Example Requests** + + GET /api/user/v1/me[?view=shared] parameters: [] responses: '200': description: '' + schema: + type: object + properties: + username: + type: string + '401': + description: '' consumes: - application/json - application/merge-patch+json @@ -9296,16 +9808,6 @@ paths: operationId: user_v1_user_prefs_list description: DRF class for interacting with the UserPreference ORM parameters: - - name: key - in: query - description: key - required: false - type: string - - name: user - in: query - description: user - required: false - type: string - name: page in: query description: A page number within the paginated result set. @@ -9798,6 +10300,36 @@ paths: tags: - val parameters: [] + /val/v0/videos/video-transcripts/: + post: + operationId: val_v0_videos_video-transcripts_create + description: Creates a video transcript instance with the given information. + parameters: [] + responses: + '201': + description: '' + tags: + - val + patch: + operationId: val_v0_videos_video-transcripts_partial_update + description: Partially update a video transcript, only supporting updating the + `provider` field. + parameters: [] + responses: + '200': + description: '' + tags: + - val + delete: + operationId: val_v0_videos_video-transcripts_delete + description: Delete a video transcript instance with the given information. + parameters: [] + responses: + '204': + description: '' + tags: + - val + parameters: [] /val/v0/videos/video-transcripts/create/: post: operationId: val_v0_videos_video-transcripts_create_create @@ -9808,6 +10340,25 @@ paths: description: '' tags: - val + patch: + operationId: val_v0_videos_video-transcripts_create_partial_update + description: Partially update a video transcript, only supporting updating the + `provider` field. + parameters: [] + responses: + '200': + description: '' + tags: + - val + delete: + operationId: val_v0_videos_video-transcripts_create_delete + description: Delete a video transcript instance with the given information. + parameters: [] + responses: + '204': + description: '' + tags: + - val parameters: [] /val/v0/videos/{edx_video_id}: get: @@ -9868,7 +10419,7 @@ paths: required: true type: string pattern: ^[a-zA-Z0-9\-_]*$ - /xblock/v2/xblocks/{usage_key_str}/: + /xblock/v2/xblocks/{usage_key}/: get: operationId: xblock_v2_xblocks_read summary: Get metadata about the specified block. @@ -9884,11 +10435,11 @@ paths: tags: - xblock parameters: - - name: usage_key_str + - name: usage_key in: path required: true type: string - /xblock/v2/xblocks/{usage_key_str}/fields/: + /xblock/v2/xblocks/{usage_key}/fields/: get: operationId: xblock_v2_xblocks_fields_list description: retrieves the xblock, returning display_name, data, and metadata @@ -9909,11 +10460,11 @@ paths: tags: - xblock parameters: - - name: usage_key_str + - name: usage_key in: path required: true type: string - /xblock/v2/xblocks/{usage_key_str}/handler_url/{handler_name}/: + /xblock/v2/xblocks/{usage_key}/handler_url/{handler_name}/: get: operationId: xblock_v2_xblocks_handler_url_read summary: |- @@ -9928,7 +10479,7 @@ paths: tags: - xblock parameters: - - name: usage_key_str + - name: usage_key in: path required: true type: string @@ -9936,7 +10487,22 @@ paths: in: path required: true type: string - /xblock/v2/xblocks/{usage_key_str}/view/{view_name}/: + /xblock/v2/xblocks/{usage_key}/olx/: + get: + operationId: xblock_v2_xblocks_olx_list + description: Get the OLX (XML serialization) of the specified XBlock + parameters: [] + responses: + '200': + description: '' + tags: + - xblock + parameters: + - name: usage_key + in: path + required: true + type: string + /xblock/v2/xblocks/{usage_key}/view/{view_name}/: get: operationId: xblock_v2_xblocks_view_read description: Get the HTML, JS, and CSS needed to render the given XBlock. @@ -9947,7 +10513,129 @@ paths: tags: - xblock parameters: - - name: usage_key_str + - name: usage_key + in: path + required: true + type: string + - name: view_name + in: path + required: true + type: string + /xblock/v2/xblocks/{usage_key}@{version}/: + get: + operationId: xblock_v2_xblocks_read + summary: Get metadata about the specified block. + description: |- + Accepts the following query parameters: + + * "include": a comma-separated list of keys to include. + Valid keys are "index_dictionary" and "student_view_data". + parameters: [] + responses: + '200': + description: '' + tags: + - xblock + parameters: + - name: usage_key + in: path + required: true + type: string + - name: version + in: path + required: true + type: string + /xblock/v2/xblocks/{usage_key}@{version}/fields/: + get: + operationId: xblock_v2_xblocks_fields_list + description: retrieves the xblock, returning display_name, data, and metadata + parameters: [] + responses: + '200': + description: '' + tags: + - xblock + post: + operationId: xblock_v2_xblocks_fields_create + description: edits the xblock, saving changes to data and metadata only (display_name + included in metadata) + parameters: [] + responses: + '201': + description: '' + tags: + - xblock + parameters: + - name: usage_key + in: path + required: true + type: string + - name: version + in: path + required: true + type: string + /xblock/v2/xblocks/{usage_key}@{version}/handler_url/{handler_name}/: + get: + operationId: xblock_v2_xblocks_handler_url_read + summary: |- + Get an absolute URL which can be used (without any authentication) to call + the given XBlock handler. + description: The URL will expire but is guaranteed to be valid for a minimum + of 2 days. + parameters: [] + responses: + '200': + description: '' + tags: + - xblock + parameters: + - name: usage_key + in: path + required: true + type: string + - name: version + in: path + required: true + type: string + - name: handler_name + in: path + required: true + type: string + /xblock/v2/xblocks/{usage_key}@{version}/olx/: + get: + operationId: xblock_v2_xblocks_olx_list + description: Get the OLX (XML serialization) of the specified XBlock + parameters: [] + responses: + '200': + description: '' + tags: + - xblock + parameters: + - name: usage_key + in: path + required: true + type: string + - name: version + in: path + required: true + type: string + /xblock/v2/xblocks/{usage_key}@{version}/view/{view_name}/: + get: + operationId: xblock_v2_xblocks_view_read + description: Get the HTML, JS, and CSS needed to render the given XBlock. + parameters: [] + responses: + '200': + description: '' + tags: + - xblock + parameters: + - name: usage_key + in: path + required: true + type: string + - name: version in: path required: true type: string @@ -10158,12 +10846,9 @@ definitions: - can_view_certificate - course_modes - is_new_discussion_sidebar_view_enabled + - has_course_author_access type: object properties: - can_show_upgrade_sock: - title: Can show upgrade sock - type: string - readOnly: true verified_mode: title: Verified mode type: string @@ -10237,6 +10922,9 @@ definitions: is_new_discussion_sidebar_view_enabled: title: Is new discussion sidebar view enabled type: boolean + has_course_author_access: + title: Has course author access + type: boolean DateSummary: required: - complete @@ -10451,10 +11139,6 @@ definitions: title: Dates banner info type: string readOnly: true - can_show_upgrade_sock: - title: Can show upgrade sock - type: string - readOnly: true verified_mode: title: Verified mode type: string @@ -10652,10 +11336,6 @@ definitions: - disable_progress_graph type: object properties: - can_show_upgrade_sock: - title: Can show upgrade sock - type: string - readOnly: true verified_mode: title: Verified mode type: string @@ -11297,7 +11977,7 @@ definitions: thread_counts: title: Thread counts description: Mapping of thread counts by type of thread - type: string + type: object readOnly: true enabled_in_context: title: Enabled in context @@ -11946,6 +12626,11 @@ definitions: format: uri maxLength: 200 x-nullable: true + course_id: + title: Course id + type: string + maxLength: 255 + x-nullable: true last_read: title: Last read type: string diff --git a/docs/references/settings.rst b/docs/references/settings.rst index 79b366e990..216715f934 100644 --- a/docs/references/settings.rst +++ b/docs/references/settings.rst @@ -6,13 +6,19 @@ This is the list of (non-toggle) Django settings defined in the ``common.py`` mo .. note:: Toggle settings, which enable or disable a specific feature, are documented in the :ref:`feature toggles ` section. -LMS settings +Platform-Wide Settings +---------------------- + +.. settings:: + :folder_path: openedx/envs/common.py + +LMS Settings ------------ .. settings:: :folder_path: lms/envs/common.py -CMS settings +CMS Settings ------------ .. settings:: diff --git a/docs/references/static-assets.rst b/docs/references/static-assets.rst index b1db36b7f1..9a3e3c25b6 100644 --- a/docs/references/static-assets.rst +++ b/docs/references/static-assets.rst @@ -11,7 +11,7 @@ which communicate with edx-platform over AJAX, but are built and deployed independently. Eventually, we expect that MFEs will replace all edx-platform frontend pages, except perhaps XBlock views.* -Configuraiton +Configuration ************* To customize the static assets build, set some or all of these variable in your diff --git a/lms/djangoapps/branding/test_toggles.py b/lms/djangoapps/branding/test_toggles.py new file mode 100644 index 0000000000..92bde93a71 --- /dev/null +++ b/lms/djangoapps/branding/test_toggles.py @@ -0,0 +1,23 @@ +""" +Tests for toggles, where there is logic beyond enable/disable. +""" + +import ddt +from django.test import override_settings, TestCase + +from lms.djangoapps.branding.toggles import use_catalog_mfe + + +@ddt.ddt +class TestBrandingToggles(TestCase): + """ + Tests for toggles, where there is logic beyond enable/disable. + """ + + @ddt.data(True, False) + def test_use_catalog_mfe(self, enabled): + """ + Test the use_catalog_mfe toggle. + """ + with override_settings(FEATURES={'ENABLE_CATALOG_MICROFRONTEND': enabled}): + assert use_catalog_mfe() == enabled diff --git a/lms/djangoapps/branding/toggles.py b/lms/djangoapps/branding/toggles.py new file mode 100644 index 0000000000..ba7b3407d7 --- /dev/null +++ b/lms/djangoapps/branding/toggles.py @@ -0,0 +1,15 @@ +""" +Configuration for features of Branding +""" +from django.conf import settings + +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers + + +def use_catalog_mfe(): + """ + Determine if Catalog MFE is enabled, replacing student_dashboard + """ + return configuration_helpers.get_value( + 'ENABLE_CATALOG_MICROFRONTEND', settings.FEATURES['ENABLE_CATALOG_MICROFRONTEND'] + ) diff --git a/lms/djangoapps/bulk_email/messages.py b/lms/djangoapps/bulk_email/messages.py index 8052ca0158..c1c16a2d51 100644 --- a/lms/djangoapps/bulk_email/messages.py +++ b/lms/djangoapps/bulk_email/messages.py @@ -83,6 +83,7 @@ class ACEEmail(CourseEmailMessage): language=email_context['course_language'], user_context={"name": email_context['name']}, ) + message.options['skip_disable_user_policy'] = True self.message = message def send(self): diff --git a/lms/djangoapps/bulk_email/models.py b/lms/djangoapps/bulk_email/models.py index 0e26ea559c..b1a7aa5744 100644 --- a/lms/djangoapps/bulk_email/models.py +++ b/lms/djangoapps/bulk_email/models.py @@ -114,7 +114,7 @@ class Target(models.Model): """ staff_qset = CourseStaffRole(course_id).users_with_role() instructor_qset = CourseInstructorRole(course_id).users_with_role() - staff_instructor_qset = (staff_qset | instructor_qset) + staff_instructor_qset = staff_qset | instructor_qset enrollment_query = models.Q( is_active=True, courseenrollment__course_id=course_id, @@ -146,7 +146,7 @@ class Target(models.Model): User.objects.filter( models.Q(courseenrollment__mode=self.coursemodetarget.track.mode_slug) & enrollment_query - ) + ).exclude(id__in=staff_instructor_qset) ) else: raise ValueError(f"Unrecognized target type {self.target_type}") diff --git a/lms/djangoapps/bulk_email/signals.py b/lms/djangoapps/bulk_email/signals.py index fb8749bf45..7402dca754 100644 --- a/lms/djangoapps/bulk_email/signals.py +++ b/lms/djangoapps/bulk_email/signals.py @@ -1,6 +1,8 @@ """ Signal handlers for the bulk_email app """ +import logging + from django.dispatch import receiver from eventtracking import tracker @@ -10,6 +12,8 @@ from edx_ace.signals import ACE_MESSAGE_SENT from .models import Optout +log = logging.getLogger(__name__) + @receiver(USER_RETIRE_MAILINGS) def force_optout_all(sender, **kwargs): # lint-amnesty, pylint: disable=unused-argument @@ -40,10 +44,12 @@ def ace_email_sent_handler(sender, **kwargs): user_id = recipient.get('user_id', None) channel = message.get('channel', None) course_id = context.get('course_id', None) + message_language = message.get('message_language', None) + translation_language = message.get('translation_language', None) if not course_id: course_email = context.get('course_email', None) course_id = course_email.course_id if course_email else None - + log.info(f'Email sent for {message_name} for course {course_id} using channel {channel}') tracker.emit( 'edx.ace.message_sent', { @@ -52,5 +58,7 @@ def ace_email_sent_handler(sender, **kwargs): 'course_id': course_id, 'user_id': user_id, 'user_email': email_address, + 'message_language': message_language, + 'translation_language': translation_language, } ) diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index 197a7c0f13..4f18b224fe 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -111,7 +111,7 @@ def _get_course_email_context(course): 'course_url': course_url, 'course_image_url': image_url, 'course_end_date': course_end_date, - 'account_settings_url': '{}{}'.format(lms_root_url, reverse('account_settings')), + 'account_settings_url': settings.ACCOUNT_MICROFRONTEND_URL, 'email_settings_url': '{}{}'.format(lms_root_url, reverse('dashboard')), 'logo_url': get_logo_url_for_email(), 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), @@ -181,7 +181,7 @@ def perform_delegate_email_batches(entry_id, course_id, task_input, action_name) # inefficient OUTER JOIN query that would read the whole user table. combined_set = recipient_qsets[0].union(*recipient_qsets[1:]) if len(recipient_qsets) > 1 \ else recipient_qsets[0] - recipient_fields = ['profile__name', 'email', 'username'] + recipient_fields = ['profile__name', 'email', 'username', 'password'] log.info("Task %s: Preparing to queue subtasks for sending emails for course %s, email %s", task_id, course_id, email_id) @@ -350,6 +350,21 @@ def _filter_optouts_from_recipients(to_list, course_id): return to_list, num_optout +def _filter_disabled_users_from_recipients(to_list, course_key_str): + """ + Filters a user if its account is disabled + """ + user_list = [] + disabled_count = 0 + for user in to_list: + if user['password'].startswith('!'): + log.info(f"Bulk Email User is disabled {user['email']} in course {course_key_str}") + disabled_count += 1 + else: + user_list.append(user) + return user_list, disabled_count + + def _get_source_address(course_id, course_title, course_language, truncate=True): """ Calculates an email address to be used as the 'from-address' for sent emails. @@ -485,7 +500,8 @@ def _send_course_email(entry_id, email_id, to_list, global_email_context, subtas # in the Optout list. if subtask_status.get_retry_count() == 0: to_list, num_optout = _filter_optouts_from_recipients(to_list, course_email.course_id) - subtask_status.increment(skipped=num_optout) + to_list, num_disabled = _filter_disabled_users_from_recipients(to_list, str(course_email.course_id)) + subtask_status.increment(skipped=num_optout + num_disabled) course_title = global_email_context['course_title'] course_language = global_email_context['course_language'] diff --git a/lms/djangoapps/bulk_email/tests/test_email.py b/lms/djangoapps/bulk_email/tests/test_email.py index ba95bd26dd..f68fd9b249 100644 --- a/lms/djangoapps/bulk_email/tests/test_email.py +++ b/lms/djangoapps/bulk_email/tests/test_email.py @@ -743,7 +743,7 @@ class TestCourseEmailContext(SharedModuleStoreTestCase): assert email_context['course_image_url'] == \ f'{scheme}://edx.org/asset-v1:{course_id_fragment}+type@asset+block@images_course_image.jpg' assert email_context['email_settings_url'] == f'{scheme}://edx.org/dashboard' - assert email_context['account_settings_url'] == f'{scheme}://edx.org/account/settings' + assert email_context['account_settings_url'] == settings.ACCOUNT_MICROFRONTEND_URL @override_settings(LMS_ROOT_URL="http://edx.org") def test_insecure_email_context(self): diff --git a/lms/djangoapps/bulk_email/tests/test_models.py b/lms/djangoapps/bulk_email/tests/test_models.py index 1f7dc0c856..43062492e8 100644 --- a/lms/djangoapps/bulk_email/tests/test_models.py +++ b/lms/djangoapps/bulk_email/tests/test_models.py @@ -15,7 +15,7 @@ from opaque_keys.edx.keys import CourseKey from pytz import UTC from common.djangoapps.course_modes.models import CourseMode -from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory, StaffFactory from lms.djangoapps.bulk_email.api import is_bulk_email_feature_enabled from lms.djangoapps.bulk_email.models import ( SEND_TO_COHORT, @@ -25,8 +25,10 @@ from lms.djangoapps.bulk_email.models import ( CourseAuthorization, CourseEmail, CourseEmailTemplate, + CourseModeTarget, DisabledCourse, - Optout + Optout, + Target, ) from lms.djangoapps.bulk_email.models_api import is_bulk_email_disabled_for_course from lms.djangoapps.bulk_email.tests.factories import TargetFactory @@ -366,6 +368,7 @@ class TargetFilterTest(ModuleStoreTestCase): course_id=self.course.id, user=self.user3 ) + self.staff_user = StaffFactory.create(course_key=self.course.id) self.target = TargetFactory() @override_settings(BULK_COURSE_EMAIL_LAST_LOGIN_ELIGIBILITY_PERIOD=None) @@ -391,3 +394,40 @@ class TargetFilterTest(ModuleStoreTestCase): assert result.count() == 1 assert result.filter(id=self.user1.id).exists() + + def test_filtering_of_recipients_target_for_audit_track(self): + """ + Verifies the default behavior. + + This test ensures that when the `BULK_COURSE_EMAIL_LAST_LOGIN_ELIGIBILITY_PERIOD` + setting is not defined, all users enrolled in the course are included in the results. + """ + target = Target.objects.create(target_type=SEND_TO_TRACK) + course_mode = CourseMode.objects.create( + mode_slug=CourseMode.AUDIT, + mode_display_name=CourseMode.AUDIT.capitalize(), + course_id=self.course.id, + ) + course_mode_target = CourseModeTarget.objects.create(track=course_mode) + target.coursemodetarget = course_mode_target + result = target.get_users(self.course.id) + + assert result.count() == 1 + assert result.filter(id=self.user2.id).exists() + + # Ensure staff user is not included + assert not result.filter(id=self.staff_user.id).exists() + + def test_filtering_of_recipients_target_for_staff(self): + """ + Test filtering of recipients for a target of type SEND_TO_STAFF. + + This test verifies that only staff users are returned for the given target. + It creates a target of type SEND_TO_STAFF and ensures that the correct users + are retrieved. + """ + self.target = TargetFactory(target_type=SEND_TO_STAFF) + result = self.target.get_users(self.course.id) + + assert result.count() == 1 + assert result.filter(id=self.staff_user.id).exists() diff --git a/lms/djangoapps/bulk_email/tests/test_tasks.py b/lms/djangoapps/bulk_email/tests/test_tasks.py index 96c48e0d9e..c4080b872b 100644 --- a/lms/djangoapps/bulk_email/tests/test_tasks.py +++ b/lms/djangoapps/bulk_email/tests/test_tasks.py @@ -479,3 +479,15 @@ class TestBulkEmailInstructorTask(InstructorTaskCourseTestCase): # we should expect only one email to be sent as the other learner is not eligible to receive the message # based on their last_login date self._test_run_with_task(send_bulk_course_email, 'emailed', 1, 1) + + def test_email_is_not_sent_to_disabled_user(self): + """ + Tests if disabled user are skipped when sending bulk email + """ + user_1 = self.create_student(username="user1", email="user1@example.com") + user_1.set_unusable_password() + user_1.save() + self.create_student(username="user2", email="user2@example.com") + with patch('lms.djangoapps.bulk_email.tasks.get_connection', autospec=True) as get_conn: + get_conn.return_value.send_messages.side_effect = cycle([None]) + self._test_run_with_task(send_bulk_course_email, 'emailed', 3, 2, skipped=1) diff --git a/lms/djangoapps/certificates/admin.py b/lms/djangoapps/certificates/admin.py index 3facf6f6b5..ffa2253212 100644 --- a/lms/djangoapps/certificates/admin.py +++ b/lms/djangoapps/certificates/admin.py @@ -22,6 +22,7 @@ from lms.djangoapps.certificates.models import ( CertificateTemplateAsset, GeneratedCertificate, ModifiedCertificateTemplateCommandConfiguration, + PurgeReferencestoPDFCertificatesCommandConfiguration, ) @@ -103,6 +104,11 @@ class CertificateGenerationCommandConfigurationAdmin(ConfigurationModelAdmin): pass +@admin.register(PurgeReferencestoPDFCertificatesCommandConfiguration) +class PurgeReferencestoPDFCertificatesCommandConfigurationAdmin(ConfigurationModelAdmin): + pass + + class CertificateDateOverrideAdmin(admin.ModelAdmin): """ # Django admin customizations for CertificateDateOverride model diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index bd7db8662e..02b254ffad 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -82,6 +82,7 @@ def _format_certificate_for_user(username, cert): if cert.status == CertificateStatuses.downloadable else None ), + "uuid": cert.verify_uuid, } return None @@ -298,6 +299,15 @@ def certificate_downloadable_status(student, course_key): response_data["earned_but_not_available"] = True response_data["certificate_available_date"] = course_overview.certificate_available_date + if ( + not certificates_viewable_for_course(course_overview) + and not CertificateStatuses.is_passing_status(current_status["status"]) + and display_behavior_is_valid + and course_overview.certificate_available_date + ): + response_data["not_earned_but_available_date"] = True + response_data["certificate_available_date"] = course_overview.certificate_available_date + may_view_certificate = _should_certificate_be_visible( course_overview.certificates_display_behavior, course_overview.certificates_show_before_end, @@ -857,7 +867,14 @@ def available_date_for_certificate(course, certificate) -> datetime: def display_date_for_certificate(course, certificate): """ - Returns the display date that a certificate should display. + Returns the date that should be displayed on a certificate when rendered. + + If the certificate has a certificate date override associated with it, display the override date. + + Otherwise, if the course has a display behavior of "END_WITH_DATE", display the associated certificate available + date. If the course has a display behavior of "END", we should display the end date of the course. Lastly, when the + display behavior is "EARLY_NO_INFO" or when the course run is self-paced, we display the modified date of the + certificate instance. Arguments: course (CourseOverview or course block): The course we're getting the date for @@ -872,8 +889,17 @@ def display_date_for_certificate(course, certificate): if _course_uses_available_date(course) and course.certificate_available_date < datetime.now(UTC): return course.certificate_available_date - - return certificate.modified_date + # It is possible for a self-paced course run to end up configured with a display behavior of "END" even though it + # shouldn't be a valid option. We must check if the course is instructor-paced here to ensure that we are selecting + # the correct date to display. + elif ( + not course.self_paced + and course.certificates_display_behavior == CertificatesDisplayBehaviors.END + and course.end + ): + return course.end + else: + return certificate.modified_date def is_valid_pdf_certificate(cert_data): diff --git a/lms/djangoapps/certificates/apis/v0/tests/test_views.py b/lms/djangoapps/certificates/apis/v0/tests/test_views.py index efff97f54d..c8ef620dd2 100644 --- a/lms/djangoapps/certificates/apis/v0/tests/test_views.py +++ b/lms/djangoapps/certificates/apis/v0/tests/test_views.py @@ -173,17 +173,22 @@ class CertificatesListRestApiTest(AuthAndScopesTestMixin, SharedModuleStoreTestC def assert_success_response_for_student(self, response, download_url='www.google.com'): """ This method is required by AuthAndScopesTestMixin. """ - assert response.data ==\ - [{'username': self.student.username, - 'course_id': str(self.course.id), - 'course_display_name': self.course.display_name, - 'course_organization': self.course.org, - 'certificate_type': CourseMode.VERIFIED, - 'created_date': self.now, - 'modified_date': self.now, - 'status': CertificateStatuses.downloadable, - 'is_passing': True, - 'download_url': download_url, 'grade': '0.88'}] + assert response.data == [ + { + 'username': self.student.username, + 'course_id': str(self.course.id), + 'course_display_name': self.course.display_name, + 'course_organization': self.course.org, + 'certificate_type': CourseMode.VERIFIED, + 'created_date': self.now, + 'modified_date': self.now, + 'status': CertificateStatuses.downloadable, + 'is_passing': True, + 'download_url': download_url, + 'grade': '0.88', + 'uuid': str(self.cert.verify_uuid) + } + ] @patch('edx_rest_framework_extensions.permissions.log') @ddt.data(*list(AuthType)) @@ -212,6 +217,7 @@ class CertificatesListRestApiTest(AuthAndScopesTestMixin, SharedModuleStoreTestC assert resp.status_code == status.HTTP_200_OK assert len(resp.data) == 1 + assert 'uuid' in resp.data[0] def test_owner_can_access_its_certs(self): """ @@ -227,6 +233,7 @@ class CertificatesListRestApiTest(AuthAndScopesTestMixin, SharedModuleStoreTestC resp = self.get_response(AuthType.session, requesting_user=self.student) assert resp.status_code == status.HTTP_200_OK + assert 'uuid' in resp.data[0] # verifies that other than owner cert list api is not accessible resp = self.get_response(AuthType.session, requesting_user=self.other_student) @@ -246,12 +253,15 @@ class CertificatesListRestApiTest(AuthAndScopesTestMixin, SharedModuleStoreTestC resp = self.get_response(AuthType.session, requesting_user=self.student) assert resp.status_code == status.HTTP_200_OK + assert 'uuid' in resp.data[0] resp = self.get_response(AuthType.session, requesting_user=self.other_student) assert resp.status_code == status.HTTP_200_OK + assert 'uuid' in resp.data[0] resp = self.get_response(AuthType.session, requesting_user=self.global_staff) assert resp.status_code == status.HTTP_200_OK + assert 'uuid' in resp.data[0] @ddt.data(*list(AuthType)) def test_another_user_with_certs_shared_custom(self, auth_type): @@ -276,6 +286,7 @@ class CertificatesListRestApiTest(AuthAndScopesTestMixin, SharedModuleStoreTestC assert resp.status_code == status.HTTP_200_OK assert len(resp.data) == 1 + assert 'uuid' in resp.data[0] @patch('edx_rest_framework_extensions.permissions.log') @ddt.data(*JWT_AUTH_TYPES) @@ -290,6 +301,7 @@ class CertificatesListRestApiTest(AuthAndScopesTestMixin, SharedModuleStoreTestC else: assert resp.status_code == status.HTTP_200_OK assert len(resp.data) == 1 + assert 'uuid' in resp.data[0] @patch('edx_rest_framework_extensions.permissions.log') @ddt.data(*JWT_AUTH_TYPES) @@ -422,3 +434,4 @@ class CertificatesListRestApiTest(AuthAndScopesTestMixin, SharedModuleStoreTestC assert response.status_code == status.HTTP_200_OK self.assertContains(response, cert_for_deleted_course.download_url) self.assertContains(response, expected_course_name) + assert 'uuid' in response.data[0] diff --git a/lms/djangoapps/certificates/apis/v0/views.py b/lms/djangoapps/certificates/apis/v0/views.py index 121f37fe72..aa6512535b 100644 --- a/lms/djangoapps/certificates/apis/v0/views.py +++ b/lms/djangoapps/certificates/apis/v0/views.py @@ -244,6 +244,7 @@ class CertificatesListView(APIView): 'is_passing': user_cert.get('is_passing'), 'download_url': user_cert.get('download_url'), 'grade': user_cert.get('grade'), + 'uuid': user_cert.get('uuid'), }) return Response(user_certs) diff --git a/lms/djangoapps/certificates/docs/decisions/003-web-certs.rst b/lms/djangoapps/certificates/docs/decisions/003-web-certs.rst index 191a8d1d88..fd3112580f 100644 --- a/lms/djangoapps/certificates/docs/decisions/003-web-certs.rst +++ b/lms/djangoapps/certificates/docs/decisions/003-web-certs.rst @@ -117,9 +117,9 @@ Related DEPR (edX deprecation process) tickets: * `Remove PDF generation code`_ * `Remove PDF view code`_ -.. _Enable Course Certificates: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/enable_certificates.html +.. _Enable Course Certificates: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/enable_certificates.html .. _Deprecate web certificate setting: https://github.com/openedx/edx-platform/pull/17285 .. _Disable PDF certificate generation: https://github.com/openedx/edx-platform/pull/19833 -.. _Set Up Certificates in Studio: https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/set_up_course/studio_add_course_information/studio_creating_certificates.html +.. _Set Up Certificates in Studio: https://docs.openedx.org/en/latest/educators/how-tos/set_up_course/manage_certificates.html .. _Remove PDF generation code: https://openedx.atlassian.net/browse/DEPR-155 .. _Remove PDF view code: https://openedx.atlassian.net/browse/DEPR-157 diff --git a/lms/djangoapps/certificates/management/commands/purge_references_to_pdf_certificates.py b/lms/djangoapps/certificates/management/commands/purge_references_to_pdf_certificates.py new file mode 100644 index 0000000000..384e58b308 --- /dev/null +++ b/lms/djangoapps/certificates/management/commands/purge_references_to_pdf_certificates.py @@ -0,0 +1,104 @@ +""" +A management command designed to be part of the retirement pipeline for any Open EdX operators +with users who still have legacy PDF certificates. + +Once an external process has run to remove the four files comprising a legacy PDF certificate, +this management command will remove the reference to the file from the certificate record. + +Note: it is important to retain the reference in the certificate table until +the files have been deleted, because that reference is the files' identifying descriptor. +""" + +import logging +import shlex + +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand, CommandError + +from lms.djangoapps.certificates.models import ( + GeneratedCertificate, + PurgeReferencestoPDFCertificatesCommandConfiguration, +) + +User = get_user_model() +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Doesn't invoke the custom save() function defined as part of the `GeneratedCertificate` + model; perforce will emit no Django signals. This is desired behavior. We are + using this management command to purge information that was never sent to any + other systems, so we don't need to propagate updates. + + Example usage: + + # Dry Run (preview changes): + $ ./manage.py lms purge_references_to_pdf_certificates --dry-run + + # Purge data: + $ ./manage.py lms purge_references_to_pdf_certificates + """ + + help = """Purges references to PDF certificates. Intended to be run after the files have been deleted.""" + + def add_arguments(self, parser): + parser.add_argument( + "--dry-run", + action="store_true", + help="Shows a preview of what users would be affected by running this management command.", + ) + parser.add_argument( + "--certificate_ids", + nargs="+", + dest="certificate_ids", + help="space-separated list of GeneratedCertificate IDs to clean up", + ) + parser.add_argument( + "--args-from-database", + action="store_true", + help=( + "Use arguments from the PurgeReferencesToPDFCertificatesCommandConfiguration " + "model instead of the command line" + ), + ) + + def get_args_from_database(self): + """ + Returns an options dictionary from the current CertificateGenerationCommandConfiguration model. + """ + config = PurgeReferencestoPDFCertificatesCommandConfiguration.current() + if not config.enabled: + raise CommandError( + "PurgeReferencestoPDFCertificatesCommandConfiguration is disabled, " + "but --args-from-database was requested" + ) + + args = shlex.split(config.arguments) + parser = self.create_parser("manage.py", "purge_references_to_pdf_certificates") + + return vars(parser.parse_args(args)) + + def handle(self, *args, **options): + # database args will override cmd line args + if options["args_from_database"]: + options = self.get_args_from_database() + + if options["dry_run"]: + dry_run_string = "[DRY RUN] " + else: + dry_run_string = "" + + certificate_ids = options.get("certificate_ids") + if not certificate_ids: + raise CommandError("You must specify one or more certificate IDs") + + log.info( + f"{dry_run_string}Purging download_url and download_uri " + f"from the following certificate records: {certificate_ids}" + ) + if not options["dry_run"]: + GeneratedCertificate.objects.filter(id__in=certificate_ids).update( + download_url="", + download_uuid="", + ) diff --git a/lms/djangoapps/certificates/management/commands/tests/test_purge_references_to_pdf_certificates.py b/lms/djangoapps/certificates/management/commands/tests/test_purge_references_to_pdf_certificates.py new file mode 100644 index 0000000000..b831aa813f --- /dev/null +++ b/lms/djangoapps/certificates/management/commands/tests/test_purge_references_to_pdf_certificates.py @@ -0,0 +1,101 @@ +""" +Tests for the `purge_references_to_pdf_certificates` management command. +""" + +import uuid + +from django.core.management import CommandError, call_command +from testfixtures import LogCapture + +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.certificates.models import GeneratedCertificate +from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +class PurgeReferencesToPDFCertificatesTests(ModuleStoreTestCase): + """ + Tests for the `purge_references_to_pdf_certificates` management command. + """ + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.course_run_1 = CourseFactory() + self.course_run_2 = CourseFactory() + self.course_run_3 = CourseFactory() + self.cert_1 = GeneratedCertificateFactory( + user=self.user, + course_id=self.course_run_1.id, + download_url="http://example.com/1", + download_uuid=uuid.uuid4(), + grade=1.00, + ) + self.cert_2 = GeneratedCertificateFactory( + user=self.user, + course_id=self.course_run_2.id, + download_url="http://example.com/2", + download_uuid=uuid.uuid4(), + grade=2.00, + ) + self.cert_3 = GeneratedCertificateFactory( + user=self.user, + course_id=self.course_run_3.id, + download_url="http://example.com/3", + download_uuid=uuid.uuid4(), + grade=3.00, + ) + + def test_command_with_missing_certificate_ids(self): + """ + Verify command with a missing certificate_ids param. + """ + with self.assertRaises(CommandError): + call_command("purge_references_to_pdf_certificates") + + def test_management_command(self): + """ + Verify the management command purges expected data from only the certs requested. + """ + call_command( + "purge_references_to_pdf_certificates", + "--certificate_ids", + self.cert_2.id, + self.cert_3.id, + ) + + cert1_post = GeneratedCertificate.objects.get(id=self.cert_1.id) + cert2_post = GeneratedCertificate.objects.get(id=self.cert_2.id) + cert3_post = GeneratedCertificate.objects.get(id=self.cert_3.id) + self.assertEqual(cert1_post.download_url, "http://example.com/1") + self.assertNotEqual(cert1_post.download_uuid, "") + + self.assertEqual(cert2_post.download_url, "") + self.assertEqual(cert2_post.download_uuid, "") + + self.assertEqual(cert3_post.download_url, "") + self.assertEqual(cert3_post.download_uuid, "") + + def test_management_command_dry_run(self): + """ + Verify that the management command does not purge any data when invoked with the `--dry-run` flag + """ + expected_log_msg = ( + "[DRY RUN] Purging download_url and download_uri " + f"from the following certificate records: {list(str(self.cert_3.id))}" + ) + + with LogCapture() as logger: + call_command( + "purge_references_to_pdf_certificates", + "--dry-run", + "--certificate_ids", + self.cert_3.id, + ) + + cert3_post = GeneratedCertificate.objects.get(id=self.cert_3.id) + self.assertEqual(cert3_post.download_url, "http://example.com/3") + self.assertNotEqual(cert3_post.download_uuid, "") + + assert logger.records[0].msg == expected_log_msg diff --git a/lms/djangoapps/certificates/migrations/0038_pdf_certificate_purge_data_management.py b/lms/djangoapps/certificates/migrations/0038_pdf_certificate_purge_data_management.py new file mode 100644 index 0000000000..6816719975 --- /dev/null +++ b/lms/djangoapps/certificates/migrations/0038_pdf_certificate_purge_data_management.py @@ -0,0 +1,29 @@ +# Generated by Django 4.2.18 on 2025-02-20 22:36 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('certificates', '0037_fix_legacy_broken_invalid_certs'), + ] + + operations = [ + migrations.CreateModel( + name='PurgeReferencestoPDFCertificatesCommandConfiguration', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), + ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), + ('arguments', models.TextField(blank=True, default='', help_text="Arguments for the 'purge_references_to_pdf_certificates' management command. Specify like '--certificate_ids '")), + ('changed_by', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL, verbose_name='Changed by')), + ], + options={ + 'verbose_name': 'purge_references_to_pdf_certificates argument', + }, + ), + ] diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 9c58327b99..ac674167a2 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -403,6 +403,7 @@ class GeneratedCertificate(models.Model): ) # .. event_implemented_name: CERTIFICATE_REVOKED + # .. event_type: org.openedx.learning.certificate.revoked.v1 CERTIFICATE_REVOKED.send_event( time=self.modified_date.astimezone(timezone.utc), certificate=CertificateData( @@ -487,6 +488,7 @@ class GeneratedCertificate(models.Model): ) # .. event_implemented_name: CERTIFICATE_CHANGED + # .. event_type: org.openedx.learning.certificate.changed.v1 CERTIFICATE_CHANGED.send_event( time=timestamp, certificate=CertificateData( @@ -520,6 +522,7 @@ class GeneratedCertificate(models.Model): ) # .. event_implemented_name: CERTIFICATE_CREATED + # .. event_type: org.openedx.learning.certificate.created.v1 CERTIFICATE_CREATED.send_event( time=timestamp, certificate=CertificateData( @@ -1289,6 +1292,30 @@ class CertificateGenerationCommandConfiguration(ConfigurationModel): return str(self.arguments) +class PurgeReferencestoPDFCertificatesCommandConfiguration(ConfigurationModel): + """ + Manages configuration for a run of the purge_references_to_pdf_certificates management command. + + .. no_pii: + """ + + class Meta: + app_label = "certificates" + verbose_name = "purge_references_to_pdf_certificates argument" + + arguments = models.TextField( + blank=True, + help_text=( + "Arguments for the 'purge_references_to_pdf_certificates' management command. " + "Specify like '--certificate_ids '" + ), + default="", + ) + + def __str__(self): + return str(self.arguments) + + class CertificateDateOverride(TimeStampedModel): """ Model to manually override a given certificate date with the given date. diff --git a/lms/djangoapps/certificates/tests/test_api.py b/lms/djangoapps/certificates/tests/test_api.py index cb11b9e00b..ca12c98966 100644 --- a/lms/djangoapps/certificates/tests/test_api.py +++ b/lms/djangoapps/certificates/tests/test_api.py @@ -210,13 +210,14 @@ class CertificateDownloadableStatusTests(WebCertificateTestMixin, ModuleStoreTes } @ddt.data( - (True, timedelta(days=2), CertificatesDisplayBehaviors.END_WITH_DATE, True, None), - (False, -timedelta(days=2), CertificatesDisplayBehaviors.EARLY_NO_INFO, True, None), - (False, timedelta(days=2), CertificatesDisplayBehaviors.EARLY_NO_INFO, True, None), - (False, -timedelta(days=2), CertificatesDisplayBehaviors.END, True, None), - (False, timedelta(days=2), CertificatesDisplayBehaviors.END, False, True), - (False, -timedelta(days=2), CertificatesDisplayBehaviors.END_WITH_DATE, True, None), - (False, timedelta(days=2), CertificatesDisplayBehaviors.END_WITH_DATE, False, True), + (True, timedelta(days=2), CertificatesDisplayBehaviors.END_WITH_DATE, True, None, None), + (False, -timedelta(days=2), CertificatesDisplayBehaviors.EARLY_NO_INFO, True, None, None), + (False, timedelta(days=2), CertificatesDisplayBehaviors.EARLY_NO_INFO, True, None, None), + (False, -timedelta(days=2), CertificatesDisplayBehaviors.END, True, None, None), + (False, timedelta(days=2), CertificatesDisplayBehaviors.END, False, True, None), + (False, -timedelta(days=2), CertificatesDisplayBehaviors.END_WITH_DATE, True, None, None), + (False, timedelta(days=2), CertificatesDisplayBehaviors.END_WITH_DATE, False, True, None), + (False, timedelta(days=2), CertificatesDisplayBehaviors.END_WITH_DATE, False, None, True), ) @ddt.unpack @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) @@ -227,6 +228,7 @@ class CertificateDownloadableStatusTests(WebCertificateTestMixin, ModuleStoreTes certificates_display_behavior, cert_downloadable_status, earned_but_not_available, + no_earned_but_available_date, ): """ Test 'downloadable status' @@ -239,7 +241,14 @@ class CertificateDownloadableStatusTests(WebCertificateTestMixin, ModuleStoreTes self._setup_course_certificate() - downloadable_status = certificate_downloadable_status(self.student, self.course.id) + if no_earned_but_available_date: + downloadable_status = certificate_downloadable_status(self.student_no_cert, self.course.id) + + assert downloadable_status.get("not_earned_but_available_date") == no_earned_but_available_date + assert downloadable_status.get("certificate_available_date") is not None + else: + downloadable_status = certificate_downloadable_status(self.student, self.course.id) + assert downloadable_status["is_downloadable"] == cert_downloadable_status assert downloadable_status.get("earned_but_not_available") == earned_but_not_available @@ -1135,6 +1144,67 @@ class CertificatesApiTestCase(TestCase): assert date == display_date_for_certificate(self.course, self.certificate) assert maybe_avail == available_date_for_certificate(self.course, self.certificate) + def test_display_date_for_certificate_cdb_early_no_info(self): + """ + Test to verify that the "earned date" displayed on a course certificate is the last modified date of a + certificate instance when the display behavior is set to EARLY_NO_INFO. + """ + with configure_waffle_namespace(True): + self.course.self_paced = False + self.course.certificates_display_behavior = CertificatesDisplayBehaviors.EARLY_NO_INFO + assert display_date_for_certificate(self.course, self.certificate) == self.certificate.modified_date + + def test_display_date_for_certificate_cdb_end_with_date(self): + """ + Test to verify that the "earned date" displayed on a course certificate is the certificate available date + associated with the course when the display behavior is set to END_WITH_DATE. + """ + with configure_waffle_namespace(True): + self.course.self_paced = False + self.course.certificates_display_behavior = CertificatesDisplayBehaviors.END_WITH_DATE + self.course.certificate_available_date = datetime(2017, 2, 1, tzinfo=pytz.UTC) + assert display_date_for_certificate(self.course, self.certificate) == self.course.certificate_available_date + + def test_display_date_for_certificate_cdb_end(self): + """ + Test to verify that the "earned date" displayed on a course certificate is the end date of the course run + when the display behavior is set to END. + """ + with configure_waffle_namespace(True): + self.course.self_paced = False + self.course.certificates_display_behavior = CertificatesDisplayBehaviors.END + assert display_date_for_certificate(self.course, self.certificate) == self.course.end + + def test_display_date_for_certificate_date_override(self): + """ + Test to verify that the "earned date" displayed on a course certificate is the certificate override date + if-and-only-if date override associated with the certificate instance. + """ + with configure_waffle_namespace(True): + self.certificate.date_override = datetime(2016, 1, 1, tzinfo=pytz.UTC) + assert display_date_for_certificate(self.course, self.certificate) == self.certificate.date_override.date + + def test_display_date_for_self_paced_course_run(self): + """ + Test to verify that the "earned date" displayed on a course certificate is the last modified date of a + certificate instance when the display behavior is set to EARLY_NO_INFO and the course run is self-paced. + """ + with configure_waffle_namespace(True): + self.course.self_paced = True + self.course.certificates_display_behavior = CertificatesDisplayBehaviors.EARLY_NO_INFO + assert display_date_for_certificate(self.course, self.certificate) == self.certificate.modified_date + + def test_display_date_for_self_paced_course_run_with_cdb_end(self): + """ + Test for a bug fix and some defensive coding. It is possible for self-paced course runs to end up with a display + behavior of END. This test ensures that we select the correct issue date even when the course run's + configuration is unexpected. + """ + with configure_waffle_namespace(True): + self.course.self_paced = True + self.course.certificates_display_behavior = CertificatesDisplayBehaviors.END + assert display_date_for_certificate(self.course, self.certificate) == self.certificate.modified_date + @ddt.ddt class CertificatesMessagingTestCase(ModuleStoreTestCase): diff --git a/lms/djangoapps/certificates/tests/test_webview_views.py b/lms/djangoapps/certificates/tests/test_webview_views.py index 7949b84f13..1bfcd6ae2e 100644 --- a/lms/djangoapps/certificates/tests/test_webview_views.py +++ b/lms/djangoapps/certificates/tests/test_webview_views.py @@ -3,7 +3,7 @@ import datetime from unittest import skipUnless -from unittest.mock import patch +from unittest.mock import patch, Mock from urllib.parse import urlencode from uuid import uuid4 @@ -35,6 +35,7 @@ from lms.djangoapps.certificates.tests.factories import ( GeneratedCertificateFactory, LinkedInAddToProfileConfigurationFactory ) +from lms.djangoapps.certificates.views.webview import _get_user_certificate from lms.djangoapps.certificates.utils import get_certificate_url from openedx.core.djangoapps.dark_lang.models import DarkLangConfig from openedx.core.djangoapps.site_configuration.tests.test_util import ( @@ -46,9 +47,9 @@ from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from openedx.core.lib.courses import course_image_url from openedx.core.lib.tests.assertions.events import assert_event_matches from openedx.features.name_affirmation_api.utils import get_name_affirmation_service -from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order -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 xmodule.data import CertificatesDisplayBehaviors +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True @@ -1707,6 +1708,115 @@ class CertificatesViewsTests(CommonCertificatesTestCase, CacheIsolationTestCase) self.assertNotContains(response, 'identity of the learner has been checked and is valid') self.assertContains(response, 'IDV disabled') + @patch("lms.djangoapps.certificates.views.webview.datetime") + def test_get_user_certificate_preview_instructor_paced_early_no_info(self, mock_datetime): + """ + A test to verify that the _get_user_certificate utility function returns the expected date for an instructor + paced course with a display behavior of 'EARLY_NO_INFO' when the certificate is being previewed. + """ + expected_date = datetime.date(2024, 5, 2) + mock_datetime.now.return_value.date.return_value = expected_date + + mock_request = Mock() + mock_request.user.has_perm.return_value = True + + self.course.certificates_display_behavior = CertificatesDisplayBehaviors.EARLY_NO_INFO + self.course.certificate_available_date = None + self.course.self_paced = False + + cert = _get_user_certificate(mock_request, self.user, self.course.id, self.course, preview_mode='verified') + assert cert.modified_date == expected_date + + def test_get_user_certificate_preview_instructor_paced_end(self): + """ + A test to verify that the _get_user_certificate utility function returns the expected date for an instructor + paced course with a display behavior of 'END' when the certificate is being previewed. + """ + course_run_end_date = datetime.datetime(2024, 8, 19, 0, 0, 0) + + mock_request = Mock() + mock_request.user.has_perm.return_value = True + + self.course.certificates_display_behavior = CertificatesDisplayBehaviors.END + self.course.certificate_available_date = None + self.course.self_paced = False + self.course.end = course_run_end_date + + cert = _get_user_certificate(mock_request, self.user, self.course.id, self.course, preview_mode='verified') + assert cert.modified_date == course_run_end_date + + def test_get_user_certificate_preview_instructor_paced_end_with_date(self): + """ + A test to verify that the _get_user_certificate utility function returns the expected date for an instructor + paced course with a display behavior of 'END_WITH_DATE' when the certificate is being previewed. + """ + course_run_certificate_available_date = datetime.datetime(2024, 3, 14, 0, 0, 0) + + mock_request = Mock() + mock_request.user.has_perm.return_value = True + + self.course.certificates_display_behavior = CertificatesDisplayBehaviors.END_WITH_DATE + self.course.certificate_available_date = course_run_certificate_available_date + self.course.self_paced = False + + cert = _get_user_certificate(mock_request, self.user, self.course.id, self.course, preview_mode='verified') + assert cert.modified_date == course_run_certificate_available_date + + @patch("lms.djangoapps.certificates.views.webview.datetime") + def test_get_user_certificate_preview_self_paced(self, mock_datetime): + """ + A test to verify that the _get_user_certificate utility function returns the expected date for a self paced + course when the certificate is being previewed. + """ + expected_date = datetime.date(2024, 3, 10) + mock_datetime.now.return_value.date.return_value = expected_date + + mock_request = Mock() + mock_request.user.has_perm.return_value = True + + self.course.certificates_display_behavior = CertificatesDisplayBehaviors.EARLY_NO_INFO + self.course.certificate_available_date = None + self.course.self_paced = True + + cert = _get_user_certificate(mock_request, self.user, self.course.id, self.course, preview_mode='verified') + assert cert.modified_date == expected_date + + def test_get_user_certificate(self): + """ + A test to verify that the _get_user_certificate utility function returns the correct certificate when requested. + """ + mock_request = Mock() + + cert = _get_user_certificate(mock_request, self.user, self.course.id, self.course) + assert cert.course_id == self.course.id + assert cert.user == self.user + assert cert.status == CertificateStatuses.downloadable + + def test_get_user_certificate_no_eligible_cert(self): + """ + A test to verify the behavior of the _get_user_certificate utility function when there is no eligible + certificate to retrieve. + """ + self.cert.status = CertificateStatuses.unavailable + self.cert.save() + + mock_request = Mock() + + cert = _get_user_certificate(mock_request, self.user, self.course.id, self.course) + assert cert is None + + def test_get_user_certificate_no_cert(self): + """ + A test to verify the behavior of the _get_user_certificate utility function when there is no certificate to + retrieve. + """ + GeneratedCertificate.objects.all().delete() + + mock_request = Mock() + + cert = _get_user_certificate(mock_request, self.user, self.course.id, self.course) + assert cert is None + class CertificateEventTests(CommonCertificatesTestCase, EventTrackingTestCase): """ diff --git a/lms/djangoapps/certificates/views/webview.py b/lms/djangoapps/certificates/views/webview.py index 06e4e8a553..3b6cc75e48 100644 --- a/lms/djangoapps/certificates/views/webview.py +++ b/lms/djangoapps/certificates/views/webview.py @@ -351,7 +351,10 @@ def _get_user_certificate(request, user, course_key, course_overview, preview_mo """ user_certificate = None if preview_mode: - # certificate is being previewed from studio + # The certificate is being previewed from the CMS. When previewing a certificate the "modified date" is + # displayed when rendered. We try to set the "modified date" of the artificial certificate record in such a way + # that it matches the date selection logic used by the system when rendering a "real" certificate instance. See + # the `display_date_for_certificate function` in the lms/djangoapps/certificates/api.py file. if request.user.has_perm(PREVIEW_CERTIFICATES, course_overview): if ( course_overview.certificates_display_behavior == CertificatesDisplayBehaviors.END_WITH_DATE @@ -359,7 +362,11 @@ def _get_user_certificate(request, user, course_key, course_overview, preview_mo and not course_overview.self_paced ): modified_date = course_overview.certificate_available_date - elif course_overview.certificates_display_behavior == CertificatesDisplayBehaviors.END: + elif ( + course_overview.certificates_display_behavior == CertificatesDisplayBehaviors.END + and course_overview.end + and not course_overview.self_paced + ): modified_date = course_overview.end else: modified_date = datetime.now().date() diff --git a/lms/djangoapps/commerce/api/v0/views.py b/lms/djangoapps/commerce/api/v0/views.py index 1022173503..a6cadadb43 100644 --- a/lms/djangoapps/commerce/api/v0/views.py +++ b/lms/djangoapps/commerce/api/v0/views.py @@ -4,26 +4,27 @@ import logging from urllib.parse import urljoin -from django.urls import reverse from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from requests.exceptions import HTTPError from rest_framework.permissions import IsAuthenticated -from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_409_CONFLICT +from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_409_CONFLICT, HTTP_400_BAD_REQUEST, HTTP_403_FORBIDDEN from rest_framework.views import APIView from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.entitlements.models import CourseEntitlement -from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.models import CourseEnrollment, EnrollmentNotAllowed from common.djangoapps.util.json_request import JsonResponse from lms.djangoapps.courseware import courses from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client from openedx.core.djangoapps.embargo import api as embargo_api from openedx.core.djangoapps.enrollments.api import add_enrollment +from openedx.core.djangoapps.enrollments.errors import InvalidEnrollmentAttribute from openedx.core.djangoapps.enrollments.views import EnrollmentCrossDomainSessionAuth from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser +from openedx.features.course_experience.url_helpers import make_learning_mfe_courseware_url from ...constants import Messages from ...http import DetailResponse @@ -122,7 +123,7 @@ class BasketsView(APIView): if CourseEntitlement.check_for_existing_entitlement_and_enroll(user=user, course_run_key=course_key): return JsonResponse( { - 'redirect_destination': reverse('courseware', args=[str(course_id)]), + 'redirect_destination': make_learning_mfe_courseware_url(course_id), }, ) @@ -149,7 +150,28 @@ class BasketsView(APIView): announcement=course_announcement ) log.info(msg) - self._enroll(course_key, user, default_enrollment_mode.slug) + + try: + self._enroll(course_key, user, default_enrollment_mode.slug) + except InvalidEnrollmentAttribute as e: + # Exception handling for InvalidEnrollmentAttribute + return self._handle_enrollment_error( + e, + user, + course_id, + "Invalid enrollment attribute ", + HTTP_400_BAD_REQUEST + ) + except EnrollmentNotAllowed as e: + # Exception handling for EnrollmentNotAllowed + return self._handle_enrollment_error( + e, + user, + course_id, + "Enrollment not allowed ", + HTTP_403_FORBIDDEN + ) + mode = CourseMode.AUDIT if audit_mode else CourseMode.HONOR # lint-amnesty, pylint: disable=unused-variable self._handle_marketing_opt_in(request, course_key, user) return DetailResponse(msg) @@ -157,6 +179,24 @@ class BasketsView(APIView): msg = Messages.NO_DEFAULT_ENROLLMENT_MODE.format(course_id=course_id) return DetailResponse(msg, status=HTTP_406_NOT_ACCEPTABLE) + def _handle_enrollment_error(self, exception, user, course_id, log_message, status_code): + """ + Helper function to handle enrollment exceptions. + + Args: + exception (Exception): The exception raised. + user (User): The user attempting to enroll. + course_id (str): The course ID. + log_message (str): The log message template. + status_code (int): The HTTP status code to return. + + Returns: + DetailResponse: The response with the error message and status code. + """ + log.exception(log_message, str(exception)) + error_msg = f"{log_message.format(str(exception))} for user {user.username} in course {course_id}: {str(exception)}" # lint-amnesty, pylint: disable=line-too-long + return DetailResponse(error_msg, status=status_code) + class BasketOrderView(APIView): """ diff --git a/lms/djangoapps/commerce/utils.py b/lms/djangoapps/commerce/utils.py index 9abcabd4a9..6ecc96f460 100644 --- a/lms/djangoapps/commerce/utils.py +++ b/lms/djangoapps/commerce/utils.py @@ -153,6 +153,7 @@ class EcommerceService: return None +@pluggable_override('OVERRIDE_REFUND_ENTITLEMENT') def refund_entitlement(course_entitlement): """ Attempt a refund of a course entitlement. Verify the User before calling this refund method @@ -277,6 +278,32 @@ def refund_seat(course_enrollment, change_mode=False): return refund_ids +@pluggable_override('OVERRIDE_GET_PROGRAM_PRICE_INFO') +def get_program_price_info(api_user, params): + """ + Get the program price info from the ecommerce service. + + Args: + api_user: The user to use to make the request. + params: The params to use to make the request. + + Returns: + JSON: { + 'total_incl_tax_excl_discounts': basket.total_incl_tax_excl_discounts, + 'total_incl_tax': basket.total_incl_tax, + 'currency': basket.currency + } + """ + if not api_user.is_authenticated: + api_user = get_user_model().objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME) + + api_client = get_ecommerce_api_client(api_user) + api_url = urljoin(f"{get_ecommerce_api_base_url()}/", "baskets/calculate/") + + response = api_client.get(api_url, params=params) + return response + + def auto_enroll(course_enrollment): """ Helper method to update an enrollment to a default course mode. diff --git a/lms/djangoapps/course_api/blocks/serializers.py b/lms/djangoapps/course_api/blocks/serializers.py index 666724e357..b3370fc677 100644 --- a/lms/djangoapps/course_api/blocks/serializers.py +++ b/lms/djangoapps/course_api/blocks/serializers.py @@ -6,6 +6,7 @@ from django.conf import settings from rest_framework import serializers from rest_framework.reverse import reverse +from lms.djangoapps.course_blocks.transformers.hidden_content import HiddenContentTransformer from lms.djangoapps.course_blocks.transformers.visibility import VisibilityTransformer from openedx.core.djangoapps.discussions.transformers import DiscussionsTopicLinkTransformer @@ -81,6 +82,13 @@ SUPPORTED_FIELDS = [ VisibilityTransformer, requested_field_name='visible_to_staff_only', ), + + SupportedFieldType( + 'merged_hide_after_due', + HiddenContentTransformer, + requested_field_name='hide_after_due' + ), + SupportedFieldType(BlockCompletionTransformer.COMPLETION, BlockCompletionTransformer), SupportedFieldType(BlockCompletionTransformer.COMPLETE), SupportedFieldType(BlockCompletionTransformer.RESUME_BLOCK), diff --git a/lms/djangoapps/course_api/blocks/tests/test_serializers.py b/lms/djangoapps/course_api/blocks/tests/test_serializers.py index e3bae70e23..b1b9f4cfef 100644 --- a/lms/djangoapps/course_api/blocks/tests/test_serializers.py +++ b/lms/djangoapps/course_api/blocks/tests/test_serializers.py @@ -84,6 +84,7 @@ class TestBlockSerializerBase(SharedModuleStoreTestCase): 'student_view_multi_device', 'lti_url', 'visible_to_staff_only', + 'hide_after_due' ]) def assert_extended_block(self, serialized_block): @@ -100,7 +101,8 @@ class TestBlockSerializerBase(SharedModuleStoreTestCase): 'graded', 'student_view_multi_device', 'lti_url', - 'visible_to_staff_only' + 'visible_to_staff_only', + 'hide_after_due' } <= set(serialized_block.keys()) # video blocks should have student_view_data diff --git a/lms/djangoapps/course_api/views.py b/lms/djangoapps/course_api/views.py index b2019c793f..698800bf18 100644 --- a/lms/djangoapps/course_api/views.py +++ b/lms/djangoapps/course_api/views.py @@ -172,7 +172,7 @@ class LazyPageNumberPagination(NamespacedPageNumberPagination): The paginator cache uses ``@cached_property`` to cache the property values for count and num_pages. It assumes these won't change, but in the case of a - LazySquence, its count gets updated as we move through it. This class clears + LazySequence, its count gets updated as we move through it. This class clears the cached property values before reporting results so they will be recalculated. """ diff --git a/lms/djangoapps/course_blocks/transformers/tests/test_visibility.py b/lms/djangoapps/course_blocks/transformers/tests/test_visibility.py index 8ceb821a64..6b1f311ba4 100644 --- a/lms/djangoapps/course_blocks/transformers/tests/test_visibility.py +++ b/lms/djangoapps/course_blocks/transformers/tests/test_visibility.py @@ -36,7 +36,7 @@ class VisibilityTransformerTestCase(BlockParentsMapTestCase): ): for idx, _ in enumerate(self.parents_map): block = self.get_block(idx) - block.visible_to_staff_only = (idx in staff_only_blocks) + block.visible_to_staff_only = idx in staff_only_blocks update_block(block) self.assert_transform_results( diff --git a/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py b/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py index b49d79976c..e43fedcb6b 100644 --- a/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py +++ b/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py @@ -1,7 +1,12 @@ """ Command to trigger sending reminder emails for learners to achieve their Course Goals """ +import time from datetime import date, datetime, timedelta + +import boto3 +from edx_ace.channel.django_email import DjangoEmailChannel +from edx_ace.channel.mixins import EmailChannelMixin from eventtracking import tracker import logging import uuid @@ -9,10 +14,10 @@ import uuid from django.conf import settings from django.contrib.sites.models import Site from django.core.management.base import BaseCommand -from edx_ace import ace +from edx_ace import ace, presentation from edx_ace.message import Message from edx_ace.recipient import Recipient - +from edx_ace.utils.signals import send_ace_message_sent_signal from common.djangoapps.student.models import CourseEnrollment from lms.djangoapps.certificates.api import get_certificate_for_user_id from lms.djangoapps.certificates.data import CertificateStatuses @@ -44,6 +49,9 @@ def send_ace_message(goal, session_id): Returns true if sent, false if it absorbed an exception and did not send """ user = goal.user + if not user.has_usable_password(): + log.info(f'Goal Reminder User is disabled {user.username} course {goal.course_key}') + return False try: course = CourseOverview.get_from_id(goal.course_key) except CourseOverview.DoesNotExist: @@ -84,6 +92,7 @@ def send_ace_message(goal, session_id): message_context.update({ 'email': user.email, + 'user_name': user.username, 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), 'course_name': course_name, 'course_id': str(goal.course_key), @@ -95,18 +104,22 @@ def send_ace_message(goal, session_id): 'omit_unsubscribe_link': True, 'courses_url': getattr(settings, 'ACE_EMAIL_COURSES_URL', None), 'programs_url': getattr(settings, 'ACE_EMAIL_PROGRAMS_URL', None), + 'goal_reminder_banner_url': settings.GOAL_REMINDER_BANNER_URL, + 'goal_reminder_profile_url': settings.GOAL_REMINDER_PROFILE_URL, }) - options = {'transactional': True} + options = { + 'transactional': True, + 'skip_disable_user_policy': True + } is_ses_enabled = ENABLE_SES_FOR_GOALREMINDER.is_enabled(goal.course_key) if is_ses_enabled: - options = { - 'transactional': True, + options.update({ 'from_address': settings.LMS_COMM_DEFAULT_FROM_EMAIL, 'override_default_channel': 'django_email', - } + }) msg = Message( name="goalreminder", @@ -119,7 +132,15 @@ def send_ace_message(goal, session_id): with emulate_http_request(site, user): try: - ace.send(msg) + start_time = time.perf_counter() + if is_ses_enabled: + # experimental implementation to log errors with ses + send_email_using_ses(user, msg) + else: + ace.send(msg) + end_time = time.perf_counter() + log.info(f"Goal Reminder for {user.id} for course {goal.course_key} sent in {end_time - start_time} " + f"using {'SES' if is_ses_enabled else 'others'}") except Exception as exc: # pylint: disable=broad-except log.error(f"Goal Reminder for {user.id} for course {goal.course_key} could not send: {exc}") tracker.emit( @@ -211,8 +232,11 @@ class Command(BaseCommand): 'goal_count': total_goals, } ) - log.info(f'Processing course goals, total goal count {total_goals},' - + f'timestamp: {datetime.now()}, uuid: {session_id}') + log.info('Processing course goals, total goal count {}, timestamp: {}, uuid: {}'.format( + total_goals, + datetime.now(), + session_id + )) for goal in course_goals: # emulate a request for waffle's benefit with emulate_http_request(site=Site.objects.get_current(), user=goal.user): @@ -221,8 +245,13 @@ class Command(BaseCommand): else: filtered_count += 1 if (sent_count + filtered_count) % 10000 == 0: - log.info(f'Processing course goals: sent {sent_count} filtered {filtered_count} out of {total_goals},' - + f'timestamp: {datetime.now()}, uuid: {session_id}') + log.info('Processing course goals: sent {} filtered {} out of {}, timestamp: {}, uuid: {}'.format( + sent_count, + filtered_count, + total_goals, + datetime.now(), + session_id + )) tracker.emit( 'edx.course.goal.email.session_completed', @@ -234,8 +263,10 @@ class Command(BaseCommand): 'emails_filtered': filtered_count, } ) - log.info(f'Processing course goals complete: sent {sent_count} emails, filtered out {filtered_count} emails' - + f'timestamp: {datetime.now()}, uuid: {session_id}') + log.info('Processing course goals complete: sent {} emails, ' + 'filtered out {} emails, timestamp: {}, ' + 'uuid: {}'.format(sent_count, filtered_count, datetime.now(), session_id) + ) @staticmethod def handle_goal(goal, today, sunday_date, monday_date, session_id): @@ -279,7 +310,7 @@ class Command(BaseCommand): 'uuid': session_id, 'timestamp': datetime.now(), 'reason': 'User time zone', - 'user_timezone': user_timezone, + 'user_timezone': str(user_timezone), 'now_in_users_timezone': now_in_users_timezone, } ) @@ -292,3 +323,48 @@ class Command(BaseCommand): return True return False + + +def send_email_using_ses(user, msg): + """ + Send email using AWS SES + """ + render_msg = presentation.render(DjangoEmailChannel, msg) + # send rendered email using SES + + sender = EmailChannelMixin.get_from_address(msg) + + subject = EmailChannelMixin.get_subject(render_msg) + body_text = render_msg.body + body_html = render_msg.body_html + + try: + # Send email + response = boto3.client('ses', settings.AWS_SES_REGION_NAME).send_email( + Source=sender, + Destination={ + 'ToAddresses': [user.email], + }, + Message={ + 'Subject': { + 'Data': subject, + 'Charset': 'UTF-8' + }, + 'Body': { + 'Text': { + 'Data': body_text, + 'Charset': 'UTF-8' + }, + 'Html': { + 'Data': body_html, + 'Charset': 'UTF-8' + } + } + } + ) + + log.info(f"Goal Reminder Email: email sent using SES with message ID {response['MessageId']}") + send_ace_message_sent_signal(DjangoEmailChannel, msg) + except Exception as e: # pylint: disable=broad-exception-caught + log.error(f"Goal Reminder Email: Error sending email using SES: {e}") + raise e diff --git a/lms/djangoapps/course_goals/management/commands/tests/test_goal_reminder_email.py b/lms/djangoapps/course_goals/management/commands/tests/test_goal_reminder_email.py index 5b98b202d4..46d46832a6 100644 --- a/lms/djangoapps/course_goals/management/commands/tests/test_goal_reminder_email.py +++ b/lms/djangoapps/course_goals/management/commands/tests/test_goal_reminder_email.py @@ -1,6 +1,10 @@ """Tests for the goal_reminder_email command""" - +import uuid from datetime import datetime + +from botocore.exceptions import NoCredentialsError +from django.contrib.sites.models import Site +from edx_ace import Recipient, Message from pytz import UTC from unittest import mock # lint-amnesty, pylint: disable=wrong-import-order @@ -13,14 +17,17 @@ from freezegun import freeze_time from waffle import get_waffle_flag_model # pylint: disable=invalid-django-waffle-import from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.tests.factories import CourseEnrollmentFactory +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from lms.djangoapps.course_goals.management.commands.goal_reminder_email import send_email_using_ses, send_ace_message from lms.djangoapps.course_goals.models import CourseGoalReminderStatus from lms.djangoapps.course_goals.tests.factories import ( CourseGoalFactory, CourseGoalReminderStatusFactory, UserActivityFactory, ) from lms.djangoapps.certificates.data import CertificateStatuses from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory +from openedx.core.djangoapps.ace_common.template_context import get_base_template_context from openedx.core.djangolib.testing.utils import skip_unless_lms +from openedx.core.lib.celery.task_utils import emulate_http_request from openedx.features.course_experience import ENABLE_COURSE_GOALS, ENABLE_SES_FOR_GOALREMINDER # Some constants just for clarity of tests (assuming week starts on a Monday, as March 2021 used below does) @@ -43,6 +50,7 @@ class TestGoalReminderEmailCommand(TestCase): A lot of these methods will hardcode references to March 2021. This is just a convenient anchor point for us because it started on a Monday. Calls to the management command will freeze time so it's during March. """ + def make_valid_goal(self, **kwargs): """Creates a goal that will cause an email to be sent as the goal is valid but has been missed""" kwargs.setdefault('days_per_week', 6) @@ -182,8 +190,8 @@ class TestGoalReminderEmailCommand(TestCase): self.make_valid_goal(overview__end=end) self.call_command(expect_sent=False) - @mock.patch('lms.djangoapps.course_goals.management.commands.goal_reminder_email.ace.send') - def test_params_with_ses(self, mock_ace): + @mock.patch('lms.djangoapps.course_goals.management.commands.goal_reminder_email.send_email_using_ses') + def test_params_with_ses(self, mock_send_email_using_ses): """Test that the parameters of the msg passed to ace.send() are set correctly when SES is enabled""" with override_waffle_flag(ENABLE_SES_FOR_GOALREMINDER, active=None): goal = self.make_valid_goal() @@ -193,8 +201,8 @@ class TestGoalReminderEmailCommand(TestCase): with freeze_time('2021-03-02 10:00:00'): call_command('goal_reminder_email') - assert mock_ace.call_count == 1 - msg = mock_ace.call_args[0][0] + assert mock_send_email_using_ses.call_count == 1 + msg = mock_send_email_using_ses.call_args[0][1] assert msg.options['override_default_channel'] == 'django_email' assert msg.options['from_address'] == settings.LMS_COMM_DEFAULT_FROM_EMAIL @@ -211,3 +219,63 @@ class TestGoalReminderEmailCommand(TestCase): assert msg.options['transactional'] is True assert 'override_default_channel' not in msg.options assert 'from_address' not in msg.options + + @ddt.data(True, False) + @mock.patch('lms.djangoapps.course_goals.management.commands.goal_reminder_email.ace.send') + def test_goal_reminder_email_sent_to_disable_user(self, value, mock_ace): + """ + Test that the goal reminder email is not sent to disabled users. + """ + goal = self.make_valid_goal() + if value: + goal.user.set_password("12345678") + else: + goal.user.set_unusable_password() + goal.user.save() + send_ace_message(goal, str(uuid.uuid4())) + assert mock_ace.called is value + + +class TestGoalReminderEmailSES(TestCase): + """ + Tests for the send_email_using_ses function + """ + def test_send_email_using_ses(self): + """ + Test that the send_email_using_ses function sends an email using the SES channel + """ + user = UserFactory() + + options = { + 'transactional': True, + 'from_address': settings.LMS_COMM_DEFAULT_FROM_EMAIL, + 'override_default_channel': 'django_email', + } + site = Site.objects.get_current() + message_context = get_base_template_context(site) + message_context.update({ + 'email': user.email, + 'platform_name': 'edx', + 'course_name': 'zombie survival', + 'course_id': 'course.101', + 'days_per_week': 3, + 'course_url': 'test.com', + 'goals_unsubscribe_url': 'test.com', + 'image_url': 'test', + 'unsubscribe_url': None, + 'omit_unsubscribe_link': True, + 'courses_url': 'course.example.com', + 'programs_url': 'course.example.com', + }) + with emulate_http_request(site, user): + msg = Message( + name="goalreminder", + app_label="course_goals", + recipient=Recipient(user.id, user.email), + language='en', + context=message_context, + options=options, + ) + # expect an exception here + with self.assertRaises(NoCredentialsError): + send_email_using_ses(user, msg) diff --git a/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/base_body.html b/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/base_body.html new file mode 100644 index 0000000000..4253f59200 --- /dev/null +++ b/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/base_body.html @@ -0,0 +1,90 @@ +{% load django_markup %} +{% load i18n %} + +{% load ace %} + +{% load acetags %} + +{% get_current_language as LANGUAGE_CODE %} +{% get_current_language_bidi as LANGUAGE_BIDI %} + +{# This is preview text that is visible in the inbox view of many email clients but not visible in the actual #} +{# email itself. #} + +
    +{% block preview_text %}{% endblock %} +
    + +{% for image_src in channel.tracker_image_sources %} + +{% endfor %} + +{% google_analytics_tracking_pixel %} + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + +
    \ No newline at end of file diff --git a/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/body.html b/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/body.html index 902ef907a8..3a491dbfed 100644 --- a/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/body.html +++ b/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/body.html @@ -1,4 +1,5 @@ -{% extends 'ace_common/edx_ace/common/base_body.html' %} +{% extends 'course_goals/edx_ace/goalreminder/email/base_body.html' %} + {% load i18n %} {% load django_markup %} {% load static %} @@ -21,117 +22,152 @@ {# email client support for style sheets is pretty spotty, so we have to inline all of these styles #} {# we're using important below to override inline styles and my understanding is for email clients where media queries do not work, they'll simply see the desktop css on their phone #} - - - - - -
    - {% include "goal_reminder_banner.html" %} -
     
    - -

    - {% filter force_escape %}{% blocktrans %} - There’s still time to reach your goal - {% endblocktrans %}{% endfilter %} -

    - - - -

    - {% filter force_escape %} - {% blocktrans count count=days_per_week asvar goal_text %} - You set a goal of learning {start_bold}{{days_per_week}} time a week in {{course_name}}{end_bold}. You’re not quite there, but there's still time to reach that goal! - {% plural %} - You set a goal of learning {start_bold}{{days_per_week}} times a week in {{course_name}}{end_bold}. You're not quite there, but there's still time to reach that goal! - {% endblocktrans %} - {% endfilter %} - {% interpolate_html goal_text start_bold=''|safe end_bold=''|safe %} -

    - - - - -

    - {% filter force_escape %}{% blocktrans %} - Jump back in - {% endblocktrans %}{% endfilter %} +

    + + + + + + + + + + + + + + + + + -
    + {% trans 'There’s still time to reach your goal' as tmsg %}{{ tmsg | force_escape }} +
    +

    + {% filter force_escape %} + {% blocktrans with user_name=user_name %} + You’re almost there, {{ user_name }}! + {% endblocktrans %} + {% endfilter %} +

    +

    + {% filter force_escape %} + {% autoescape off %} + {% blocktrans count count=days_per_week asvar goal_text %} + You set a goal of learning {start_bold}{{days_per_week}} time a week in {{course_name}}{end_bold}. Now it’s up to you to make those goals a reality. + {% plural %} + You set a goal of learning {start_bold}{{days_per_week}} times a week in {{course_name}}{end_bold}. Now it’s up to you to make those goals a reality. + {% endblocktrans %} + {% endautoescape %} + {% endfilter %} + {% interpolate_html goal_text start_bold=''|safe end_bold=''|safe %} +

    +
    + + {% filter force_escape %}{% blocktrans %} + Jump back in + {% endblocktrans %}{% endfilter %} + +
    +   +
    + + + + + + - - -

    -
    - {% filter force_escape %}{% blocktrans %} - Remember, you can always change your learning goal. The best goal is one that you can stick to. - {% endblocktrans %}{% endfilter %} -

    - - - - -
    - {% filter force_escape %}{% blocktrans %} - Adjust my goal - {% endblocktrans %}{% endfilter %} -
    -
    - -
    + +
    + + +
    +

    + {% trans "Remember: the best goal is one that you can stick to. " as tmsg %}{{ tmsg | force_escape }} + {% trans "You can always" as tmsg %}{{ tmsg | force_escape }} + change your learning goal + {% trans "if you need to." as tmsg %}{{ tmsg | force_escape }}

    - -
    + Message Icon +
    +
    + {% filter force_escape %}{% blocktrans %} Unsubscribe from goal reminder emails for this course {% endblocktrans %}{% endfilter %} - -
    - - + + + + -{% endblock %} \ No newline at end of file +{% endblock %} + +{% block footer%} +{%include 'course_goals/edx_ace/goalreminder/email/footer.html'%} +{% endblock%} diff --git a/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/body.txt b/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/body.txt index e5cec8a244..80bde4ea80 100644 --- a/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/body.txt +++ b/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/body.txt @@ -1,11 +1,13 @@ {% load i18n %} {% trans "You're almost there!" %} {% trans "There's still time to reach your goal" as tmsg %} +{% autoescape off %} {% blocktrans %}You set a goal of learning {{days_per_week}} times a week in {{course_name}}. You're not quite there, but there's still time to reach that goal!{% endblocktrans %} +{% endautoescape %} {% trans "Jump back in"} {{course_url}} {% blocktrans %}Remember, you can always change your learning goal. The best goal is one that you can stick to. {% endblocktrans %} {% trans "Adjust my goal"} {{course_url}} {% trans "Unsubscribe from goal reminder emails to this course"} -{{course_url}} \ No newline at end of file +{{course_url}} diff --git a/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/footer.html b/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/footer.html new file mode 100644 index 0000000000..82e493276d --- /dev/null +++ b/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/footer.html @@ -0,0 +1,195 @@ +{% load django_markup %} +{% load i18n %} +{% load ace %} +{% load acetags %} +{% load static %} + + + + {% if confirm_activation_link %} + {% endif %} + + + +
    + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + +
    + + + + + + +
    + + + + + + +
    + + + + + + + +
    + + {% filter force_escape %}{% blocktrans %}Go to {{ platform_name }} Home Page{% endblocktrans %}{% endfilter %} +
    + + + + + + +
    + + + + {% if social_media_urls.facebook %} + + {% endif %} + {% if social_media_urls.instagram %} + + {% endif %} + {% if social_media_urls.linkedin %} + + {% endif %} + {% if social_media_urls.twitter %} + + {% endif %} + {% if social_media_urls.reddit %} + + {% endif %} + + +
    + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on Facebook{% endblocktrans %}{% endfilter %} + + + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on Facebook{% endblocktrans %}{% endfilter %} + + + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on LinkedIn{% endblocktrans %}{% endfilter %} + + + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on Twitter{% endblocktrans %}{% endfilter %} + + + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on Reddit{% endblocktrans %}{% endfilter %} + +
    +
    +
    +
    +
    + + + + + + +
    + + + + + + +
    + + + + {% if mobile_store_urls.apple %} + + {% endif %} + + {% if mobile_store_urls.google %} + + {% endif %} + + +
    + + {% trans + + + + {% trans + +
    +
    +
    +
    + {% if disclaimer %} + {{ disclaimer }}
    + {% endif %} + {% trans "edX is the trusted platform for education and learning" as tmsg %}{{ tmsg | force_escape }}.
    +
    + © {% now "Y" %} {{ platform_name }} LLC. {% trans "All rights reserved" as tmsg %}{{ tmsg | force_escape }}.
    +
    + {% if unsubscribe_link %} + + {%if unsubscribe_text%} {{unsubscribe_text}} {%else%} {% trans "Unsubscribe from these emails." as tmsg %}{{ tmsg | force_escape }} {%endif%} +
    +
    + {% endif %} + {{ contact_mailing_address }} +
    +
    +
    diff --git a/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/head.html b/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/head.html index 23910941b6..565956d137 100644 --- a/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/head.html +++ b/lms/djangoapps/course_goals/templates/course_goals/edx_ace/goalreminder/email/head.html @@ -1,15 +1,40 @@ -{% extends 'ace_common/edx_ace/common/base_head.html' %} -{% block additional_styles %} - -{% endblock %} \ No newline at end of file +{% load django_markup %} +{% load i18n %} +{% load ace %} +{% load acetags %} +{% load static %} + + + + + +
    + + + + +
    + + + + + + +
     
    + + + + +
    + + + + +
    + + {% filter force_escape %}{% blocktrans %}Go to {{ platform_name }} Home Page{% endblocktrans %}{% endfilter %} +
    +
    +
     
    +
    +
    diff --git a/lms/djangoapps/course_home_api/course_metadata/serializers.py b/lms/djangoapps/course_home_api/course_metadata/serializers.py index 29b92fc7b0..769a006052 100644 --- a/lms/djangoapps/course_home_api/course_metadata/serializers.py +++ b/lms/djangoapps/course_home_api/course_metadata/serializers.py @@ -59,3 +59,4 @@ class CourseHomeMetadataSerializer(VerifiedModeSerializer): can_view_certificate = serializers.BooleanField() course_modes = CourseModeSerrializer(many=True) is_new_discussion_sidebar_view_enabled = serializers.BooleanField() + has_course_author_access = serializers.BooleanField() diff --git a/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py b/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py index 23052e5e7d..43a3974f11 100644 --- a/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py +++ b/lms/djangoapps/course_home_api/course_metadata/tests/test_views.py @@ -11,7 +11,12 @@ from edx_toggles.toggles.testutils import override_waffle_flag from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.roles import CourseInstructorRole +from common.djangoapps.student.roles import ( + CourseBetaTesterRole, + CourseInstructorRole, + CourseLimitedStaffRole, + CourseStaffRole +) from common.djangoapps.student.tests.factories import UserFactory from lms.djangoapps.course_home_api.tests.utils import BaseCourseHomeTests from lms.djangoapps.courseware.toggles import ( @@ -247,3 +252,32 @@ class CourseHomeMetadataTests(BaseCourseHomeTests): assert 'discussion' in tab_ids else: assert 'discussion' not in tab_ids + + @ddt.data( + { + 'course_team_role': None, + 'has_course_author_access': False + }, + { + 'course_team_role': CourseBetaTesterRole, + 'has_course_author_access': False + }, + { + 'course_team_role': CourseStaffRole, + 'has_course_author_access': True + }, + { + 'course_team_role': CourseLimitedStaffRole, + 'has_course_author_access': False + }, + ) + @ddt.unpack + def test_has_course_author_access_for_staff_roles(self, course_team_role, has_course_author_access): + CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED) + + if course_team_role: + course_team_role(self.course.id).add_users(self.user) + + response = self.client.get(self.url) + assert response.status_code == 200 + assert response.data['has_course_author_access'] == has_course_author_access diff --git a/lms/djangoapps/course_home_api/course_metadata/views.py b/lms/djangoapps/course_home_api/course_metadata/views.py index 02c30ff62e..ae854b8887 100644 --- a/lms/djangoapps/course_home_api/course_metadata/views.py +++ b/lms/djangoapps/course_home_api/course_metadata/views.py @@ -16,6 +16,7 @@ from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiv from openedx.core.djangoapps.courseware_api.utils import get_celebrations_dict from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.auth import has_course_author_access from common.djangoapps.student.models import CourseEnrollment from lms.djangoapps.course_api.api import course_detail from lms.djangoapps.course_goals.models import UserActivity @@ -140,6 +141,12 @@ class CourseHomeMetadataView(RetrieveAPIView): 'can_view_certificate': certificates_viewable_for_course(course), 'course_modes': course_modes, 'is_new_discussion_sidebar_view_enabled': new_discussion_sidebar_view_is_enabled(course_key), + # We check the course author access in the context of CMS here because this field is used + # to determine whether the user can access the course authoring tools in the CMS. + # This is a temporary solution until the course author role is split into "Course Author" and + # "Course Editor" as described in the permission matrix here: + # https://github.com/openedx/platform-roadmap/issues/246 + 'has_course_author_access': has_course_author_access(request.user, course_key, 'cms'), } context = self.get_serializer_context() context['course'] = course diff --git a/lms/djangoapps/course_home_api/dates/views.py b/lms/djangoapps/course_home_api/dates/views.py index 6c95f82349..0467af8a69 100644 --- a/lms/djangoapps/course_home_api/dates/views.py +++ b/lms/djangoapps/course_home_api/dates/views.py @@ -75,13 +75,19 @@ class DatesTabView(RetrieveAPIView): def get(self, request, *args, **kwargs): course_key_string = kwargs.get('course_key_string') course_key = CourseKey.from_string(course_key_string) + allow_not_started_courses = request.GET.get('allow_not_started_courses', False) # Enable NR tracing for this view based on course monitoring_utils.set_custom_attribute('course_id', course_key_string) monitoring_utils.set_custom_attribute('user_id', request.user.id) monitoring_utils.set_custom_attribute('is_staff', request.user.is_staff) - - course = get_course_or_403(request.user, 'load', course_key, check_if_enrolled=False) + course = get_course_or_403( + request.user, + 'load', + course_key, + check_if_enrolled=False, + allow_not_started_courses=allow_not_started_courses + ) is_staff = bool(has_access(request.user, 'staff', course_key)) _, request.user = setup_masquerade( diff --git a/lms/djangoapps/course_home_api/outline/tests/test_view.py b/lms/djangoapps/course_home_api/outline/tests/test_view.py index 76928846f0..07e7a36a8a 100644 --- a/lms/djangoapps/course_home_api/outline/tests/test_view.py +++ b/lms/djangoapps/course_home_api/outline/tests/test_view.py @@ -3,17 +3,16 @@ Tests for Outline Tab API in the Course Home API """ import itertools +import json from datetime import datetime, timedelta, timezone -from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory -from unittest.mock import Mock, patch # lint-amnesty, pylint: disable=wrong-import-order +from unittest.mock import Mock, patch -import ddt # lint-amnesty, pylint: disable=wrong-import-order -import json # lint-amnesty, pylint: disable=wrong-import-order +import ddt from completion.models import BlockCompletion -from django.conf import settings # lint-amnesty, pylint: disable=wrong-import-order +from django.conf import settings from django.test import override_settings -from django.urls import reverse # lint-amnesty, pylint: disable=wrong-import-order -from edx_toggles.toggles.testutils import override_waffle_flag # lint-amnesty, pylint: disable=wrong-import-order +from django.urls import reverse +from edx_toggles.toggles.testutils import override_waffle_flag from cms.djangoapps.contentstore.outlines import update_outline_from_modulestore from common.djangoapps.course_modes.models import CourseMode @@ -21,7 +20,9 @@ from common.djangoapps.course_modes.tests.factories import CourseModeFactory from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.roles import CourseInstructorRole from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.course_home_api.toggles import COURSE_HOME_SEND_COURSE_PROGRESS_ANALYTICS_FOR_STUDENT from lms.djangoapps.course_home_api.tests.utils import BaseCourseHomeTests +from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.learning_sequences.api import replace_course_outline from openedx.core.djangoapps.content.learning_sequences.data import CourseOutlineData, CourseVisibility @@ -31,15 +32,17 @@ from openedx.core.djangoapps.user_api.tests.factories import UserCourseTagFactor from openedx.features.course_duration_limits.models import CourseDurationLimitConfig from openedx.features.course_experience import ( COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, - DISPLAY_COURSE_SOCK_FLAG, ENABLE_COURSE_GOALS ) -from openedx.features.discounts.applicability import ( - DISCOUNT_APPLICABILITY_FLAG, - FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG +from openedx.features.discounts.applicability import DISCOUNT_APPLICABILITY_FLAG, FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG +from xmodule.course_block import ( + COURSE_VISIBILITY_PUBLIC, + COURSE_VISIBILITY_PUBLIC_OUTLINE +) +from xmodule.modulestore.tests.factories import ( + BlockFactory, + CourseFactory ) -from xmodule.course_block import COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order @ddt.ddt @@ -362,12 +365,6 @@ class OutlineTabTestViews(BaseCourseHomeTests): assert (data['access_expiration'] is not None) == show_enrolled assert (data['resume_course']['url'] is not None) == show_enrolled - @ddt.data(True, False) - def test_can_show_upgrade_sock(self, sock_enabled): - with override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=sock_enabled): - response = self.client.get(self.url) - assert response.data['can_show_upgrade_sock'] == sock_enabled - def test_verified_mode(self): enrollment = CourseEnrollment.enroll(self.user, self.course.id) CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) @@ -468,6 +465,25 @@ class OutlineTabTestViews(BaseCourseHomeTests): CourseEnrollment.enroll(UserFactory(), self.course.id) # grr, some rando took our spot! self.assert_can_enroll(False) + @override_waffle_flag(COURSE_HOME_SEND_COURSE_PROGRESS_ANALYTICS_FOR_STUDENT, active=True) + @patch("lms.djangoapps.course_home_api.outline.views.collect_progress_for_user_in_course.delay") + def test_course_progress_analytics_enabled(self, mock_task): + """ + Ensures that the `calculate_course_progress_for_user_in_course` task is enqueued, with the correct args, only + if the feature is enabled. + """ + self.client.get(self.url) + mock_task.assert_called_once_with(str(self.course.id), self.user.id) + + @override_waffle_flag(COURSE_HOME_SEND_COURSE_PROGRESS_ANALYTICS_FOR_STUDENT, active=False) + @patch("lms.djangoapps.course_home_api.outline.views.collect_progress_for_user_in_course.delay") + def test_course_progress_analytics_disabled(self, mock_task): + """ + Ensures that the `calculate_course_progress_for_user_in_course` task is not run if the feature is disabled. + """ + self.client.get(self.url) + mock_task.assert_not_called() + @ddt.ddt class SidebarBlocksTestViews(BaseCourseHomeTests): diff --git a/lms/djangoapps/course_home_api/outline/views.py b/lms/djangoapps/course_home_api/outline/views.py index e7ebb2eef2..c1b2a9779b 100644 --- a/lms/djangoapps/course_home_api/outline/views.py +++ b/lms/djangoapps/course_home_api/outline/views.py @@ -35,6 +35,8 @@ from lms.djangoapps.course_home_api.outline.serializers import ( OutlineTabSerializer, ) from lms.djangoapps.course_home_api.utils import get_course_or_403 +from lms.djangoapps.course_home_api.tasks import collect_progress_for_user_in_course +from lms.djangoapps.course_home_api.toggles import send_course_progress_analytics_for_student_is_enabled from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs from lms.djangoapps.courseware.courses import get_course_date_blocks, get_course_info_section @@ -366,6 +368,9 @@ class OutlineTabView(RetrieveAPIView): context['enrollment'] = enrollment serializer = self.get_serializer_class()(data, context=context) + if send_course_progress_analytics_for_student_is_enabled(course_key) and not user_is_masquerading: + collect_progress_for_user_in_course.delay(course_key_string, request.user.id) + return Response(serializer.data) def finalize_response(self, request, response, *args, **kwargs): @@ -501,7 +506,7 @@ class CourseNavigationBlocksView(RetrieveAPIView): for section_data in course_sections: section_data['children'] = self.get_accessible_sequences( user_course_outline, - section_data.get('children', ['completion']) + section_data.get('children', []) ) accessible_sequence_ids = {str(usage_key) for usage_key in user_course_outline.accessible_sequences} for sequence_data in section_data['children']: diff --git a/lms/djangoapps/course_home_api/progress/api.py b/lms/djangoapps/course_home_api/progress/api.py new file mode 100644 index 0000000000..b2a8634c59 --- /dev/null +++ b/lms/djangoapps/course_home_api/progress/api.py @@ -0,0 +1,45 @@ +""" +Python APIs exposed for the progress tracking functionality of the course home API. +""" + +from django.contrib.auth import get_user_model +from opaque_keys.edx.keys import CourseKey + +from lms.djangoapps.courseware.courses import get_course_blocks_completion_summary + +User = get_user_model() + + +def calculate_progress_for_learner_in_course(course_key: CourseKey, user: User) -> dict: + """ + Calculate a given learner's progress in the specified course run. + """ + summary = get_course_blocks_completion_summary(course_key, user) + if not summary: + return {} + + complete_count = summary.get("complete_count", 0) + locked_count = summary.get("locked_count", 0) + incomplete_count = summary.get("incomplete_count", 0) + + # This completion calculation mirrors the logic used in the CompletionDonutChart component on the Learning MFE's + # Progress tab. It's duplicated here to enable backend reporting on learner progress. Ideally, this logic should be + # refactored in the future so that the calculation is handled solely on the backend, eliminating the need for it to + # be done in the frontend. + num_total_units = complete_count + incomplete_count + locked_count + if num_total_units == 0: + complete_percentage = locked_percentage = incomplete_percentage = 0.0 + else: + complete_percentage = round(complete_count / num_total_units, 2) + locked_percentage = round(locked_count / num_total_units, 2) + incomplete_percentage = 1.00 - complete_percentage - locked_percentage + + return { + "complete_count": complete_count, + "locked_count": locked_count, + "incomplete_count": incomplete_count, + "total_count": num_total_units, + "complete_percentage": complete_percentage, + "locked_percentage": locked_percentage, + "incomplete_percentage": incomplete_percentage + } diff --git a/lms/djangoapps/course_home_api/progress/tests/test_api.py b/lms/djangoapps/course_home_api/progress/tests/test_api.py new file mode 100644 index 0000000000..30d8d9059e --- /dev/null +++ b/lms/djangoapps/course_home_api/progress/tests/test_api.py @@ -0,0 +1,75 @@ +""" +Tests for the Python APIs exposed by the Progress API of the Course Home API app. +""" + +from unittest.mock import patch + +from django.test import TestCase + +from lms.djangoapps.course_home_api.progress.api import calculate_progress_for_learner_in_course + + +class ProgressApiTests(TestCase): + """ + Tests for the progress calculation functions. + """ + + @patch("lms.djangoapps.course_home_api.progress.api.get_course_blocks_completion_summary") + def test_calculate_progress_for_learner_in_course(self, mock_get_summary): + """ + A test to verify functionality of the function under test. + """ + mock_get_summary.return_value = { + "complete_count": 5, + "incomplete_count": 2, + "locked_count": 1, + } + + expected_data = { + "complete_count": 5, + "incomplete_count": 2, + "locked_count": 1, + "total_count": 8, + "complete_percentage": 0.62, + "locked_percentage": 0.12, + "incomplete_percentage": 0.26, + } + + results = calculate_progress_for_learner_in_course("some_course", "some_user") + assert mock_get_summary.called_once_with("some_course", "some_user") + assert results == expected_data + + @patch("lms.djangoapps.course_home_api.progress.api.get_course_blocks_completion_summary") + def test_handle_division_by_zero(self, mock_get_summary): + """ + A test to verify that we're avoiding division-by-zero errors if the total number of units is 0. + """ + mock_get_summary.return_value = { + "complete_count": 0, + "incomplete_count": 0, + "locked_count": 0, + } + + expected_data = { + "complete_count": 0, + "incomplete_count": 0, + "locked_count": 0, + "total_count": 0, + "complete_percentage": 0.0, + "locked_percentage": 0.0, + "incomplete_percentage": 0.0, + } + + results = calculate_progress_for_learner_in_course("some_course", "some_user") + assert mock_get_summary.called_once_with("some_course", "some_user") + assert results == expected_data + + @patch("lms.djangoapps.course_home_api.progress.api.get_course_blocks_completion_summary") + def test_calculate_progress_for_learner_in_course_summary_empty(self, mock_get_summary): + """ + A test to verify functionality of the function under test if a block summary is not received. + """ + mock_get_summary.return_value = {} + + results = calculate_progress_for_learner_in_course("some_course", "some_user") + assert not results diff --git a/lms/djangoapps/course_home_api/serializers.py b/lms/djangoapps/course_home_api/serializers.py index d3aa561733..44023c9d50 100644 --- a/lms/djangoapps/course_home_api/serializers.py +++ b/lms/djangoapps/course_home_api/serializers.py @@ -9,7 +9,6 @@ from rest_framework import serializers from lms.djangoapps.courseware.utils import verified_upgrade_deadline_link from openedx.core.djangoapps.courseware_api.utils import serialize_upgrade_info from openedx.features.content_type_gating.models import ContentTypeGatingConfig -from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG from openedx.features.course_experience.utils import dates_banner_should_display @@ -59,13 +58,8 @@ class VerifiedModeSerializer(ReadOnlySerializer): Requires 'course_overview', 'enrollment', and 'request' from self.context. """ - can_show_upgrade_sock = serializers.SerializerMethodField() verified_mode = serializers.SerializerMethodField() - def get_can_show_upgrade_sock(self, _): - course_overview = self.context['course_overview'] - return DISPLAY_COURSE_SOCK_FLAG.is_enabled(course_overview.id) - def get_verified_mode(self, _): """Return verified mode information, or None.""" course_overview = self.context['course_overview'] diff --git a/lms/djangoapps/course_home_api/tasks.py b/lms/djangoapps/course_home_api/tasks.py new file mode 100644 index 0000000000..39bc2bbb3c --- /dev/null +++ b/lms/djangoapps/course_home_api/tasks.py @@ -0,0 +1,62 @@ +""" +Celery tasks used by the `course_home_api` app. +""" +import logging + +from celery import shared_task +from django.contrib.auth import get_user_model +from edx_django_utils.monitoring import set_code_owner_attribute +from eventtracking import tracker +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey + +from common.djangoapps.student.models_api import get_course_enrollment +from lms.djangoapps.course_home_api.progress.api import calculate_progress_for_learner_in_course + +User = get_user_model() +COURSE_COMPLETION_FOR_USER_EVENT_NAME = "edx.bi.user.course-progress" + +log = logging.getLogger(__name__) + + +@shared_task +@set_code_owner_attribute +def collect_progress_for_user_in_course(course_id: str, user_id: str) -> None: + """ + Celery task that retrieves a learner's progress in a given course. + """ + try: + course_key = CourseKey.from_string(course_id) + except InvalidKeyError: + log.warning(f"Invalid course id {course_id}, aborting task.") + return + + try: + user = User.objects.get(id=user_id) + except User.DoesNotExist: + log.warning(f"Could not retrieve a user with id {user_id}, aborting task.") + return + + try: + enrollment = get_course_enrollment(user, course_key) + enrollment_mode = enrollment.mode + except AttributeError: + log.warning(f"Could not retrieve enrollment info for user {user.id} in course {course_id}") + return + + progress = calculate_progress_for_learner_in_course(course_key, user) + + # add a few extra fields to the returned data to make the event payload a bit more usable + progress["user_id"] = user.id + progress["course_id"] = course_id + progress["enrollment_mode"] = enrollment_mode + + context = { + "course_id": course_id, + "user_id": user.id, + } + with tracker.get_tracker().context(COURSE_COMPLETION_FOR_USER_EVENT_NAME, context): + tracker.emit( + COURSE_COMPLETION_FOR_USER_EVENT_NAME, + progress + ) diff --git a/lms/djangoapps/course_home_api/tests/test_tasks.py b/lms/djangoapps/course_home_api/tests/test_tasks.py new file mode 100644 index 0000000000..3858782252 --- /dev/null +++ b/lms/djangoapps/course_home_api/tests/test_tasks.py @@ -0,0 +1,128 @@ +""" +Tests for Celery tasks used by the `course_home_api` app. +""" + +from unittest.mock import patch, MagicMock + +from opaque_keys.edx.keys import CourseKey +from testfixtures import LogCapture +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from lms.djangoapps.course_home_api.tasks import ( + COURSE_COMPLETION_FOR_USER_EVENT_NAME, + collect_progress_for_user_in_course +) +from openedx.core.djangoapps.catalog.tests.factories import CourseFactory, CourseRunFactory + +LOG_PATH = 'lms.djangoapps.course_home_api.tasks' + + +class CalculateCompletionTaskTests(ModuleStoreTestCase): + """ + Tests for the `emit_course_completion_analytics_for_user` Celery task. + """ + def setUp(self): + super().setUp() + self.user = UserFactory() + self.course_run = CourseRunFactory() + self.course_run_key_string = self.course_run['key'] + self.course = CourseFactory(key=self.course_run_key_string, course_runs=[self.course_run]) + self.enrollment = CourseEnrollmentFactory( + user=self.user, + course_id=self.course_run_key_string, + mode="verified" + ) + + @patch("lms.djangoapps.course_home_api.tasks.calculate_progress_for_learner_in_course") + @patch("lms.djangoapps.course_home_api.tasks.tracker.emit") + @patch("lms.djangoapps.course_home_api.tasks.tracker.get_tracker") + def test_successful_event_emission(self, mock_get_tracker, mock_tracker, mock_progress): + """ + Test to ensure a tracker event is emit by the task with the expected completion information. + """ + mock_context_manager = MagicMock() + mock_context_manager.__enter__.return_value = None + mock_context_manager.__exit__.return_value = None + + mock_tracker_instance = MagicMock() + mock_tracker_instance.context.return_value = mock_context_manager + mock_get_tracker.return_value = mock_tracker_instance + + mock_progress.return_value = { + "complete_count": 5, + "incomplete_count": 2, + "locked_count": 1, + "total_count": 8, + "complete_percentage": 0.62, + "locked_percentage": 0.12, + "incomplete_percentage": 0.26, + } + + expected_data = { + "user_id": self.user.id, + "course_id": self.course_run_key_string, + "enrollment_mode": self.enrollment.mode, + "complete_count": 5, + "incomplete_count": 2, + "locked_count": 1, + "total_count": 8, + "complete_percentage": 0.62, + "locked_percentage": 0.12, + "incomplete_percentage": 0.26, + } + + collect_progress_for_user_in_course(self.course_run_key_string, self.user.id) + mock_progress.assert_called_once_with(CourseKey.from_string(self.course_run_key_string), self.user) + mock_tracker_instance.context.assert_called_once_with( + COURSE_COMPLETION_FOR_USER_EVENT_NAME, + { + "course_id": self.course_run_key_string, + "user_id": self.user.id, + }, + ) + mock_tracker.assert_called_once_with( + COURSE_COMPLETION_FOR_USER_EVENT_NAME, + expected_data, + ) + + @patch("lms.djangoapps.course_home_api.tasks.calculate_progress_for_learner_in_course") + @patch("lms.djangoapps.course_home_api.tasks.get_course_enrollment") + @patch("lms.djangoapps.course_home_api.tasks.tracker.emit") + def test_cannot_retrieve_enrollment_info(self, mock_tracker, mock_get_enrollment, mock_progress): + """ + Test to ensure the task is aborted if we cannot retrieve enrollment info for the user in the specified course. + """ + mock_get_enrollment.return_value = None + + expected_message = ( + f"Could not retrieve enrollment info for user {self.user.id} in course {self.course_run_key_string}" + ) + + with LogCapture() as log: + collect_progress_for_user_in_course(self.course_run_key_string, self.user.id) + + mock_get_enrollment.assert_called_once_with(self.user, CourseKey.from_string(self.course_run_key_string)) + log.check_present((LOG_PATH, "WARNING", expected_message),) + mock_progress.assert_not_called() + mock_tracker.assert_not_called() + + @patch("lms.djangoapps.course_home_api.tasks.calculate_progress_for_learner_in_course") + @patch("lms.djangoapps.course_home_api.tasks.tracker.emit") + def test_aborted_task_user_dne(self, mock_tracker, mock_progress): + """ + Test to ensure the task is aborted if we cannot find the user for some reason. + """ + collect_progress_for_user_in_course(self.course_run_key_string, 8675309) + mock_progress.assert_not_called() + mock_tracker.assert_not_called() + + @patch("lms.djangoapps.course_home_api.tasks.calculate_progress_for_learner_in_course") + @patch("lms.djangoapps.course_home_api.tasks.tracker.emit") + def test_aborted_task_bad_course_id(self, mock_tracker, mock_progress): + """ + Test to ensure the task is aborted if the course key provided is no good. + """ + collect_progress_for_user_in_course("nonsense", self.user.id) + mock_progress.assert_not_called() + mock_tracker.assert_not_called() diff --git a/lms/djangoapps/course_home_api/toggles.py b/lms/djangoapps/course_home_api/toggles.py index c143621630..052862796c 100644 --- a/lms/djangoapps/course_home_api/toggles.py +++ b/lms/djangoapps/course_home_api/toggles.py @@ -36,6 +36,21 @@ COURSE_HOME_NEW_DISCUSSION_SIDEBAR_VIEW = CourseWaffleFlag( ) +# Waffle flag to enable emission of course progress analytics for students in their courses. +# +# .. toggle_name: course_home.send_course_progress_analytics_for_student +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: This toggle controls whether the system will enqueue a Celery task responsible for emitting an +# analytics events describing how much course content a learner has completed in a course. +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2025-04-02 +# .. toggle_target_removal_date: None +COURSE_HOME_SEND_COURSE_PROGRESS_ANALYTICS_FOR_STUDENT = CourseWaffleFlag( + f'{WAFFLE_FLAG_NAMESPACE}.send_course_progress_analytics_for_student', __name__ +) + + def course_home_mfe_progress_tab_is_active(course_key): # Avoiding a circular dependency from .models import DisableProgressPageStackedConfig @@ -51,3 +66,10 @@ def new_discussion_sidebar_view_is_enabled(course_key): Returns True if the new discussion sidebar view is enabled for the given course. """ return COURSE_HOME_NEW_DISCUSSION_SIDEBAR_VIEW.is_enabled(course_key) + + +def send_course_progress_analytics_for_student_is_enabled(course_key): + """ + Returns True if the course completion analytics feature is enabled for a given course. + """ + return COURSE_HOME_SEND_COURSE_PROGRESS_ANALYTICS_FOR_STUDENT.is_enabled(course_key) diff --git a/lms/djangoapps/course_wiki/plugins/markdownedx/mdx_mathjax.py b/lms/djangoapps/course_wiki/plugins/markdownedx/mdx_mathjax.py index d2eb489248..9d2ea58abb 100644 --- a/lms/djangoapps/course_wiki/plugins/markdownedx/mdx_mathjax.py +++ b/lms/djangoapps/course_wiki/plugins/markdownedx/mdx_mathjax.py @@ -21,9 +21,9 @@ class MathJaxPattern(markdown.inlinepatterns.Pattern): # lint-amnesty, pylint: class MathJaxExtension(markdown.Extension): - def extendMarkdown(self, md, md_globals): # lint-amnesty, pylint: disable=arguments-differ, unused-argument + def extendMarkdown(self, md): # lint-amnesty, pylint: disable=arguments-differ, unused-argument # Needs to come before escape matching because \ is pretty important in LaTeX - md.inlinePatterns.add('mathjax', MathJaxPattern(), '\S+.flv)') self.add_inline(md, 'dailymotion', Dailymotion, diff --git a/lms/djangoapps/course_wiki/tests/tests.py b/lms/djangoapps/course_wiki/tests/tests.py index 7fc86947d4..7821f659d9 100644 --- a/lms/djangoapps/course_wiki/tests/tests.py +++ b/lms/djangoapps/course_wiki/tests/tests.py @@ -7,6 +7,7 @@ from unittest.mock import patch from django.urls import reverse from lms.djangoapps.courseware.tests.tests import LoginEnrollmentTestCase +from openedx.features.course_experience.url_helpers import make_learning_mfe_courseware_url from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired 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 @@ -121,7 +122,7 @@ class WikiRedirectTestCase(EnterpriseTestConsentRequired, LoginEnrollmentTestCas self.create_course_page(self.toy) course_wiki_page = reverse('wiki:get', kwargs={'path': self.toy.wiki_slug + '/'}) - referer = reverse("courseware", kwargs={'course_id': str(self.toy.id)}) + referer = make_learning_mfe_courseware_url(self.toy.id) resp = self.client.get(course_wiki_page, follow=True, HTTP_REFERER=referer) @@ -141,7 +142,7 @@ class WikiRedirectTestCase(EnterpriseTestConsentRequired, LoginEnrollmentTestCas self.login(self.student, self.password) course_wiki_page = reverse('wiki:get', kwargs={'path': self.toy.wiki_slug + '/'}) - referer = reverse("courseware", kwargs={'course_id': str(self.toy.id)}) + referer = make_learning_mfe_courseware_url(self.toy.id) # When not enrolled, we should get a 302 resp = self.client.get(course_wiki_page, follow=False, HTTP_REFERER=referer) @@ -195,7 +196,7 @@ class WikiRedirectTestCase(EnterpriseTestConsentRequired, LoginEnrollmentTestCas self.create_course_page(course) course_wiki_page = reverse('wiki:get', kwargs={'path': course.wiki_slug + '/'}) - referer = reverse("courseware", kwargs={'course_id': str(course.id)}) + referer = make_learning_mfe_courseware_url(self.toy.id) resp = self.client.get(course_wiki_page, follow=True, HTTP_REFERER=referer) assert resp.status_code == 200 diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 436cb3514a..a1a46137fc 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -34,7 +34,6 @@ from lms.djangoapps.courseware.access_utils import ( check_course_open_for_learner, check_start_date, debug, - in_preview_mode ) from lms.djangoapps.courseware.masquerade import get_masquerade_role, is_masquerading_as_student from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException @@ -158,11 +157,6 @@ def has_access(user, action, obj, course_key=None): if not user: user = AnonymousUser() - # Preview mode is only accessible by staff. - if in_preview_mode() and course_key: - if not has_staff_access_to_preview_mode(user, course_key): - return ACCESS_DENIED - # delegate the work to type-specific functions. # (start with more specific types, then get more general) if isinstance(obj, CourseBlock): diff --git a/lms/djangoapps/courseware/access_utils.py b/lms/djangoapps/courseware/access_utils.py index d53699e5e1..23ec992f81 100644 --- a/lms/djangoapps/courseware/access_utils.py +++ b/lms/djangoapps/courseware/access_utils.py @@ -3,7 +3,6 @@ Simple utility functions for computing access. It allows us to share code between access.py and block transformers. """ - from datetime import datetime, timedelta from logging import getLogger @@ -21,12 +20,11 @@ from lms.djangoapps.courseware.access_response import ( EnrollmentRequiredAccessError, IncorrectActiveEnterpriseAccessError, StartDateEnterpriseLearnerError, - StartDateError + StartDateError, ) from lms.djangoapps.courseware.masquerade import get_course_masquerade, is_masquerading_as_student from openedx.features.course_experience import COURSE_ENABLE_UNENROLLED_ACCESS_FLAG, COURSE_PRE_START_ACCESS_FLAG from xmodule.course_block import COURSE_VISIBILITY_PUBLIC # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.util.xmodule_django import get_current_request_hostname # lint-amnesty, pylint: disable=wrong-import-order DEBUG_ACCESS = False log = getLogger(__name__) @@ -58,9 +56,12 @@ def adjust_start_date(user, days_early_for_beta, start, course_key): if CourseBetaTesterRole(course_key).has_user(user): debug("Adjust start time: user in beta role for %s", course_key) - delta = timedelta(days_early_for_beta) - effective = start - delta - return effective + # timedelta.max days from now is in the year 2739931, so that's probably pretty safe + delta = timedelta(min(days_early_for_beta, timedelta.max.days)) + try: + return start - delta + except OverflowError: + return start return start @@ -93,7 +94,7 @@ def enterprise_learner_enrolled(request, user, course_key): # enterprise_customer_data is either None (if learner is not linked to any customer) or a serialized # EnterpriseCustomer representing the learner's active linked customer. enterprise_customer_data = enterprise_customer_from_session_or_learner_data(request) - learner_portal_enabled = enterprise_customer_data and enterprise_customer_data['enable_learner_portal'] + learner_portal_enabled = enterprise_customer_data and enterprise_customer_data["enable_learner_portal"] if not learner_portal_enabled: return False @@ -102,18 +103,18 @@ def enterprise_learner_enrolled(request, user, course_key): enterprise_enrollments = EnterpriseCourseEnrollment.objects.filter( course_id=course_key, enterprise_customer_user__user_id=user.id, - enterprise_customer_user__enterprise_customer__uuid=enterprise_customer_data['uuid'], + enterprise_customer_user__enterprise_customer__uuid=enterprise_customer_data["uuid"], ) enterprise_enrollment_exists = enterprise_enrollments.exists() log.info( ( - '[enterprise_learner_enrolled] Checking for an enterprise enrollment for ' - 'lms_user_id=%s in course_key=%s via enterprise_customer_uuid=%s. ' - 'Exists: %s' + "[enterprise_learner_enrolled] Checking for an enterprise enrollment for " + "lms_user_id=%s in course_key=%s via enterprise_customer_uuid=%s. " + "Exists: %s" ), user.id, course_key, - enterprise_customer_data['uuid'], + enterprise_customer_data["uuid"], enterprise_enrollment_exists, ) return enterprise_enrollment_exists @@ -130,13 +131,13 @@ def check_start_date(user, days_early_for_beta, start, course_key, display_error Returns: AccessResponse: Either ACCESS_GRANTED or StartDateError. """ - start_dates_disabled = settings.FEATURES['DISABLE_START_DATES'] + start_dates_disabled = settings.FEATURES["DISABLE_START_DATES"] masquerading_as_student = is_masquerading_as_student(user, course_key) if start_dates_disabled and not masquerading_as_student: return ACCESS_GRANTED else: - if start is None or in_preview_mode() or get_course_masquerade(user, course_key): + if start is None or get_course_masquerade(user, course_key): return ACCESS_GRANTED if now is None: @@ -156,15 +157,6 @@ def check_start_date(user, days_early_for_beta, start, course_key, display_error return StartDateError(start, display_error_to_user=display_error_to_user) -def in_preview_mode(): - """ - Returns whether the user is in preview mode or not. - """ - hostname = get_current_request_hostname() - preview_lms_base = settings.FEATURES.get('PREVIEW_LMS_BASE', None) - return bool(preview_lms_base and hostname and hostname.split(':')[0] == preview_lms_base.split(':')[0]) - - def check_course_open_for_learner(user, course): """ Check if the course is open for learners based on the start date. @@ -233,18 +225,19 @@ def check_public_access(course, visibilities): def check_data_sharing_consent(course_id): """ - Grants access if the user is do not need DataSharing consent, otherwise returns data sharing link. + Grants access if the user is do not need DataSharing consent, otherwise returns data sharing link. - Returns: - AccessResponse: Either ACCESS_GRANTED or DataSharingConsentRequiredAccessError - """ + Returns: + AccessResponse: Either ACCESS_GRANTED or DataSharingConsentRequiredAccessError + """ from openedx.features.enterprise_support.api import get_enterprise_consent_url + consent_url = get_enterprise_consent_url( request=get_current_request(), course_id=str(course_id), - return_to='courseware', + return_to="courseware", enrollment_exists=True, - source='CoursewareAccess' + source="CoursewareAccess", ) if consent_url: return DataSharingConsentRequiredAccessError(consent_url=consent_url) @@ -274,7 +267,7 @@ def check_correct_active_enterprise_customer(user, course_id): except (EnterpriseCustomerUser.DoesNotExist, EnterpriseCustomerUser.MultipleObjectsReturned): # Ideally this should not happen. As there should be only 1 active enterprise customer in our system log.error("Multiple or No Active Enterprise found for the user %s.", user.id) - active_enterprise_name = 'Incorrect' + active_enterprise_name = "Incorrect" enrollment_enterprise_name = enterprise_enrollments.first().enterprise_customer_user.enterprise_customer.name return IncorrectActiveEnterpriseAccessError(enrollment_enterprise_name, active_enterprise_name) diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 0ff6a11a62..a8333dd52e 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -4,6 +4,7 @@ courseware. """ import logging +import pickle from collections import defaultdict, namedtuple from datetime import datetime @@ -16,7 +17,7 @@ from django.core.cache import cache from django.http import Http404, QueryDict from django.urls import reverse from django.utils.translation import gettext as _ -from edx_django_utils.monitoring import function_trace +from edx_django_utils.monitoring import function_trace, set_custom_attribute from fs.errors import ResourceNotFound from opaque_keys.edx.keys import UsageKey from path import Path as path @@ -94,7 +95,16 @@ def get_course(course_id, depth=0): return course -def get_course_with_access(user, action, course_key, depth=0, check_if_enrolled=False, check_survey_complete=True, check_if_authenticated=False): # lint-amnesty, pylint: disable=line-too-long +def get_course_with_access( + user, + action, + course_key, + depth=0, + check_if_enrolled=False, + check_survey_complete=True, + check_if_authenticated=False, + allow_not_started_courses=False, +): """ Given a course_key, look up the corresponding course block, check that the user has the access to perform the specified action @@ -113,7 +123,15 @@ def get_course_with_access(user, action, course_key, depth=0, check_if_enrolled= be plugged in as additional callback checks for different actions. """ course = get_course_by_id(course_key, depth) - check_course_access_with_redirect(course, user, action, check_if_enrolled, check_survey_complete, check_if_authenticated) # lint-amnesty, pylint: disable=line-too-long + check_course_access_with_redirect( + course, + user, + action, + check_if_enrolled, + check_survey_complete, + check_if_authenticated, + allow_not_started_courses=allow_not_started_courses + ) return course @@ -202,7 +220,15 @@ def check_course_access( return non_staff_access_response -def check_course_access_with_redirect(course, user, action, check_if_enrolled=False, check_survey_complete=True, check_if_authenticated=False): # lint-amnesty, pylint: disable=line-too-long +def check_course_access_with_redirect( + course, + user, + action, + check_if_enrolled=False, + check_survey_complete=True, + check_if_authenticated=False, + allow_not_started_courses=False +): """ Check that the user has the access to perform the specified action on the course (CourseBlock|CourseOverview). @@ -216,6 +242,9 @@ def check_course_access_with_redirect(course, user, action, check_if_enrolled=Fa access_response = check_course_access(course, user, action, check_if_enrolled, check_survey_complete, check_if_authenticated) # lint-amnesty, pylint: disable=line-too-long if not access_response: + # StartDateError should be ignored + if isinstance(access_response, StartDateError) and allow_not_started_courses: + return # Redirect if StartDateError if isinstance(access_response, StartDateError): start_date = strftime_localized(course.start, 'SHORT_DATE') @@ -792,7 +821,17 @@ def get_assignments_grades(user, course_id, cache_timeout): collected_block_structure = cache.get(cache_key) if not collected_block_structure: collected_block_structure = get_block_structure_manager(course_id).get_collected() - cache.set(cache_key, collected_block_structure, cache_timeout) + + total_bytes_in_one_mb = 1024 * 1024 + data_size_in_bytes = len(pickle.dumps(collected_block_structure)) + if data_size_in_bytes < total_bytes_in_one_mb * 2: + cache.set(cache_key, collected_block_structure, cache_timeout) + else: + data_size_in_mbs = round(data_size_in_bytes / total_bytes_in_one_mb, 2) + # .. custom_attribute_name: collected_block_structure_size_in_mbs + # .. custom_attribute_description: contains the data chunk size in MBs. The size on which + # the memcached client failed to store value in cache. + set_custom_attribute('collected_block_structure_size_in_mbs', data_size_in_mbs) course_grade = CourseGradeFactory().read(user, collected_block_structure=collected_block_structure) diff --git a/lms/djangoapps/courseware/date_summary.py b/lms/djangoapps/courseware/date_summary.py index 4123da5f38..e6bd5ef705 100644 --- a/lms/djangoapps/courseware/date_summary.py +++ b/lms/djangoapps/courseware/date_summary.py @@ -268,7 +268,7 @@ class CourseStartDate(DateSummary): @property def title(self): enrollment = CourseEnrollment.get_enrollment(self.user, self.course_id) - if enrollment and self.course.end and enrollment.created > self.course.end: + if self.course.self_paced and enrollment and self.course.start and enrollment.created > self.course.start: return gettext_lazy('Enrollment Date') return gettext_lazy('Course starts') diff --git a/lms/djangoapps/courseware/models.py b/lms/djangoapps/courseware/models.py index eacf2424de..5006299451 100644 --- a/lms/djangoapps/courseware/models.py +++ b/lms/djangoapps/courseware/models.py @@ -287,6 +287,16 @@ class StudentModuleHistory(BaseStudentModuleHistory): student_module = models.ForeignKey(StudentModule, db_index=True, db_constraint=False, on_delete=models.CASCADE) + def __repr__(self): + student_dict = { + "course_id": str(self.student_module.course_id), + "module_type": self.student_module.module_type, + "student_id": self.student_module.student_id, + "grade": self.grade, + } + + return f"StudentModuleHistory<{student_dict!r}>" + def __str__(self): return str(repr(self)) diff --git a/lms/djangoapps/courseware/tests/helpers.py b/lms/djangoapps/courseware/tests/helpers.py index 85241ead4a..2b5a33b2ac 100644 --- a/lms/djangoapps/courseware/tests/helpers.py +++ b/lms/djangoapps/courseware/tests/helpers.py @@ -8,9 +8,8 @@ import re import json from collections import OrderedDict from datetime import timedelta -from unittest.mock import Mock, patch +from unittest.mock import Mock -from django.conf import settings from django.contrib import messages from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.test import TestCase @@ -20,7 +19,6 @@ from django.utils.timezone import now from xblock.field_data import DictFieldData from common.djangoapps.edxmako.shortcuts import render_to_string -from lms.djangoapps.courseware import access_utils from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.utils import verified_upgrade_deadline_link from lms.djangoapps.courseware.masquerade import MasqueradeView @@ -460,11 +458,3 @@ def get_context_dict_from_string(data): sorted(json.loads(validated_data['metadata']).items(), key=lambda t: t[0]) ) return validated_data - - -def set_preview_mode(preview_mode: bool): - """ - A decorator to force the preview mode on or off. - """ - hostname = settings.FEATURES.get('PREVIEW_LMS_BASE') if preview_mode else None - return patch.object(access_utils, 'get_current_request_hostname', new=lambda: hostname) diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py index e0c5f59a83..bb6d42025e 100644 --- a/lms/djangoapps/courseware/tests/test_access.py +++ b/lms/djangoapps/courseware/tests/test_access.py @@ -49,8 +49,10 @@ from xmodule.course_block import ( # lint-amnesty, pylint: disable=wrong-import CATALOG_VISIBILITY_CATALOG_AND_ABOUT, CATALOG_VISIBILITY_NONE ) + from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.tests.django_utils import ( # lint-amnesty, pylint: disable=wrong-import-order ModuleStoreTestCase, SharedModuleStoreTestCase @@ -245,35 +247,54 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes course_key = self.course.id chapter = BlockFactory.create(category="chapter", parent_location=self.course.location) overview = CourseOverview.get_from_id(course_key) + subsection = BlockFactory.create(category="sequential", parent_location=chapter.location) + unit = BlockFactory.create(category="vertical", parent_location=subsection.location) + + with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred): + html_block = BlockFactory.create( + category="html", + parent_location=unit.location, + display_name="Unpublished Block", + data='

    This block should not be published.

    ', + publish_item=False, + ) # Enroll student to the course CourseEnrollmentFactory(user=self.student, course_id=self.course.id) modules = [ self.course, - overview, chapter, + overview, + subsection, + unit, + ] - with patch('lms.djangoapps.courseware.access.in_preview_mode') as mock_preview: - mock_preview.return_value = False - for obj in modules: - assert bool(access.has_access(self.student, 'load', obj, course_key=self.course.id)) + # Student should have access to modules they're enrolled in + for obj in modules: + assert bool(access.has_access( + self.student, + 'load', + self.store.get_item(obj.location), + course_key=self.course.id) + ) - with patch('lms.djangoapps.courseware.access.in_preview_mode') as mock_preview: - mock_preview.return_value = True - for obj in modules: - assert not bool(access.has_access(self.student, 'load', obj, course_key=self.course.id)) + # If the document is not published yet, it should return an error when we try to fetch + # it from the store. This check confirms that the student would not be able to access it. + with pytest.raises(ItemNotFoundError): + self.store.get_item(html_block.location) - @patch('lms.djangoapps.courseware.access.in_preview_mode', Mock(return_value=True)) - def test_has_access_with_preview_mode(self): + def test_has_access_based_on_roles(self): """ - Tests particular user's can access content via has_access in preview mode. + Tests user access to content based on their role. """ assert bool(access.has_access(self.global_staff, 'staff', self.course, course_key=self.course.id)) assert bool(access.has_access(self.course_staff, 'staff', self.course, course_key=self.course.id)) assert bool(access.has_access(self.course_instructor, 'staff', self.course, course_key=self.course.id)) assert not bool(access.has_access(self.student, 'staff', self.course, course_key=self.course.id)) - assert not bool(access.has_access(self.student, 'load', self.course, course_key=self.course.id)) + + # Student should be able to load the course even if they don't have staff access. + assert bool(access.has_access(self.student, 'load', self.course, course_key=self.course.id)) # When masquerading is true, user should not be able to access staff content with patch('lms.djangoapps.courseware.access.is_masquerading_as_student') as mock_masquerade: @@ -281,10 +302,9 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes assert not bool(access.has_access(self.global_staff, 'staff', self.course, course_key=self.course.id)) assert not bool(access.has_access(self.student, 'staff', self.course, course_key=self.course.id)) - @patch('lms.djangoapps.courseware.access_utils.in_preview_mode', Mock(return_value=True)) - def test_has_access_in_preview_mode_with_group(self): + def test_has_access_with_content_groups(self): """ - Test that a user masquerading as a member of a group sees appropriate content in preview mode. + Test that a user masquerading as a member of a group sees appropriate content. """ # Note about UserPartition and UserPartition Group IDs: these must not conflict with IDs used # by dynamic user partitions. @@ -423,24 +443,6 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes assert bool(access._has_access_to_block(self.beta_user, 'load', mock_unit, course_key=self.course.id)) - @ddt.data(None, YESTERDAY, TOMORROW) - @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) - @patch( - 'lms.djangoapps.courseware.access_utils.get_current_request_hostname', - Mock(return_value='preview.localhost') - ) - def test__has_access_to_block_in_preview_mode(self, start): - """ - Tests that block has access in preview mode. - """ - mock_unit = Mock(location=self.course.location, user_partitions=[]) - mock_unit._class_tags = {} # Needed for detached check in _has_access_to_block - mock_unit.visible_to_staff_only = False - mock_unit.start = self.DATES[start] - mock_unit.merged_group_access = {} - - self.verify_access(mock_unit, True) - @ddt.data( (TOMORROW, access_response.StartDateError), (None, None), @@ -448,10 +450,11 @@ class AccessTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, MilestonesTes ) # ddt throws an error if I don't put the None argument there @ddt.unpack @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) - @patch('lms.djangoapps.courseware.access_utils.get_current_request_hostname', Mock(return_value='localhost')) - def test__has_access_to_block_when_not_in_preview_mode(self, start, expected_error_type): + def test__has_access_to_block_with_start_date(self, start, expected_error_type): """ - Tests that block has no access when start date in future & without preview. + Tests that block access follows start date rules. + Access should be denied when start date is in the future and granted when + start date is in the past or not set. """ expected_access = expected_error_type is None mock_unit = Mock(location=self.course.location, user_partitions=[]) diff --git a/lms/djangoapps/courseware/tests/test_date_summary.py b/lms/djangoapps/courseware/tests/test_date_summary.py index 27e7f1a3c2..1762d37bde 100644 --- a/lms/djangoapps/courseware/tests/test_date_summary.py +++ b/lms/djangoapps/courseware/tests/test_date_summary.py @@ -400,6 +400,37 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): block = CourseStartDate(course, user) assert block.date == course.start + @ddt.data( + # Instructor-paced course: Use course start date + (False, datetime(2025, 1, 10, tzinfo=utc), datetime(2025, 1, 12, tzinfo=utc), + datetime(2025, 1, 10, tzinfo=utc), 'Course starts'), + + # Self-paced course: Enrollment created later than course start + (True, datetime(2025, 1, 10, tzinfo=utc), datetime(2025, 1, 12), datetime(2025, 1, 12, tzinfo=utc), + 'Enrollment Date'), + + # Self-paced course: Enrollment created earlier than course start + (True, datetime(2025, 1, 10, tzinfo=utc), datetime(2025, 1, 8), datetime(2025, 1, 10, tzinfo=utc), + 'Course starts'), + + # Self-paced course: No enrollment + (True, datetime(2025, 1, 10, tzinfo=utc), None, datetime(2025, 1, 10, tzinfo=utc), 'Course starts'), + ) + @ddt.unpack + def test_course_start_date_label(self, self_paced, course_start, enrollment_created, expected_date, expected_title): + """ + Test the CourseStartDate class has correct label for course start date + """ + course = CourseFactory(self_paced=self_paced, start=course_start) + user = create_user() + if enrollment_created: + enrollment = CourseEnrollmentFactory(course_id=course.id, user=user, mode=CourseMode.VERIFIED) + enrollment.created = enrollment_created + enrollment.save() + date_summary = CourseStartDate(user=user, course=course) + self.assertEqual(date_summary.date, expected_date) + self.assertEqual(str(date_summary.title), expected_title) + ## Tests Course End Date Block def test_course_end_date_for_certificate_eligible_mode(self): course = create_course_run(days_till_start=-1) @@ -586,7 +617,7 @@ class CourseDateSummaryTest(SharedModuleStoreTestCase): block = VerificationDeadlineDate(course, user) assert block.css_class == 'verification-deadline-passed' assert block.title == 'Missed Verification Deadline' - assert block.date == (datetime.now(utc) + timedelta(days=(- 1))) + assert block.date == (datetime.now(utc) + timedelta(days=- 1)) assert block.description == "Unfortunately you missed this course's deadline for a successful verification." assert block.link_text == 'Learn More' assert block.link == '' diff --git a/lms/djangoapps/courseware/tests/test_masquerade.py b/lms/djangoapps/courseware/tests/test_masquerade.py index 9d211b05ea..33d100c7a2 100644 --- a/lms/djangoapps/courseware/tests/test_masquerade.py +++ b/lms/djangoapps/courseware/tests/test_masquerade.py @@ -26,7 +26,7 @@ from lms.djangoapps.courseware.masquerade import ( ) from lms.djangoapps.courseware.tests.helpers import ( - LoginEnrollmentTestCase, MasqueradeMixin, masquerade_as_group_member, set_preview_mode, + LoginEnrollmentTestCase, MasqueradeMixin, masquerade_as_group_member ) from lms.djangoapps.courseware.tests.test_submitting_problems import ProblemSubmissionTestMixin from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY @@ -244,14 +244,20 @@ class TestMasqueradeOptionsNoContentGroups(StaffMasqueradeTestCase): assert is_target_available == expected -@set_preview_mode(True) +# These tests are testing a capability of the old courseware page. We have to not +# force redirect to the new MFE in order to be able to load the old pages which are +# being tested by this page. +# +# This is a temporary change, until we can remove the old courseware pages +# all together. +@patch('lms.djangoapps.courseware.views.index.CoursewareIndex._redirect_to_learning_mfe', return_value=None) class TestStaffMasqueradeAsStudent(StaffMasqueradeTestCase): """ Check for staff being able to masquerade as student. """ @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) - def test_staff_debug_with_masquerade(self): + def test_staff_debug_with_masquerade(self, mock_redirect): """ Tests that staff debug control is not visible when masquerading as a student. """ @@ -267,7 +273,7 @@ class TestStaffMasqueradeAsStudent(StaffMasqueradeTestCase): self.verify_staff_debug_present(True) @patch.dict('django.conf.settings.FEATURES', {'DISABLE_START_DATES': False}) - def test_show_answer_for_staff(self): + def test_show_answer_for_staff(self, mock_redirect): """ Tests that "Show Answer" is not visible when masquerading as a student. """ diff --git a/lms/djangoapps/courseware/tests/test_navigation.py b/lms/djangoapps/courseware/tests/test_navigation.py index 82b27a5bd6..8c2144ee2a 100644 --- a/lms/djangoapps/courseware/tests/test_navigation.py +++ b/lms/djangoapps/courseware/tests/test_navigation.py @@ -15,11 +15,11 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory from common.djangoapps.student.tests.factories import GlobalStaffFactory -from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase, set_preview_mode +from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase from openedx.features.course_experience import DISABLE_COURSE_OUTLINE_PAGE_FLAG +from openedx.features.course_experience.url_helpers import make_learning_mfe_courseware_url -@set_preview_mode(True) class TestNavigation(SharedModuleStoreTestCase, LoginEnrollmentTestCase): """ Check that navigation state is saved properly. @@ -72,6 +72,13 @@ class TestNavigation(SharedModuleStoreTestCase, LoginEnrollmentTestCase): def setUp(self): super().setUp() self.login(self.user.email, 'test') + self.patcher = patch( + 'lms.djangoapps.courseware.views.index.CoursewareIndex._redirect_to_learning_mfe', return_value=None) + self.mock_redirect = self.patcher.start() + + def tearDown(self): + self.patcher.stop() + super().tearDown() def assertTabActive(self, tabname, response): ''' Check if the progress tab is active in the tab set ''' @@ -111,7 +118,7 @@ class TestNavigation(SharedModuleStoreTestCase, LoginEnrollmentTestCase): assert ('course-navigation' in response.content.decode('utf-8')) == accordion self.assertTabInactive('progress', response) - self.assertTabActive('courseware', response) + self.assertTabActive(make_learning_mfe_courseware_url(self.course.id), response) response = self.client.get(reverse('courseware_section', kwargs={ 'course_id': str(self.course.id), @@ -120,7 +127,7 @@ class TestNavigation(SharedModuleStoreTestCase, LoginEnrollmentTestCase): })) self.assertTabActive('progress', response) - self.assertTabInactive('courseware', response) + self.assertTabInactive(make_learning_mfe_courseware_url(self.course.id), response) @override_settings(SESSION_INACTIVITY_TIMEOUT_IN_SECONDS=1) def test_inactive_session_timeout(self): diff --git a/lms/djangoapps/courseware/tests/test_submitting_problems.py b/lms/djangoapps/courseware/tests/test_submitting_problems.py index 4d55645d7a..4d04204078 100644 --- a/lms/djangoapps/courseware/tests/test_submitting_problems.py +++ b/lms/djangoapps/courseware/tests/test_submitting_problems.py @@ -28,6 +28,7 @@ from xmodule.capa.tests.response_xml_factory import ( OptionResponseXMLFactory, SchematicResponseXMLFactory ) +from xmodule.capa.tests.test_util import use_unsafe_codejail from xmodule.capa.xqueue_interface import XQueueInterface from common.djangoapps.course_modes.models import CourseMode from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule @@ -810,6 +811,7 @@ class ProblemWithUploadedFilesTest(TestSubmittingProblems): self.assertEqual(list(kwargs['files'].keys()), filenames.split()) +@use_unsafe_codejail() class TestPythonGradedResponse(TestSubmittingProblems): """ Check that we can submit a schematic and custom response, and it answers properly. diff --git a/lms/djangoapps/courseware/tests/test_utils.py b/lms/djangoapps/courseware/tests/test_utils.py index f8508f69c9..0fedc27390 100644 --- a/lms/djangoapps/courseware/tests/test_utils.py +++ b/lms/djangoapps/courseware/tests/test_utils.py @@ -1,7 +1,9 @@ """ Unit test for various Utility functions """ + import json +from datetime import date, timedelta from unittest.mock import patch import ddt @@ -14,13 +16,44 @@ from rest_framework import status from common.djangoapps.student.tests.factories import GlobalStaffFactory, UserFactory from lms.djangoapps.courseware.constants import UNEXPECTED_ERROR_IS_ELIGIBLE from lms.djangoapps.courseware.tests.factories import FinancialAssistanceConfigurationFactory +from lms.djangoapps.courseware.access_utils import adjust_start_date from lms.djangoapps.courseware.utils import ( create_financial_assistance_application, get_financial_assistance_application_status, - is_eligible_for_financial_aid + is_eligible_for_financial_aid, ) +@ddt.ddt +class TestAccessUtils(TestCase): + """Tests for the access_utils functions.""" + + @ddt.data( + # days_early_for_beta, is_beta_user, expected_date + (None, True, "2025-01-03"), + (2, True, "2025-01-01"), + (timedelta.max.days + 10, True, "2025-01-03"), + (None, False, "2025-01-03"), + (2, False, "2025-01-03"), + (timedelta.max.days + 10, False, "2025-01-03"), + ) + @ddt.unpack + def test_adjust_start_date(self, days_early_for_beta, is_beta_user, expected_date): + """Tests adjust_start_date + + Should only modify the date if the user is beta for the course, + and `days_early_for_beta` is sensible number.""" + start = date(2025, 1, 3) + expected = date.fromisoformat(expected_date) + user = "princessofpower" + course_key = "edx1+8675" + with patch("lms.djangoapps.courseware.access_utils.CourseBetaTesterRole") as role_mock: + instance = role_mock.return_value + instance.has_user.return_value = is_beta_user + adjusted_date = adjust_start_date(user, days_early_for_beta, start, course_key) + self.assertEqual(expected, adjusted_date) + + @ddt.ddt class TestFinancialAssistanceViews(TestCase): """ @@ -29,17 +62,17 @@ class TestFinancialAssistanceViews(TestCase): def setUp(self) -> None: super().setUp() - self.test_course_id = 'course-v1:edX+Test+1' + self.test_course_id = "course-v1:edX+Test+1" self.user = UserFactory() self.global_staff = GlobalStaffFactory.create() _ = FinancialAssistanceConfigurationFactory( - api_base_url='http://financial.assistance.test:1234', + api_base_url="http://financial.assistance.test:1234", service_username=self.global_staff.username, fa_backend_enabled_courses_percentage=100, - enabled=True + enabled=True, ) _ = Application.objects.create( - name='Test Application', + name="Test Application", user=self.global_staff, client_type=Application.CLIENT_PUBLIC, authorization_grant_type=Application.GRANT_CLIENT_CREDENTIALS, @@ -51,33 +84,31 @@ class TestFinancialAssistanceViews(TestCase): """ mock_response = Response() mock_response.status_code = status_code - mock_response._content = json.dumps(content).encode('utf-8') # pylint: disable=protected-access + mock_response._content = json.dumps(content).encode("utf-8") # pylint: disable=protected-access return mock_response @ddt.data( - {'is_eligible': True, 'reason': None}, - {'is_eligible': False, 'reason': 'This course is not eligible for financial aid'} + {"is_eligible": True, "reason": None}, + {"is_eligible": False, "reason": "This course is not eligible for financial aid"}, ) def test_is_eligible_for_financial_aid(self, response_data): """ Tests the functionality of is_eligible_for_financial_aid which calls edx-financial-assistance backend to return eligibility status for financial assistance for a given course. """ - with patch.object(OAuthAPIClient, 'request') as oauth_mock: + with patch.object(OAuthAPIClient, "request") as oauth_mock: oauth_mock.return_value = self._mock_response(status.HTTP_200_OK, response_data) is_eligible, reason = is_eligible_for_financial_aid(self.test_course_id) - assert is_eligible is response_data.get('is_eligible') - assert reason == response_data.get('reason') + assert is_eligible is response_data.get("is_eligible") + assert reason == response_data.get("reason") def test_is_eligible_for_financial_aid_invalid_course_id(self): """ Tests the functionality of is_eligible_for_financial_aid for an invalid course id. """ error_message = f"Invalid course id {self.test_course_id} provided" - with patch.object(OAuthAPIClient, 'request') as oauth_mock: - oauth_mock.return_value = self._mock_response( - status.HTTP_400_BAD_REQUEST, {"message": error_message} - ) + with patch.object(OAuthAPIClient, "request") as oauth_mock: + oauth_mock.return_value = self._mock_response(status.HTTP_400_BAD_REQUEST, {"message": error_message}) is_eligible, reason = is_eligible_for_financial_aid(self.test_course_id) assert is_eligible is False assert reason == error_message @@ -86,9 +117,9 @@ class TestFinancialAssistanceViews(TestCase): """ Tests the functionality of is_eligible_for_financial_aid for an unexpected error """ - with patch.object(OAuthAPIClient, 'request') as oauth_mock: + with patch.object(OAuthAPIClient, "request") as oauth_mock: oauth_mock.return_value = self._mock_response( - status.HTTP_500_INTERNAL_SERVER_ERROR, {'error': 'unexpected error occurred'} + status.HTTP_500_INTERNAL_SERVER_ERROR, {"error": "unexpected error occurred"} ) is_eligible, reason = is_eligible_for_financial_aid(self.test_course_id) assert is_eligible is False @@ -99,33 +130,27 @@ class TestFinancialAssistanceViews(TestCase): Tests the functionality of get_financial_assistance_application_status against a user id and a course id edx-financial-assistance backend to return status of a financial assistance application. """ - test_response = {'id': 123, 'status': 'ACCEPTED', 'coupon_code': 'ABCD..'} - with patch.object(OAuthAPIClient, 'request') as oauth_mock: + test_response = {"id": 123, "status": "ACCEPTED", "coupon_code": "ABCD.."} + with patch.object(OAuthAPIClient, "request") as oauth_mock: oauth_mock.return_value = self._mock_response(status.HTTP_200_OK, test_response) has_application, reason = get_financial_assistance_application_status(self.user.id, self.test_course_id) assert has_application is True assert reason == test_response @ddt.data( - { - 'status': status.HTTP_400_BAD_REQUEST, - 'content': {'message': 'Invalid course id provided'} - }, - { - 'status': status.HTTP_404_NOT_FOUND, - 'content': {'message': 'Application details not found'} - } + {"status": status.HTTP_400_BAD_REQUEST, "content": {"message": "Invalid course id provided"}}, + {"status": status.HTTP_404_NOT_FOUND, "content": {"message": "Application details not found"}}, ) def test_get_financial_assistance_application_status_unsuccessful(self, response_data): """ Tests unsuccessful scenarios of get_financial_assistance_application_status against a user id and a course id edx-financial-assistance backend. """ - with patch.object(OAuthAPIClient, 'request') as oauth_mock: - oauth_mock.return_value = self._mock_response(response_data.get('status'), response_data.get('content')) + with patch.object(OAuthAPIClient, "request") as oauth_mock: + oauth_mock.return_value = self._mock_response(response_data.get("status"), response_data.get("content")) has_application, reason = get_financial_assistance_application_status(self.user.id, self.test_course_id) assert has_application is False - assert reason == response_data.get('content').get('message') + assert reason == response_data.get("content").get("message") def test_create_financial_assistance_application(self): """ @@ -133,11 +158,11 @@ class TestFinancialAssistanceViews(TestCase): to create a new financial assistance application given a form data. """ test_form_data = { - 'lms_user_id': self.user.id, - 'course_id': self.test_course_id, + "lms_user_id": self.user.id, + "course_id": self.test_course_id, } - with patch.object(OAuthAPIClient, 'request') as oauth_mock: - oauth_mock.return_value = self._mock_response(status.HTTP_200_OK, {'success': True}) + with patch.object(OAuthAPIClient, "request") as oauth_mock: + oauth_mock.return_value = self._mock_response(status.HTTP_200_OK, {"success": True}) response = create_financial_assistance_application(form_data=test_form_data) assert response.status_code == status.HTTP_204_NO_CONTENT @@ -147,12 +172,12 @@ class TestFinancialAssistanceViews(TestCase): to create a new financial assistance application given a form data. """ test_form_data = { - 'lms_user_id': self.user.id, - 'course_id': 'invalid_course_id', + "lms_user_id": self.user.id, + "course_id": "invalid_course_id", } - error_response = {'message': 'Invalid course id provided'} - with patch.object(OAuthAPIClient, 'request') as oauth_mock: + error_response = {"message": "Invalid course id provided"} + with patch.object(OAuthAPIClient, "request") as oauth_mock: oauth_mock.return_value = self._mock_response(status.HTTP_400_BAD_REQUEST, error_response) response = create_financial_assistance_application(form_data=test_form_data) assert response.status_code == status.HTTP_400_BAD_REQUEST - assert json.loads(response.content.decode('utf-8')) == error_response + assert json.loads(response.content.decode("utf-8")) == error_response diff --git a/lms/djangoapps/courseware/tests/test_video_mongo.py b/lms/djangoapps/courseware/tests/test_video_mongo.py index d45bb94816..4f83285169 100644 --- a/lms/djangoapps/courseware/tests/test_video_mongo.py +++ b/lms/djangoapps/courseware/tests/test_video_mongo.py @@ -96,6 +96,7 @@ class TestVideoYouTube(TestVideo): # lint-amnesty, pylint: disable=missing-clas 'cdn_exp_group': None, 'display_name': 'A Name', 'download_video_link': 'example.mp4', + 'is_video_from_same_origin': False, 'handout': None, 'hide_downloads': False, 'id': self.block.location.html_id(), @@ -184,6 +185,7 @@ class TestVideoNonYouTube(TestVideo): # pylint: disable=test-inherits-tests 'cdn_exp_group': None, 'display_name': 'A Name', 'download_video_link': 'example.mp4', + 'is_video_from_same_origin': False, 'handout': None, 'hide_downloads': False, 'is_embed': False, @@ -460,6 +462,7 @@ class TestGetHtmlMethod(BaseTestVideoXBlock): 'cdn_exp_group': None, 'display_name': 'A Name', 'download_video_link': 'example.mp4', + 'is_video_from_same_origin': False, 'handout': None, 'hide_downloads': False, 'id': self.block.location.html_id(), @@ -592,6 +595,7 @@ class TestGetHtmlMethod(BaseTestVideoXBlock): 'cdn_exp_group': None, 'display_name': 'A Name', 'download_video_link': 'example.mp4', + 'is_video_from_same_origin': False, 'handout': None, 'hide_downloads': False, 'id': self.block.location.html_id(), @@ -730,6 +734,7 @@ class TestGetHtmlMethod(BaseTestVideoXBlock): 'cdn_exp_group': None, 'display_name': 'A Name', 'download_video_link': 'example.mp4', + 'is_video_from_same_origin': False, 'handout': None, 'hide_downloads': False, 'is_embed': False, @@ -809,12 +814,16 @@ class TestGetHtmlMethod(BaseTestVideoXBlock): 'edx_video_id': edx_video_id, 'result': { 'download_video_link': f'http://fake-video.edx.org/{edx_video_id}.mp4', - 'sources': ['example.mp4', 'example.webm'] + [video['url'] for video in encoded_videos], + 'is_video_from_same_origin': True, + 'sources': ['http://fake-video.edx.org/example.mp4', 'http://fake-video.edx.org/example.webm'] + + [video['url'] for video in encoded_videos], }, } - # context returned by get_html when provided with above data - # expected_context, a dict to assert with context - context, expected_context = self.helper_get_html_with_edx_video_id(data) + with override_settings(VIDEO_CDN_URL={'default': 'http://fake-video.edx.org'}): + # context returned by get_html when provided with above data + # expected_context, a dict to assert with context + context, expected_context = self.helper_get_html_with_edx_video_id(data) + mako_service = self.block.runtime.service(self.block, 'mako') assert get_context_dict_from_string(context) ==\ get_context_dict_from_string(mako_service.render_lms_template('video.html', expected_context)) @@ -839,12 +848,15 @@ class TestGetHtmlMethod(BaseTestVideoXBlock): 'edx_video_id': f"{edx_video_id}\t", 'result': { 'download_video_link': f'http://fake-video.edx.org/{edx_video_id}.mp4', - 'sources': ['example.mp4', 'example.webm'] + [video['url'] for video in encoded_videos], + 'is_video_from_same_origin': True, + 'sources': ['http://fake-video.edx.org/example.mp4', 'http://fake-video.edx.org/example.webm'] + + [video['url'] for video in encoded_videos], }, } - # context returned by get_html when provided with above data - # expected_context, a dict to assert with context - context, expected_context = self.helper_get_html_with_edx_video_id(data) + with override_settings(VIDEO_CDN_URL={'default': 'http://fake-video.edx.org'}): + # context returned by get_html when provided with above data + # expected_context, a dict to assert with context + context, expected_context = self.helper_get_html_with_edx_video_id(data) mako_service = self.block.runtime.service(self.block, 'mako') assert get_context_dict_from_string(context) ==\ @@ -910,6 +922,7 @@ class TestGetHtmlMethod(BaseTestVideoXBlock): 'cdn_exp_group': None, 'display_name': 'A Name', 'download_video_link': 'example.mp4', + 'is_video_from_same_origin': False, 'handout': None, 'hide_downloads': False, 'is_embed': False, @@ -951,6 +964,7 @@ class TestGetHtmlMethod(BaseTestVideoXBlock): 'block_id': str(self.block.location), 'course_id': str(self.block.location.course_key), 'download_video_link': data['result']['download_video_link'], + 'is_video_from_same_origin': data['result']['is_video_from_same_origin'], 'metadata': json.dumps(expected_context['metadata']) }) return context, expected_context @@ -1029,6 +1043,7 @@ class TestGetHtmlMethod(BaseTestVideoXBlock): 'cdn_exp_group': None, 'display_name': 'A Name', 'download_video_link': None, + 'is_video_from_same_origin': False, 'handout': None, 'hide_downloads': False, 'is_embed': False, @@ -1129,6 +1144,7 @@ class TestGetHtmlMethod(BaseTestVideoXBlock): 'cdn_exp_group': None, 'display_name': 'A Name', 'download_video_link': None, + 'is_video_from_same_origin': False, 'handout': None, 'hide_downloads': False, 'id': None, @@ -2382,6 +2398,7 @@ class TestVideoWithBumper(TestVideo): # pylint: disable=test-inherits-tests 'cdn_exp_group': None, 'display_name': 'A Name', 'download_video_link': 'example.mp4', + 'is_video_from_same_origin': False, 'handout': None, 'hide_downloads': False, 'is_embed': False, @@ -2464,6 +2481,7 @@ class TestAutoAdvanceVideo(TestVideo): # lint-amnesty, pylint: disable=test-inh 'cdn_exp_group': None, 'display_name': 'A Name', 'download_video_link': 'example.mp4', + 'is_video_from_same_origin': False, 'handout': None, 'hide_downloads': False, 'is_embed': False, diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 126233880a..2a27a3fc41 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -24,7 +24,7 @@ from django.test.client import Client from django.test.utils import override_settings from django.urls import reverse, reverse_lazy from edx_django_utils.cache.utils import RequestCache -from edx_toggles.toggles.testutils import override_waffle_flag +from edx_toggles.toggles.testutils import override_waffle_flag, override_waffle_switch from freezegun import freeze_time from opaque_keys.edx.keys import CourseKey, UsageKey from pytz import UTC @@ -46,7 +46,6 @@ import lms.djangoapps.courseware.views.views as views from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.roles import CourseStaffRole from common.djangoapps.student.tests.factories import ( TEST_PASSWORD, AdminFactory, @@ -75,7 +74,7 @@ from lms.djangoapps.courseware.access_utils import check_course_open_for_learner from lms.djangoapps.courseware.model_data import FieldDataCache, set_score from lms.djangoapps.courseware.block_render import get_block, handle_xblock_callback from lms.djangoapps.courseware.tests.factories import StudentModuleFactory -from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin, get_expiration_banner_text, set_preview_mode +from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin, get_expiration_banner_text from lms.djangoapps.courseware.testutils import RenderXBlockTestMixin from lms.djangoapps.courseware.toggles import ( COURSEWARE_MICROFRONTEND_ALWAYS_OPEN_AUXILIARY_SIDEBAR, @@ -83,6 +82,7 @@ from lms.djangoapps.courseware.toggles import ( COURSEWARE_MICROFRONTEND_SEARCH_ENABLED, COURSEWARE_OPTIMIZED_RENDER_XBLOCK, ) +from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH from lms.djangoapps.courseware.user_state_client import DjangoXBlockUserStateClient from lms.djangoapps.courseware.views.views import ( BasePublicVideoXBlockView, @@ -108,7 +108,7 @@ from openedx.features.course_experience import ( ) from openedx.features.course_experience.tests.views.helpers import add_course_mode from openedx.features.course_experience.url_helpers import ( - get_courseware_url, + _get_legacy_courseware_url, get_learning_mfe_home_url, make_learning_mfe_courseware_url ) @@ -152,12 +152,10 @@ class TestJumpTo(ModuleStoreTestCase): # This is fragile, but unfortunately the problem is that within the LMS we # can't use the reverse calls from the CMS - with set_preview_mode(False): - response = self.client.get(jumpto_url) + response = self.client.get(jumpto_url) assert response.status_code == 302 assert response.url == expected_redirect_url - @set_preview_mode(False) def test_jump_to_preview_from_sequence(self): with self.store.default_store(ModuleStoreEnum.Type.split): course = CourseFactory.create() @@ -171,7 +169,6 @@ class TestJumpTo(ModuleStoreTestCase): assert response.status_code == 302 assert response.url == expected_redirect_url - @set_preview_mode(False) def test_jump_to_mfe_from_sequence(self): course = CourseFactory.create() chapter = BlockFactory.create(category='chapter', parent_location=course.location) @@ -184,7 +181,6 @@ class TestJumpTo(ModuleStoreTestCase): assert response.status_code == 302 assert response.url == expected_redirect_url - @set_preview_mode(False) def test_jump_to_preview_from_block(self): with self.store.default_store(ModuleStoreEnum.Type.split): course = CourseFactory.create() @@ -211,7 +207,6 @@ class TestJumpTo(ModuleStoreTestCase): assert response.status_code == 302 assert response.url == expected_redirect_url - @set_preview_mode(False) def test_jump_to_mfe_from_block(self): course = CourseFactory.create() chapter = BlockFactory.create(category='chapter', parent_location=course.location) @@ -239,7 +234,7 @@ class TestJumpTo(ModuleStoreTestCase): # The new courseware experience does not support this sort of course structure; # it assumes a simple course->chapter->sequence->unit->component tree. - @set_preview_mode(True) + @patch.object(views, 'get_courseware_url', new=_get_legacy_courseware_url) def test_jump_to_legacy_from_nested_block(self): with self.store.default_store(ModuleStoreEnum.Type.split): course = CourseFactory.create() @@ -275,11 +270,9 @@ class TestJumpTo(ModuleStoreTestCase): ) if preview_mode else ( f'/courses/{course.id}/jump_to/NoSuchPlace' ) - with set_preview_mode(False): - response = self.client.get(jumpto_url) + response = self.client.get(jumpto_url) assert response.status_code == 404 - @set_preview_mode(True) @ddt.data( (ModuleStoreEnum.Type.split, False, '1'), (ModuleStoreEnum.Type.split, True, '2'), @@ -316,17 +309,17 @@ class TestJumpTo(ModuleStoreTestCase): } ) expected_url += "?{}".format(urlencode({'activate_block_id': str(staff_only_vertical.location)})) - assert expected_url == get_courseware_url(usage_key, request) + assert expected_url == _get_legacy_courseware_url(usage_key, request) -@set_preview_mode(True) class IndexQueryTestCase(ModuleStoreTestCase): """ Tests for query count. """ NUM_PROBLEMS = 20 - def test_index_query_counts(self): + @patch('lms.djangoapps.courseware.views.index.CoursewareIndex._redirect_to_learning_mfe', return_value=None) + def test_index_query_counts(self, mock_redirect): # TODO: decrease query count as part of REVO-28 ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) with self.store.default_store(ModuleStoreEnum.Type.split): @@ -341,7 +334,7 @@ class IndexQueryTestCase(ModuleStoreTestCase): self.client.login(username=self.user.username, password=self.user_password) CourseEnrollment.enroll(self.user, course.id) - with self.assertNumQueries(177, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST): + with self.assertNumQueries(152, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST): with check_mongo_calls(3): url = reverse( 'courseware_section', @@ -427,27 +420,8 @@ class BaseViewsTestCase(ModuleStoreTestCase, MasqueradeMixin): self.global_staff = GlobalStaffFactory.create() # pylint: disable=attribute-defined-outside-init assert self.client.login(username=self.global_staff.username, password=TEST_PASSWORD) - def _get_urls(self): # lint-amnesty, pylint: disable=missing-function-docstring - lms_url = reverse( - 'courseware_section', - kwargs={ - 'course_id': str(self.course_key), - 'chapter': str(self.chapter.location.block_id), - 'section': str(self.section2.location.block_id), - }, - ) + '?foo=b$r' - mfe_url = '{}/course/{}/{}?foo=b%24r'.format( - settings.LEARNING_MICROFRONTEND_URL, - self.course_key, - self.section2.location - ) - preview_url = "http://" + settings.FEATURES.get('PREVIEW_LMS_BASE') + lms_url - - return lms_url, mfe_url, preview_url - @ddt.ddt -@set_preview_mode(True) class CoursewareIndexTestCase(BaseViewsTestCase): """ Tests for the courseware index view, used for instructor previews. @@ -456,7 +430,8 @@ class CoursewareIndexTestCase(BaseViewsTestCase): super().setUp() self._create_global_staff_user() # this view needs staff permission - def test_index_success(self): + @patch('lms.djangoapps.courseware.views.index.CoursewareIndex._redirect_to_learning_mfe', return_value=None) + def test_index_success(self, mock_redirect): response = self._verify_index_response() self.assertContains(response, self.problem2.location.replace(branch=None, version_guid=None)) @@ -468,7 +443,6 @@ class CoursewareIndexTestCase(BaseViewsTestCase): self.assertNotContains(response, self.problem.location.replace(branch=None, version_guid=None)) self.assertContains(response, self.problem2.location.replace(branch=None, version_guid=None)) - @set_preview_mode(True) def test_index_nonexistent_chapter(self): self._verify_index_response(expected_response_code=404, chapter_name='non-existent') @@ -505,12 +479,12 @@ class CoursewareIndexTestCase(BaseViewsTestCase): assert '/courses/{course_key}/courseware?{activate_block_id}'.format( course_key=str(self.course_key), activate_block_id=urlencode({'activate_block_id': str(self.course.location)}) - ) == get_courseware_url(self.course.location) + ) == _get_legacy_courseware_url(self.course.location) # test a section location assert '/courses/{course_key}/courseware/Chapter_1/Sequential_1/?{activate_block_id}'.format( course_key=str(self.course_key), activate_block_id=urlencode({'activate_block_id': str(self.section.location)}) - ) == get_courseware_url(self.section.location) + ) == _get_legacy_courseware_url(self.section.location) def test_index_invalid_position(self): request_url = '/'.join([ @@ -542,7 +516,8 @@ class CoursewareIndexTestCase(BaseViewsTestCase): # TODO: TNL-6387: Remove test @override_waffle_flag(DISABLE_COURSE_OUTLINE_PAGE_FLAG, active=True) - def test_accordion(self): + @patch('lms.djangoapps.courseware.views.index.CoursewareIndex._redirect_to_learning_mfe', return_value=None) + def test_accordion(self, mock_redirect): """ This needs a response_context, which is not included in the render_accordion's main method returning a render_to_string, so we will render via the courseware URL in order to include @@ -1148,13 +1123,22 @@ class TestProgressDueDate(BaseDueDateTests): # TODO: LEARNER-71: Delete entire TestAccordionDueDate class -@set_preview_mode(True) class TestAccordionDueDate(BaseDueDateTests): """ Test that the accordion page displays due dates correctly """ __test__ = True + def setUp(self): + super().setUp() + self.patcher = patch( + 'lms.djangoapps.courseware.views.index.CoursewareIndex._redirect_to_learning_mfe', return_value=None) + self.mock_redirect = self.patcher.start() + + def tearDown(self): + self.patcher.stop() + super().tearDown() + def get_response(self, course): """ Returns the HTML for the accordion """ return self.client.get( @@ -2363,13 +2347,13 @@ class ViewCheckerBlock(XBlock): @ddt.ddt -@set_preview_mode(True) class TestIndexView(ModuleStoreTestCase): """ Tests of the courseware.views.index view. """ @XBlock.register_temp_plugin(ViewCheckerBlock, 'view_checker') - def test_student_state(self): + @patch('lms.djangoapps.courseware.views.index.CoursewareIndex._redirect_to_learning_mfe', return_value=None) + def test_student_state(self, mock_redirect): """ Verify that saved student state is loaded for xblocks rendered in the index view. """ @@ -2409,7 +2393,8 @@ class TestIndexView(ModuleStoreTestCase): self.assertContains(response, "ViewCheckerPassed", count=3) @XBlock.register_temp_plugin(ActivateIDCheckerBlock, 'id_checker') - def test_activate_block_id(self): + @patch('lms.djangoapps.courseware.views.index.CoursewareIndex._redirect_to_learning_mfe', return_value=None) + def test_activate_block_id(self, mock_redirect): course = CourseFactory.create() with self.store.bulk_operations(course.id): chapter = BlockFactory.create(parent=course, category='chapter') @@ -2517,7 +2502,6 @@ class TestIndexView(ModuleStoreTestCase): @ddt.ddt -@set_preview_mode(True) class TestIndexViewCompleteOnView(ModuleStoreTestCase, CompletionWaffleTestMixin): """ Tests CompleteOnView is set up correctly in CoursewareIndex. @@ -2590,7 +2574,8 @@ class TestIndexViewCompleteOnView(ModuleStoreTestCase, CompletionWaffleTestMixin CourseEnrollmentFactory(user=self.user, course_id=self.course.id) assert self.client.login(username=self.user.username, password=self.user_password) - def test_completion_service_disabled(self): + @patch('lms.djangoapps.courseware.views.index.CoursewareIndex._redirect_to_learning_mfe', return_value=None) + def test_completion_service_disabled(self, mock_redirect): self.setup_course(ModuleStoreEnum.Type.split) @@ -2600,7 +2585,8 @@ class TestIndexViewCompleteOnView(ModuleStoreTestCase, CompletionWaffleTestMixin response = self.client.get(self.section_2_url) self.assertNotContains(response, 'data-mark-completed-on-view-after-delay') - def test_completion_service_enabled(self): + @patch('lms.djangoapps.courseware.views.index.CoursewareIndex._redirect_to_learning_mfe', return_value=None) + def test_completion_service_enabled(self, mock_redirect): self.override_waffle_switch(True) @@ -2652,7 +2638,6 @@ class TestIndexViewCompleteOnView(ModuleStoreTestCase, CompletionWaffleTestMixin @ddt.ddt -@set_preview_mode(True) class TestIndexViewWithVerticalPositions(ModuleStoreTestCase): """ Test the index view to handle vertical positions. Confirms that first position is loaded @@ -2704,7 +2689,8 @@ class TestIndexViewWithVerticalPositions(ModuleStoreTestCase): @ddt.data(("-1", 1), ("0", 1), ("-0", 1), ("2", 2), ("5", 1)) @ddt.unpack - def test_vertical_positions(self, input_position, expected_position): + @patch('lms.djangoapps.courseware.views.index.CoursewareIndex._redirect_to_learning_mfe', return_value=None) + def test_vertical_positions(self, input_position, expected_position, mock_redirect): """ Tests the following cases: * Load first position when negative position inputted. @@ -2933,9 +2919,9 @@ class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase, CompletionWaf ) @ddt.unpack @patch.dict('django.conf.settings.FEATURES', {'ENABLE_PROCTORED_EXAMS': True}) - @patch('lms.djangoapps.courseware.views.views.unpack_token_for') + @patch('lms.djangoapps.courseware.views.views.unpack_jwt') def test_render_descendant_of_exam_gated_by_access_token(self, exam_access_token, - expected_response, _mock_token_unpack): + expected_response, _mock_unpack_jwt): """ Verify blocks inside an exam that requires token access are gated by a valid exam access JWT issued for that exam sequence. @@ -2968,7 +2954,7 @@ class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase, CompletionWaf CourseOverview.load_from_module_store(self.course.id) self.setup_user(admin=False, enroll=True, login=True) - def _mock_token_unpack_fn(token, user_id): + def _mock_unpack_jwt_fn(token, user_id): if token == 'valid-jwt-for-exam-sequence': return {'content_id': str(self.sequence.location)} elif token == 'valid-jwt-for-incorrect-sequence': @@ -2976,7 +2962,7 @@ class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase, CompletionWaf else: raise Exception('invalid JWT') - _mock_token_unpack.side_effect = _mock_token_unpack_fn + _mock_unpack_jwt.side_effect = _mock_unpack_jwt_fn # Problem and Vertical response should be gated on access token for block in [self.problem_block, self.vertical_block]: @@ -3097,7 +3083,6 @@ class TestRenderXBlockSelfPaced(TestRenderXBlock): # lint-amnesty, pylint: disa return options -@set_preview_mode(True) class TestIndexViewCrawlerStudentStateWrites(SharedModuleStoreTestCase): """ Ensure that courseware index requests do not trigger student state writes. @@ -3128,7 +3113,8 @@ class TestIndexViewCrawlerStudentStateWrites(SharedModuleStoreTestCase): super().setUp() self.client.login(username=self.user.username, password=TEST_PASSWORD) - def test_write_by_default(self): + @patch('lms.djangoapps.courseware.views.index.CoursewareIndex._redirect_to_learning_mfe', return_value=None) + def test_write_by_default(self, mock_redirect): """By default, always write student state, regardless of user agent.""" with patch('lms.djangoapps.courseware.model_data.UserStateCache.set_many') as patched_state_client_set_many: # Simulate someone using Chrome @@ -3140,7 +3126,8 @@ class TestIndexViewCrawlerStudentStateWrites(SharedModuleStoreTestCase): self._load_courseware('edX-downloader/0.1') assert patched_state_client_set_many.called - def test_writes_with_config(self): + @patch('lms.djangoapps.courseware.views.index.CoursewareIndex._redirect_to_learning_mfe', return_value=None) + def test_writes_with_config(self, mock_redirect): """Test state writes (or lack thereof) based on config values.""" CrawlersConfig.objects.create(known_user_agents='edX-downloader,crawler_foo', enabled=True) with patch('lms.djangoapps.courseware.model_data.UserStateCache.set_many') as patched_state_client_set_many: @@ -3158,7 +3145,10 @@ class TestIndexViewCrawlerStudentStateWrites(SharedModuleStoreTestCase): # Disabling the crawlers config should revert us to default behavior CrawlersConfig.objects.create(enabled=False) - self.test_write_by_default() + + # Disabling the violation because pylint just can't see that we'll get the mock_redirect param passed in via the + # patch. + self.test_write_by_default() # pylint: disable=no-value-for-parameter def _load_courseware(self, user_agent): """Helper to load the actual courseware page.""" @@ -3321,25 +3311,6 @@ class MFEUrlTests(TestCase): ) -class PreviewTests(BaseViewsTestCase): - """ - Make sure we allow the Legacy view for course previews. - """ - def test_learner_redirect(self): - # learners will be redirected by default - lms_url, mfe_url, __ = self._get_urls() - assert self.client.get(lms_url).url == mfe_url - - def test_preview_no_redirect(self): - __, __, preview_url = self._get_urls() - with set_preview_mode(True): - # Previews server from PREVIEW_LMS_BASE will not redirect to the mfe - course_staff = UserFactory.create(is_staff=False) - CourseStaffRole(self.course_key).add_users(course_staff) - self.client.login(username=course_staff.username, password=TEST_PASSWORD) - assert self.client.get(preview_url).status_code == 200 - - class ContentOptimizationTestCase(ModuleStoreTestCase): """ Test our ability to make browser optimizations based on XBlock content. @@ -3777,7 +3748,7 @@ class TestCoursewareMFESearchAPI(SharedModuleStoreTestCase): @override_waffle_flag(COURSEWARE_MICROFRONTEND_SEARCH_ENABLED, active=False) def test_is_mfe_search_waffle_disabled(self): """ - Courseware search is only available when the waffle flag is enabled. + Courseware search is only available when the waffle flag is enabled, if no inclusion date is provided. """ user_admin = UserFactory(is_staff=True, is_superuser=True) CourseEnrollmentFactory.create(user=user_admin, course_id=self.course.id, mode=CourseMode.VERIFIED) @@ -3789,6 +3760,7 @@ class TestCoursewareMFESearchAPI(SharedModuleStoreTestCase): self.assertEqual(body, {'enabled': False}) @patch.dict('django.conf.settings.FEATURES', {'COURSEWARE_SEARCH_INCLUSION_DATE': '2020'}) + @override_waffle_flag(COURSEWARE_MICROFRONTEND_SEARCH_ENABLED, active=False) @ddt.data( (datetime(2013, 9, 18, 11, 30, 00), False), (None, False), @@ -3824,7 +3796,8 @@ class TestCoursewareMFENavigationSidebarTogglesAPI(SharedModuleStoreTestCase): @override_waffle_flag(COURSEWARE_MICROFRONTEND_ENABLE_NAVIGATION_SIDEBAR, active=True) @override_waffle_flag(COURSEWARE_MICROFRONTEND_ALWAYS_OPEN_AUXILIARY_SIDEBAR, active=False) - def test_courseware_mfe_navigation_sidebar_enabled_aux_disabled(self): + @override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, active=False) + def test_courseware_mfe_navigation_sidebar_enabled_aux_disabled_completion_track_disabled(self): """ Getter to check if it is allowed to show the Courseware navigation sidebar to a user and auxiliary sidebar doesn't open. @@ -3833,11 +3806,40 @@ class TestCoursewareMFENavigationSidebarTogglesAPI(SharedModuleStoreTestCase): body = json.loads(response.content.decode('utf-8')) self.assertEqual(response.status_code, 200) - self.assertEqual(body, {'enable_navigation_sidebar': True, 'always_open_auxiliary_sidebar': False}) + self.assertEqual( + body, + { + "enable_navigation_sidebar": True, + "always_open_auxiliary_sidebar": False, + "enable_completion_tracking": False, + }, + ) + + @override_waffle_flag(COURSEWARE_MICROFRONTEND_ENABLE_NAVIGATION_SIDEBAR, active=True) + @override_waffle_flag(COURSEWARE_MICROFRONTEND_ALWAYS_OPEN_AUXILIARY_SIDEBAR, active=False) + @override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, active=True) + def test_courseware_mfe_navigation_sidebar_enabled_aux_disabled_completion_track_enabled(self): + """ + Getter to check if it is allowed to show the Courseware navigation sidebar to a user + and auxiliary sidebar doesn't open. + """ + response = self.client.get(self.apiUrl, content_type='application/json') + body = json.loads(response.content.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + body, + { + "enable_navigation_sidebar": True, + "always_open_auxiliary_sidebar": False, + "enable_completion_tracking": True, + }, + ) @override_waffle_flag(COURSEWARE_MICROFRONTEND_ENABLE_NAVIGATION_SIDEBAR, active=True) @override_waffle_flag(COURSEWARE_MICROFRONTEND_ALWAYS_OPEN_AUXILIARY_SIDEBAR, active=True) - def test_courseware_mfe_navigation_sidebar_enabled_aux_enabled(self): + @override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, active=False) + def test_courseware_mfe_navigation_sidebar_enabled_aux_enabled_completion_track_disabled(self): """ Getter to check if it is allowed to show the Courseware navigation sidebar to a user and auxiliary sidebar should always open. @@ -3846,11 +3848,40 @@ class TestCoursewareMFENavigationSidebarTogglesAPI(SharedModuleStoreTestCase): body = json.loads(response.content.decode('utf-8')) self.assertEqual(response.status_code, 200) - self.assertEqual(body, {'enable_navigation_sidebar': True, 'always_open_auxiliary_sidebar': True}) + self.assertEqual( + body, + { + "enable_navigation_sidebar": True, + "always_open_auxiliary_sidebar": True, + "enable_completion_tracking": False, + }, + ) + + @override_waffle_flag(COURSEWARE_MICROFRONTEND_ENABLE_NAVIGATION_SIDEBAR, active=True) + @override_waffle_flag(COURSEWARE_MICROFRONTEND_ALWAYS_OPEN_AUXILIARY_SIDEBAR, active=True) + @override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, active=True) + def test_courseware_mfe_navigation_sidebar_enabled_aux_enabled_completion_track_enabled(self): + """ + Getter to check if it is allowed to show the Courseware navigation sidebar to a user + and auxiliary sidebar should always open. + """ + response = self.client.get(self.apiUrl, content_type='application/json') + body = json.loads(response.content.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + body, + { + "enable_navigation_sidebar": True, + "always_open_auxiliary_sidebar": True, + "enable_completion_tracking": True, + }, + ) @override_waffle_flag(COURSEWARE_MICROFRONTEND_ENABLE_NAVIGATION_SIDEBAR, active=False) @override_waffle_flag(COURSEWARE_MICROFRONTEND_ALWAYS_OPEN_AUXILIARY_SIDEBAR, active=True) - def test_courseware_mfe_navigation_sidebar_disabled_aux_enabled(self): + @override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, active=False) + def test_courseware_mfe_navigation_sidebar_disabled_aux_enabled_completion_track_disabled(self): """ Getter to check if the Courseware navigation sidebar shouldn't be shown to a user and auxiliary sidebar should always open. @@ -3859,11 +3890,40 @@ class TestCoursewareMFENavigationSidebarTogglesAPI(SharedModuleStoreTestCase): body = json.loads(response.content.decode('utf-8')) self.assertEqual(response.status_code, 200) - self.assertEqual(body, {'enable_navigation_sidebar': False, 'always_open_auxiliary_sidebar': True}) + self.assertEqual( + body, + { + "enable_navigation_sidebar": False, + "always_open_auxiliary_sidebar": True, + "enable_completion_tracking": False, + }, + ) + + @override_waffle_flag(COURSEWARE_MICROFRONTEND_ENABLE_NAVIGATION_SIDEBAR, active=False) + @override_waffle_flag(COURSEWARE_MICROFRONTEND_ALWAYS_OPEN_AUXILIARY_SIDEBAR, active=True) + @override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, active=True) + def test_courseware_mfe_navigation_sidebar_disabled_aux_enabled_completion_track_enabled(self): + """ + Getter to check if the Courseware navigation sidebar shouldn't be shown to a user + and auxiliary sidebar should always open. + """ + response = self.client.get(self.apiUrl, content_type='application/json') + body = json.loads(response.content.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + body, + { + "enable_navigation_sidebar": False, + "always_open_auxiliary_sidebar": True, + "enable_completion_tracking": True, + }, + ) @override_waffle_flag(COURSEWARE_MICROFRONTEND_ENABLE_NAVIGATION_SIDEBAR, active=False) @override_waffle_flag(COURSEWARE_MICROFRONTEND_ALWAYS_OPEN_AUXILIARY_SIDEBAR, active=False) - def test_courseware_mfe_navigation_sidebar_toggles_disabled(self): + @override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, active=False) + def test_courseware_mfe_navigation_sidebar_toggles_disabled_completion_track_disabled(self): """ Getter to check if neither navigation sidebar nor auxiliary sidebar is shown. """ @@ -3871,4 +3931,31 @@ class TestCoursewareMFENavigationSidebarTogglesAPI(SharedModuleStoreTestCase): body = json.loads(response.content.decode('utf-8')) self.assertEqual(response.status_code, 200) - self.assertEqual(body, {'enable_navigation_sidebar': False, 'always_open_auxiliary_sidebar': False}) + self.assertEqual( + body, + { + "enable_navigation_sidebar": False, + "always_open_auxiliary_sidebar": False, + "enable_completion_tracking": False, + }, + ) + + @override_waffle_flag(COURSEWARE_MICROFRONTEND_ENABLE_NAVIGATION_SIDEBAR, active=False) + @override_waffle_flag(COURSEWARE_MICROFRONTEND_ALWAYS_OPEN_AUXILIARY_SIDEBAR, active=False) + @override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, active=True) + def test_courseware_mfe_navigation_sidebar_toggles_disabled_completion_track_enabled(self): + """ + Getter to check if neither navigation sidebar nor auxiliary sidebar is shown. + """ + response = self.client.get(self.apiUrl, content_type='application/json') + body = json.loads(response.content.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + body, + { + "enable_navigation_sidebar": False, + "always_open_auxiliary_sidebar": False, + "enable_completion_tracking": True, + }, + ) diff --git a/lms/djangoapps/courseware/testutils.py b/lms/djangoapps/courseware/testutils.py index a18074e9cc..b0f444f288 100644 --- a/lms/djangoapps/courseware/testutils.py +++ b/lms/djangoapps/courseware/testutils.py @@ -11,9 +11,8 @@ from urllib.parse import urlencode import ddt from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from lms.djangoapps.courseware.tests.helpers import set_preview_mode from lms.djangoapps.courseware.utils import is_mode_upsellable -from openedx.features.course_experience.url_helpers import get_courseware_url +from openedx.features.course_experience.url_helpers import _get_legacy_courseware_url from common.djangoapps.student.tests.factories import AdminFactory, CourseEnrollmentFactory, UserFactory from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory @@ -171,8 +170,8 @@ class RenderXBlockTestMixin(MasqueradeMixin, metaclass=ABCMeta): ('html_block', 4), ) @ddt.unpack - @set_preview_mode(True) - def test_courseware_html(self, block_name, mongo_calls): + @patch('lms.djangoapps.courseware.views.index.CoursewareIndex._redirect_to_learning_mfe', return_value=None) + def test_courseware_html(self, block_name, mongo_calls, mock_redirect): """ To verify that the removal of courseware chrome elements is working, we include this test here to make sure the chrome elements that should @@ -186,7 +185,7 @@ class RenderXBlockTestMixin(MasqueradeMixin, metaclass=ABCMeta): self.setup_user(admin=True, enroll=True, login=True) with check_mongo_calls(mongo_calls): - url = get_courseware_url(self.block_to_be_tested.location) + url = _get_legacy_courseware_url(self.block_to_be_tested.location) response = self.client.get(url) expected_elements = self.block_specific_chrome_html_elements + self.COURSEWARE_CHROME_HTML_ELEMENTS for chrome_element in expected_elements: diff --git a/lms/djangoapps/courseware/toggles.py b/lms/djangoapps/courseware/toggles.py index e6070a2e3b..f9f083cad4 100644 --- a/lms/djangoapps/courseware/toggles.py +++ b/lms/djangoapps/courseware/toggles.py @@ -169,28 +169,12 @@ ENABLE_COURSE_DISCOVERY_DEFAULT_LANGUAGE_FILTER = WaffleSwitch( ) -def courseware_mfe_is_active() -> bool: - """ - Should we serve the Learning MFE as the canonical courseware experience? - """ - from lms.djangoapps.courseware.access_utils import in_preview_mode # avoid a circular import - - # We only use legacy views for the Studio "preview mode" feature these days, while everyone else gets the MFE - return not in_preview_mode() - - def course_exit_page_is_active(course_key): - return ( - courseware_mfe_is_active() and - COURSEWARE_MICROFRONTEND_COURSE_EXIT_PAGE.is_enabled(course_key) - ) + return COURSEWARE_MICROFRONTEND_COURSE_EXIT_PAGE.is_enabled(course_key) def courseware_mfe_progress_milestones_are_active(course_key): - return ( - courseware_mfe_is_active() and - COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES.is_enabled(course_key) - ) + return COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES.is_enabled(course_key) def streak_celebration_is_active(course_key): diff --git a/lms/djangoapps/courseware/views/index.py b/lms/djangoapps/courseware/views/index.py index 7a9242f595..02f0ef1f7f 100644 --- a/lms/djangoapps/courseware/views/index.py +++ b/lms/djangoapps/courseware/views/index.py @@ -27,7 +27,6 @@ from web_fragments.fragment import Fragment from xmodule.course_block import COURSE_VISIBILITY_PUBLIC from xmodule.modulestore.django import modulestore from xmodule.x_module import PUBLIC_VIEW, STUDENT_VIEW -from xmodule.util.xmodule_django import get_current_request_hostname from common.djangoapps.edxmako.shortcuts import render_to_response, render_to_string from common.djangoapps.student.models import CourseEnrollment @@ -48,7 +47,6 @@ from openedx.features.course_experience import ( default_course_url ) from openedx.features.course_experience.url_helpers import make_learning_mfe_courseware_url -from openedx.features.course_experience.views.course_sock import CourseSockFragmentView from openedx.features.enterprise_support.api import data_sharing_consent_required from ..access import has_access @@ -64,7 +62,7 @@ from ..masquerade import check_content_start_date_for_masquerade_user, setup_mas from ..model_data import FieldDataCache from ..block_render import get_block_for_descriptor, toc_for_course from ..permissions import MASQUERADE_AS_STUDENT -from ..toggles import ENABLE_OPTIMIZELY_IN_COURSEWARE, courseware_mfe_is_active +from ..toggles import ENABLE_OPTIMIZELY_IN_COURSEWARE from .views import CourseTabView log = logging.getLogger("edx.courseware.views.index") @@ -172,8 +170,7 @@ class CoursewareIndex(View): Can the user access this sequence in the courseware MFE? If so, redirect to MFE. """ # If the MFE is active, prefer that - if courseware_mfe_is_active(): - raise Redirect(self.microfrontend_url) + raise Redirect(self.microfrontend_url) @property def microfrontend_url(self): @@ -189,7 +186,7 @@ class CoursewareIndex(View): unit_key = None except InvalidKeyError: unit_key = None - is_preview = settings.FEATURES.get('PREVIEW_LMS_BASE') == get_current_request_hostname() + is_preview = False url = make_learning_mfe_courseware_url( self.course_key, self.section.location if self.section else None, @@ -454,9 +451,6 @@ class CoursewareIndex(View): table_of_contents['chapters'], ) - courseware_context['course_sock_fragment'] = CourseSockFragmentView().render_to_fragment( - request, course=self.course) - # entrance exam data self._add_entrance_exam_to_context(courseware_context) diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 4fd124e1a4..af157e76a0 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -33,6 +33,8 @@ from django.views.decorators.http import require_GET, require_http_methods, requ from django.views.generic import View from edx_django_utils.monitoring import set_custom_attribute, set_custom_attributes_for_course_key from ipware.ip import get_client_ip +from xblock.core import XBlock + from lms.djangoapps.static_template_view.views import render_500 from markupsafe import escape from opaque_keys import InvalidKeyError @@ -44,7 +46,6 @@ from rest_framework import status from rest_framework.decorators import api_view, throttle_classes from rest_framework.response import Response from rest_framework.throttling import UserRateThrottle -from token_utils.api import unpack_token_for from web_fragments.fragment import Fragment from xmodule.course_block import ( COURSE_VISIBILITY_PUBLIC, @@ -100,6 +101,7 @@ from lms.djangoapps.courseware.toggles import ( COURSEWARE_MICROFRONTEND_ENABLE_NAVIGATION_SIDEBAR, COURSEWARE_MICROFRONTEND_ALWAYS_OPEN_AUXILIARY_SIDEBAR, ) +from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH from lms.djangoapps.courseware.user_state_client import DjangoXBlockUserStateClient from lms.djangoapps.courseware.utils import ( _use_new_financial_assistance_flow, @@ -136,6 +138,7 @@ from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE from openedx.core.djangoapps.zendesk_proxy.utils import create_zendesk_ticket from openedx.core.djangolib.markup import HTML, Text from openedx.core.lib.courses import get_course_by_id +from openedx.core.lib.jwt import unpack_jwt from openedx.core.lib.mobile_utils import is_request_from_mobile_app from openedx.features.course_duration_limits.access import generate_course_expired_fragment from openedx.features.course_experience import course_home_url @@ -167,6 +170,8 @@ CertData = namedtuple( ) EARNED_BUT_NOT_AVAILABLE_CERT_STATUS = 'earned_but_not_available' +NOT_EARNED_BUT_AVAILABLE_DATE_CERT_STATUS = 'not_earned_but_available_date' + AUDIT_PASSING_CERT_DATA = CertData( CertificateStatuses.audit_passing, _('Your enrollment: Audit track'), @@ -232,6 +237,17 @@ def _earned_but_not_available_cert_data(cert_downloadable_status): ) +def _not_earned_but_available_date_cert_data(cert_downloadable_status): + return CertData( + NOT_EARNED_BUT_AVAILABLE_DATE_CERT_STATUS, + _('Your certificate will be available after the indicated date'), + _('After this course officially ends, you will receive an email notification with your certificate.'), + download_url=None, + cert_web_view_url=None, + certificate_available_date=cert_downloadable_status.get('certificate_available_date') + ) + + def _downloadable_cert_data(download_url=None, cert_web_view_url=None): return CertData( CertificateStatuses.downloadable, @@ -1089,6 +1105,9 @@ def _certificate_message(student, course, enrollment_mode): # lint-amnesty, pyl if cert_downloadable_status.get('earned_but_not_available'): return _earned_but_not_available_cert_data(cert_downloadable_status) + if cert_downloadable_status.get('not_earned_but_available_date'): + return _not_earned_but_available_date_cert_data(cert_downloadable_status) + if cert_downloadable_status['is_generating']: return GENERATING_CERT_DATA @@ -1118,6 +1137,9 @@ def get_cert_data(student, course, enrollment_mode, course_grade=None): if cert_data.cert_status == EARNED_BUT_NOT_AVAILABLE_CERT_STATUS: return cert_data + if cert_data.cert_status == NOT_EARNED_BUT_AVAILABLE_DATE_CERT_STATUS: + return cert_data + certificates_enabled_for_course = certs_api.has_self_generated_certificates_enabled(course.id) if course_grade is None: course_grade = CourseGradeFactory().read(student, course) @@ -1533,7 +1555,7 @@ def _check_sequence_exam_access(request, location): try: # unpack will validate both expiration and the requesting user matches the # token user - exam_access_unpacked = unpack_token_for(exam_access_token, request.user.id) + exam_access_unpacked = unpack_jwt(exam_access_token, request.user.id) except: # pylint: disable=bare-except log.exception(f"Failed to validate exam access token. user_id={request.user.id} location={location}") return False @@ -1562,6 +1584,10 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True, disable_sta set_custom_attributes_for_course_key(course_key) set_custom_attribute('usage_key', usage_key_string) set_custom_attribute('block_type', usage_key.block_type) + block_class = XBlock.load_class(usage_key.block_type) + if hasattr(block_class, 'is_extracted'): + is_extracted = block_class.is_extracted + set_custom_attribute('block_extracted', is_extracted) requested_view = request.GET.get('view', 'student_view') if requested_view != 'student_view' and requested_view != 'public_view': # lint-amnesty, pylint: disable=consider-using-in @@ -2195,7 +2221,7 @@ def financial_assistance_form(request, course_id=None): 'header_text': _get_fa_header(FINANCIAL_ASSISTANCE_HEADER), 'course_id': course_id, 'dashboard_url': reverse('dashboard'), - 'account_settings_url': reverse('account_settings'), + 'account_settings_url': settings.ACCOUNT_MICROFRONTEND_URL, 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), 'user_details': { 'email': user.email, @@ -2316,28 +2342,35 @@ def courseware_mfe_search_enabled(request, course_id=None): Simple GET endpoint to expose whether the user may use Courseware Search for a given course. """ - enabled = False course_key = CourseKey.from_string(course_id) if course_id else None user = request.user + has_required_enrollment = False if settings.FEATURES.get('ENABLE_COURSEWARE_SEARCH_VERIFIED_ENROLLMENT_REQUIRED'): enrollment_mode, _ = CourseEnrollment.enrollment_mode_for_user(user, course_key) if ( auth.user_has_role(user, CourseStaffRole(CourseKey.from_string(course_id))) or (enrollment_mode in CourseMode.VERIFIED_MODES) ): - enabled = True + has_required_enrollment = True else: - enabled = True + has_required_enrollment = True inclusion_date = settings.FEATURES.get('COURSEWARE_SEARCH_INCLUSION_DATE') start_date = CourseOverview.get_from_id(course_key).start + has_valid_inclusion_date = False - # only include courses that have a start date later than the setting-defined inclusion date + # only include courses that have a start date later than the setting-defined inclusion date, if setting exists if inclusion_date: - enabled = enabled and (start_date and start_date.strftime('%Y-%m-%d') > inclusion_date) + has_valid_inclusion_date = start_date and start_date.strftime('%Y-%m-%d') > inclusion_date - payload = {"enabled": courseware_mfe_search_is_enabled(course_key) if enabled else False} + # if the user has the appropriate enrollment, the feature is enabled if the course has a valid start date + # or if the feature is explicitly enabled via waffle flag. + enabled = (has_valid_inclusion_date or courseware_mfe_search_is_enabled(course_key)) \ + if has_required_enrollment \ + else False + + payload = {"enabled": enabled} return JsonResponse(payload) @@ -2354,4 +2387,6 @@ def courseware_mfe_navigation_sidebar_toggles(request, course_id=None): return JsonResponse({ "enable_navigation_sidebar": COURSEWARE_MICROFRONTEND_ENABLE_NAVIGATION_SIDEBAR.is_enabled(course_key), "always_open_auxiliary_sidebar": COURSEWARE_MICROFRONTEND_ALWAYS_OPEN_AUXILIARY_SIDEBAR.is_enabled(course_key), + # Add completion tracking status for the sidebar use while a global place for switches is put in place + "enable_completion_tracking": ENABLE_COMPLETION_TRACKING_SWITCH.is_enabled() }) diff --git a/lms/djangoapps/coursewarehistoryextended/migrations/0003_rename_studentmodulehistoryextended_student_module_student_module_idx.py b/lms/djangoapps/coursewarehistoryextended/migrations/0003_rename_studentmodulehistoryextended_student_module_student_module_idx.py new file mode 100644 index 0000000000..9a860e9206 --- /dev/null +++ b/lms/djangoapps/coursewarehistoryextended/migrations/0003_rename_studentmodulehistoryextended_student_module_student_module_idx.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.20 on 2025-05-15 03:30 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('coursewarehistoryextended', '0002_force_studentmodule_index'), + ] + + operations = [ + migrations.AlterField( + model_name='studentmodulehistoryextended', + name='student_module', + field=models.ForeignKey(db_constraint=False, db_index=False, on_delete=django.db.models.deletion.DO_NOTHING, to='courseware.studentmodule'), + ), + # This SeparateDatabaseAndState operation updates the state to match the model. + # database_operations is left empty because the indexes were already dropped + # by the previous operation in the database. + migrations.SeparateDatabaseAndState( + database_operations=[], + state_operations=[ + migrations.AlterIndexTogether( + name='studentmodulehistoryextended', + index_together=set(), + ), + ], + ), + # Adds back the index on 'student_module' to ensure there is a single index on this field. + migrations.AddIndex( + model_name='studentmodulehistoryextended', + index=models.Index(fields=['student_module'], name='student_module_idx'), + ), + ] diff --git a/lms/djangoapps/coursewarehistoryextended/models.py b/lms/djangoapps/coursewarehistoryextended/models.py index 37de4a93ab..0ba1c22e06 100644 --- a/lms/djangoapps/coursewarehistoryextended/models.py +++ b/lms/djangoapps/coursewarehistoryextended/models.py @@ -33,11 +33,16 @@ class StudentModuleHistoryExtended(BaseStudentModuleHistory): class Meta: app_label = 'coursewarehistoryextended' get_latest_by = "created" - index_together = ['student_module'] + indexes = [ + models.Index( + fields=['student_module'], + name="student_module_idx" + ), + ] id = UnsignedBigIntAutoField(primary_key=True) # pylint: disable=invalid-name - student_module = models.ForeignKey(StudentModule, db_index=True, db_constraint=False, on_delete=models.DO_NOTHING) + student_module = models.ForeignKey(StudentModule, db_index=False, db_constraint=False, on_delete=models.DO_NOTHING) @receiver(post_save, sender=StudentModule) def save_history(sender, instance, **kwargs): # pylint: disable=no-self-argument, unused-argument diff --git a/lms/djangoapps/discussion/config/settings.py b/lms/djangoapps/discussion/config/settings.py index 76e4ad7594..06040ad82e 100644 --- a/lms/djangoapps/discussion/config/settings.py +++ b/lms/djangoapps/discussion/config/settings.py @@ -3,6 +3,10 @@ Discussion settings. """ from django.conf import settings +from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag + +WAFFLE_NAMESPACE = 'discussion' + # .. toggle_name: FEATURES['ENABLE_FORUM_DAILY_DIGEST'] # .. toggle_implementation: DjangoSetting # .. toggle_default: False @@ -17,3 +21,13 @@ ENABLE_FORUM_DAILY_DIGEST = 'enable_forum_daily_digest' def is_forum_daily_digest_enabled(): """Returns whether forum notification features should be visible""" return settings.FEATURES.get('ENABLE_FORUM_DAILY_DIGEST', False) + +# .. toggle_name: discussion.enable_captcha +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable account level preferences for notifications +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2025-07-12 +# .. toggle_target_removal_date: 2025-07-29 +# .. toggle_warning: When the flag is ON, users will be able to see captcha for discussion. +ENABLE_CAPTCHA_IN_DISCUSSION = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_captcha', __name__) diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests.py b/lms/djangoapps/discussion/django_comment_client/base/tests.py index 62af24f0ee..bc2253b140 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests.py @@ -82,6 +82,7 @@ class MockRequestSetupMixin: @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) class CreateThreadGroupIdTestCase( MockRequestSetupMixin, CohortedTestCase, @@ -90,7 +91,21 @@ class CreateThreadGroupIdTestCase( ): cs_endpoint = "/threads" - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True): + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + + def call_view(self, mock_is_forum_v2_enabled, mock_request, commentable_id, user, group_id, pass_group_id=True): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, {}) request_data = {"body": "body", "title": "title", "thread_type": "discussion"} if pass_group_id: @@ -105,8 +120,9 @@ class CreateThreadGroupIdTestCase( commentable_id=commentable_id ) - def test_group_info_in_response(self, mock_request): + def test_group_info_in_response(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( + mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, @@ -116,6 +132,7 @@ class CreateThreadGroupIdTestCase( @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) @disable_signal(views, 'thread_edited') @disable_signal(views, 'thread_voted') @disable_signal(views, 'thread_deleted') @@ -127,11 +144,18 @@ class ThreadActionGroupIdTestCase( def call_view( self, view_name, + mock_is_forum_v2_enabled, mock_request, user=None, post_params=None, view_args=None ): + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data( mock_request, { @@ -154,53 +178,23 @@ class ThreadActionGroupIdTestCase( **(view_args or {}) ) - def test_update(self, mock_request): + def test_update(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( "update_thread", + mock_is_forum_v2_enabled, mock_request, post_params={"body": "body", "title": "title"} ) self._assert_json_response_contains_group_info(response) - def test_delete(self, mock_request): - response = self.call_view("delete_thread", mock_request) + def test_delete(self, mock_is_forum_v2_enabled, mock_request): + response = self.call_view("delete_thread", mock_is_forum_v2_enabled, mock_request) self._assert_json_response_contains_group_info(response) - def test_vote(self, mock_request): - response = self.call_view( - "vote_for_thread", - mock_request, - view_args={"value": "up"} - ) - self._assert_json_response_contains_group_info(response) - response = self.call_view("undo_vote_for_thread", mock_request) - self._assert_json_response_contains_group_info(response) - - def test_flag(self, mock_request): - with mock.patch('openedx.core.djangoapps.django_comment_common.signals.thread_flagged.send') as signal_mock: - response = self.call_view("flag_abuse_for_thread", mock_request) - self._assert_json_response_contains_group_info(response) - self.assertEqual(signal_mock.call_count, 1) - response = self.call_view("un_flag_abuse_for_thread", mock_request) - self._assert_json_response_contains_group_info(response) - - def test_pin(self, mock_request): - response = self.call_view( - "pin_thread", - mock_request, - user=self.moderator - ) - self._assert_json_response_contains_group_info(response) - response = self.call_view( - "un_pin_thread", - mock_request, - user=self.moderator - ) - self._assert_json_response_contains_group_info(response) - - def test_openclose(self, mock_request): + def test_openclose(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( "openclose_thread", + mock_is_forum_v2_enabled, mock_request, user=self.moderator ) @@ -280,10 +274,11 @@ class ViewsTestCaseMixin: data["depth"] = 0 self._set_mock_request_data(mock_request, data) - def create_thread_helper(self, mock_request, extra_request_data=None, extra_response_data=None): + def create_thread_helper(self, mock_is_forum_v2_enabled, mock_request, extra_request_data=None, extra_response_data=None): """ Issues a request to create a thread and verifies the result. """ + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "thread_type": "discussion", "title": "Hello", @@ -350,10 +345,11 @@ class ViewsTestCaseMixin: ) assert response.status_code == 200 - def update_thread_helper(self, mock_request): + def update_thread_helper(self, mock_is_forum_v2_enabled, mock_request): """ Issues a request to update a thread and verifies the result. """ + mock_is_forum_v2_enabled.return_value = False self._setup_mock_request(mock_request) # Mock out saving in order to test that content is correctly # updated. Otherwise, the call to thread.save() receives the @@ -376,6 +372,7 @@ class ViewsTestCaseMixin: @ddt.ddt @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) @disable_signal(views, 'thread_created') @disable_signal(views, 'thread_edited') class ViewsQueryCountTestCase( @@ -393,6 +390,11 @@ class ViewsQueryCountTestCase( @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def count_queries(func): # pylint: disable=no-self-argument """ @@ -414,22 +416,23 @@ class ViewsQueryCountTestCase( ) @ddt.unpack @count_queries - def test_create_thread(self, mock_request): - self.create_thread_helper(mock_request) + def test_create_thread(self, mock_is_forum_v2_enabled, mock_request): + self.create_thread_helper(mock_is_forum_v2_enabled, mock_request) @ddt.data( (ModuleStoreEnum.Type.split, 3, 6, 41), ) @ddt.unpack @count_queries - def test_update_thread(self, mock_request): - self.update_thread_helper(mock_request) + def test_update_thread(self, mock_is_forum_v2_enabled, mock_request): + self.update_thread_helper(mock_is_forum_v2_enabled, mock_request) @ddt.ddt @disable_signal(views, 'comment_flagged') @disable_signal(views, 'thread_flagged') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) class ViewsTestCase( ForumsEnableMixin, UrlResetMixin, @@ -464,7 +467,16 @@ class ViewsTestCase( # so we need to call super.setUp() which reloads urls.py (because # of the UrlResetMixin) super().setUp() - + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) # Patch the comment client user save method so it does not try # to create a new cc user when creating a django user with patch('common.djangoapps.student.models.user.cc.User.save'): @@ -497,11 +509,11 @@ class ViewsTestCase( with self.assert_signal_sent(views, signal, sender=None, user=user, exclude_args=('post',)): yield - def test_create_thread(self, mock_request): + def test_create_thread(self, mock_is_forum_v2_enabled, mock_request): with self.assert_discussion_signals('thread_created'): - self.create_thread_helper(mock_request) + self.create_thread_helper(mock_is_forum_v2_enabled, mock_request) - def test_create_thread_standalone(self, mock_request): + def test_create_thread_standalone(self, mock_is_forum_v2_enabled, mock_request): team = CourseTeamFactory.create( name="A Team", course_id=self.course_id, @@ -513,15 +525,15 @@ class ViewsTestCase( team.add_user(self.student) # create_thread_helper verifies that extra data are passed through to the comments service - self.create_thread_helper(mock_request, extra_response_data={'context': ThreadContext.STANDALONE}) + self.create_thread_helper(mock_is_forum_v2_enabled, mock_request, extra_response_data={'context': ThreadContext.STANDALONE}) @ddt.data( ('follow_thread', 'thread_followed'), ('unfollow_thread', 'thread_unfollowed'), ) @ddt.unpack - def test_follow_unfollow_thread_signals(self, view_name, signal, mock_request): - self.create_thread_helper(mock_request) + def test_follow_unfollow_thread_signals(self, view_name, signal, mock_is_forum_v2_enabled, mock_request): + self.create_thread_helper(mock_is_forum_v2_enabled, mock_request) with self.assert_discussion_signals(signal): response = self.client.post( @@ -532,7 +544,8 @@ class ViewsTestCase( ) assert response.status_code == 200 - def test_delete_thread(self, mock_request): + def test_delete_thread(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "user_id": str(self.student.id), "closed": False, @@ -551,7 +564,8 @@ class ViewsTestCase( assert response.status_code == 200 assert mock_request.called - def test_delete_comment(self, mock_request): + def test_delete_comment(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "user_id": str(self.student.id), "closed": False, @@ -573,12 +587,13 @@ class ViewsTestCase( assert args[0] == 'delete' assert args[1].endswith(f"/{test_comment_id}") - def _test_request_error(self, view_name, view_kwargs, data, mock_request): + def _test_request_error(self, view_name, view_kwargs, data, mock_is_forum_v2_enabled, mock_request): """ Submit a request against the given view with the given data and ensure that the result is a 400 error and that no data was posted using mock_request """ + mock_is_forum_v2_enabled.return_value = False self._setup_mock_request(mock_request, include_depth=(view_name == "create_sub_comment")) response = self.client.post(reverse(view_name, kwargs=view_kwargs), data=data) @@ -586,87 +601,97 @@ class ViewsTestCase( for call in mock_request.call_args_list: assert call[0][0].lower() == 'get' - def test_create_thread_no_title(self, mock_request): + def test_create_thread_no_title(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_thread", {"commentable_id": "dummy", "course_id": str(self.course_id)}, {"body": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_thread_empty_title(self, mock_request): + def test_create_thread_empty_title(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_thread", {"commentable_id": "dummy", "course_id": str(self.course_id)}, {"body": "foo", "title": " "}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_thread_no_body(self, mock_request): + def test_create_thread_no_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_thread", {"commentable_id": "dummy", "course_id": str(self.course_id)}, {"title": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_thread_empty_body(self, mock_request): + def test_create_thread_empty_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_thread", {"commentable_id": "dummy", "course_id": str(self.course_id)}, {"body": " ", "title": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_thread_no_title(self, mock_request): + def test_update_thread_no_title(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_thread", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"body": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_thread_empty_title(self, mock_request): + def test_update_thread_empty_title(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_thread", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"body": "foo", "title": " "}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_thread_no_body(self, mock_request): + def test_update_thread_no_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_thread", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"title": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_thread_empty_body(self, mock_request): + def test_update_thread_empty_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_thread", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"body": " ", "title": "foo"}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_thread_course_topic(self, mock_request): + def test_update_thread_course_topic(self, mock_is_forum_v2_enabled, mock_request): with self.assert_discussion_signals('thread_edited'): - self.update_thread_helper(mock_request) + self.update_thread_helper(mock_is_forum_v2_enabled, mock_request) @patch( 'lms.djangoapps.discussion.django_comment_client.utils.get_discussion_categories_ids', return_value=["test_commentable"], ) - def test_update_thread_wrong_commentable_id(self, mock_get_discussion_id_map, mock_request): + def test_update_thread_wrong_commentable_id(self, mock_get_discussion_id_map, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_thread", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"body": "foo", "title": "foo", "commentable_id": "wrong_commentable"}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_comment(self, mock_request): + def test_create_comment(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._setup_mock_request(mock_request) with self.assert_discussion_signals('comment_created'): response = self.client.post( @@ -678,55 +703,62 @@ class ViewsTestCase( ) assert response.status_code == 200 - def test_create_comment_no_body(self, mock_request): + def test_create_comment_no_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_comment", {"thread_id": "dummy", "course_id": str(self.course_id)}, {}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_comment_empty_body(self, mock_request): + def test_create_comment_empty_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_comment", {"thread_id": "dummy", "course_id": str(self.course_id)}, {"body": " "}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_sub_comment_no_body(self, mock_request): + def test_create_sub_comment_no_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_sub_comment", {"comment_id": "dummy", "course_id": str(self.course_id)}, {}, + mock_is_forum_v2_enabled, mock_request ) - def test_create_sub_comment_empty_body(self, mock_request): + def test_create_sub_comment_empty_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "create_sub_comment", {"comment_id": "dummy", "course_id": str(self.course_id)}, {"body": " "}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_comment_no_body(self, mock_request): + def test_update_comment_no_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_comment", {"comment_id": "dummy", "course_id": str(self.course_id)}, {}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_comment_empty_body(self, mock_request): + def test_update_comment_empty_body(self, mock_is_forum_v2_enabled, mock_request): self._test_request_error( "update_comment", {"comment_id": "dummy", "course_id": str(self.course_id)}, {"body": " "}, + mock_is_forum_v2_enabled, mock_request ) - def test_update_comment_basic(self, mock_request): + def test_update_comment_basic(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._setup_mock_request(mock_request) comment_id = "test_comment_id" updated_body = "updated body" @@ -745,327 +777,11 @@ class ViewsTestCase( headers=ANY, params=ANY, timeout=ANY, - data={"body": updated_body} + data={"body": updated_body, "course_id": str(self.course_id)} ) - def test_flag_thread_open(self, mock_request): - self.flag_thread(mock_request, False) - - def test_flag_thread_close(self, mock_request): - self.flag_thread(mock_request, True) - - def flag_thread(self, mock_request, is_closed): - self._set_mock_request_data(mock_request, { - "title": "Hello", - "body": "this is a post", - "course_id": "MITx/999/Robot_Super_Course", - "anonymous": False, - "anonymous_to_peers": False, - "commentable_id": "i4x-MITx-999-course-Robot_Super_Course", - "created_at": "2013-05-10T18:53:43Z", - "updated_at": "2013-05-10T18:53:43Z", - "at_position_list": [], - "closed": is_closed, - "id": "518d4237b023791dca00000d", - "user_id": "1", "username": "robot", - "votes": { - "count": 0, - "up_count": 0, - "down_count": 0, - "point": 0 - }, - "abuse_flaggers": [1], - "type": "thread", - "group_id": None, - "pinned": False, - "endorsed": False, - "unread_comments_count": 0, - "read": False, - "comments_count": 0, - }) - url = reverse('flag_abuse_for_thread', kwargs={ - 'thread_id': '518d4237b023791dca00000d', - 'course_id': str(self.course_id) - }) - response = self.client.post(url) - assert mock_request.called - - call_list = [ - ( - ('get', f'{CS_PREFIX}/threads/518d4237b023791dca00000d'), - { - 'data': None, - 'params': {'mark_as_read': True, 'request_id': ANY, 'with_responses': False, 'reverse_order': False, - 'merge_question_type_responses': False}, - 'headers': ANY, - 'timeout': 5 - } - ), - ( - ('put', f'{CS_PREFIX}/threads/518d4237b023791dca00000d/abuse_flag'), - { - 'data': {'user_id': '1'}, - 'params': {'request_id': ANY}, - 'headers': ANY, - 'timeout': 5 - } - ), - ( - ('get', f'{CS_PREFIX}/threads/518d4237b023791dca00000d'), - { - 'data': None, - 'params': {'mark_as_read': True, 'request_id': ANY, 'with_responses': False, 'reverse_order': False, - 'merge_question_type_responses': False}, - 'headers': ANY, - 'timeout': 5 - } - ) - ] - - assert mock_request.call_args_list == call_list - - assert response.status_code == 200 - - def test_un_flag_thread_open(self, mock_request): - self.un_flag_thread(mock_request, False) - - def test_un_flag_thread_close(self, mock_request): - self.un_flag_thread(mock_request, True) - - def un_flag_thread(self, mock_request, is_closed): - self._set_mock_request_data(mock_request, { - "title": "Hello", - "body": "this is a post", - "course_id": "MITx/999/Robot_Super_Course", - "anonymous": False, - "anonymous_to_peers": False, - "commentable_id": "i4x-MITx-999-course-Robot_Super_Course", - "created_at": "2013-05-10T18:53:43Z", - "updated_at": "2013-05-10T18:53:43Z", - "at_position_list": [], - "closed": is_closed, - "id": "518d4237b023791dca00000d", - "user_id": "1", - "username": "robot", - "votes": { - "count": 0, - "up_count": 0, - "down_count": 0, - "point": 0 - }, - "abuse_flaggers": [], - "type": "thread", - "group_id": None, - "pinned": False, - "endorsed": False, - "unread_comments_count": 0, - "read": False, - "comments_count": 0 - }) - url = reverse('un_flag_abuse_for_thread', kwargs={ - 'thread_id': '518d4237b023791dca00000d', - 'course_id': str(self.course_id) - }) - response = self.client.post(url) - assert mock_request.called - - call_list = [ - ( - ('get', f'{CS_PREFIX}/threads/518d4237b023791dca00000d'), - { - 'data': None, - 'params': {'mark_as_read': True, 'request_id': ANY, 'with_responses': False, 'reverse_order': False, - 'merge_question_type_responses': False}, - 'headers': ANY, - 'timeout': 5 - } - ), - ( - ('put', f'{CS_PREFIX}/threads/518d4237b023791dca00000d/abuse_unflag'), - { - 'data': {'user_id': '1'}, - 'params': {'request_id': ANY}, - 'headers': ANY, - 'timeout': 5 - } - ), - ( - ('get', f'{CS_PREFIX}/threads/518d4237b023791dca00000d'), - { - 'data': None, - 'params': {'mark_as_read': True, 'request_id': ANY, 'with_responses': False, 'reverse_order': False, - 'merge_question_type_responses': False}, - 'headers': ANY, - 'timeout': 5 - } - ) - ] - - assert mock_request.call_args_list == call_list - - assert response.status_code == 200 - - def test_flag_comment_open(self, mock_request): - self.flag_comment(mock_request, False) - - def test_flag_comment_close(self, mock_request): - self.flag_comment(mock_request, True) - - def flag_comment(self, mock_request, is_closed): - self._set_mock_request_data(mock_request, { - "body": "this is a comment", - "course_id": "MITx/999/Robot_Super_Course", - "anonymous": False, - "anonymous_to_peers": False, - "commentable_id": "i4x-MITx-999-course-Robot_Super_Course", - "created_at": "2013-05-10T18:53:43Z", - "updated_at": "2013-05-10T18:53:43Z", - "at_position_list": [], - "closed": is_closed, - "id": "518d4237b023791dca00000d", - "user_id": "1", - "username": "robot", - "votes": { - "count": 0, - "up_count": 0, - "down_count": 0, - "point": 0 - }, - "abuse_flaggers": [1], - "type": "comment", - "endorsed": False - }) - url = reverse('flag_abuse_for_comment', kwargs={ - 'comment_id': '518d4237b023791dca00000d', - 'course_id': str(self.course_id) - }) - response = self.client.post(url) - assert mock_request.called - - call_list = [ - ( - ('get', f'{CS_PREFIX}/comments/518d4237b023791dca00000d'), - { - 'data': None, - 'params': {'request_id': ANY}, - 'headers': ANY, - 'timeout': 5 - } - ), - ( - ('put', f'{CS_PREFIX}/comments/518d4237b023791dca00000d/abuse_flag'), - { - 'data': {'user_id': '1'}, - 'params': {'request_id': ANY}, - 'headers': ANY, - 'timeout': 5 - } - ), - ( - ('get', f'{CS_PREFIX}/comments/518d4237b023791dca00000d'), - { - 'data': None, - 'params': {'request_id': ANY}, - 'headers': ANY, - 'timeout': 5 - } - ) - ] - - assert mock_request.call_args_list == call_list - - assert response.status_code == 200 - - def test_un_flag_comment_open(self, mock_request): - self.un_flag_comment(mock_request, False) - - def test_un_flag_comment_close(self, mock_request): - self.un_flag_comment(mock_request, True) - - def un_flag_comment(self, mock_request, is_closed): - self._set_mock_request_data(mock_request, { - "body": "this is a comment", - "course_id": "MITx/999/Robot_Super_Course", - "anonymous": False, - "anonymous_to_peers": False, - "commentable_id": "i4x-MITx-999-course-Robot_Super_Course", - "created_at": "2013-05-10T18:53:43Z", - "updated_at": "2013-05-10T18:53:43Z", - "at_position_list": [], - "closed": is_closed, - "id": "518d4237b023791dca00000d", - "user_id": "1", - "username": "robot", - "votes": { - "count": 0, - "up_count": 0, - "down_count": 0, - "point": 0 - }, - "abuse_flaggers": [], - "type": "comment", - "endorsed": False - }) - url = reverse('un_flag_abuse_for_comment', kwargs={ - 'comment_id': '518d4237b023791dca00000d', - 'course_id': str(self.course_id) - }) - response = self.client.post(url) - assert mock_request.called - - call_list = [ - ( - ('get', f'{CS_PREFIX}/comments/518d4237b023791dca00000d'), - { - 'data': None, - 'params': {'request_id': ANY}, - 'headers': ANY, - 'timeout': 5 - } - ), - ( - ('put', f'{CS_PREFIX}/comments/518d4237b023791dca00000d/abuse_unflag'), - { - 'data': {'user_id': '1'}, - 'params': {'request_id': ANY}, - 'headers': ANY, - 'timeout': 5 - } - ), - ( - ('get', f'{CS_PREFIX}/comments/518d4237b023791dca00000d'), - { - 'data': None, - 'params': {'request_id': ANY}, - 'headers': ANY, - 'timeout': 5 - } - ) - ] - - assert mock_request.call_args_list == call_list - - assert response.status_code == 200 - - @ddt.data( - ('upvote_thread', 'thread_id', 'thread_voted'), - ('upvote_comment', 'comment_id', 'comment_voted'), - ('downvote_thread', 'thread_id', 'thread_voted'), - ('downvote_comment', 'comment_id', 'comment_voted') - ) - @ddt.unpack - def test_voting(self, view_name, item_id, signal, mock_request): - self._setup_mock_request(mock_request) - with self.assert_discussion_signals(signal): - response = self.client.post( - reverse( - view_name, - kwargs={item_id: 'dummy', 'course_id': str(self.course_id)} - ) - ) - assert response.status_code == 200 - - def test_endorse_comment(self, mock_request): + def test_endorse_comment(self, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._setup_mock_request(mock_request) self.client.login(username=self.moderator.username, password=self.password) with self.assert_discussion_signals('comment_endorsed', user=self.moderator): @@ -1079,6 +795,7 @@ class ViewsTestCase( @patch("openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request", autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) @disable_signal(views, 'comment_endorsed') class ViewPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase, MockRequestSetupMixin): @@ -1106,40 +823,18 @@ class ViewPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleStor @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() - - def test_pin_thread_as_student(self, mock_request): - self._set_mock_request_data(mock_request, {}) - self.client.login(username=self.student.username, password=self.password) - response = self.client.post( - reverse("pin_thread", kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" ) - assert response.status_code == 401 - - def test_pin_thread_as_moderator(self, mock_request): - self._set_mock_request_data(mock_request, {}) - self.client.login(username=self.moderator.username, password=self.password) - response = self.client.post( - reverse("pin_thread", kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" ) - assert response.status_code == 200 + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) - def test_un_pin_thread_as_student(self, mock_request): - self._set_mock_request_data(mock_request, {}) - self.client.login(username=self.student.username, password=self.password) - response = self.client.post( - reverse("un_pin_thread", kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}) - ) - assert response.status_code == 401 - - def test_un_pin_thread_as_moderator(self, mock_request): - self._set_mock_request_data(mock_request, {}) - self.client.login(username=self.moderator.username, password=self.password) - response = self.client.post( - reverse("un_pin_thread", kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}) - ) - assert response.status_code == 200 - - def _set_mock_request_thread_and_comment(self, mock_request, thread_data, comment_data): + def _set_mock_request_thread_and_comment(self, mock_is_forum_v2_enabled, mock_request, thread_data, comment_data): def handle_request(*args, **kwargs): url = args[1] if "/threads/" in url: @@ -1148,10 +843,12 @@ class ViewPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleStor return self._create_response_mock(comment_data) else: raise ArgumentError("Bad url to mock request") + mock_is_forum_v2_enabled.return_value = False mock_request.side_effect = handle_request - def test_endorse_response_as_staff(self, mock_request): + def test_endorse_response_as_staff(self, mock_is_forum_v2_enabled, mock_request): self._set_mock_request_thread_and_comment( + mock_is_forum_v2_enabled, mock_request, {"type": "thread", "thread_type": "question", "user_id": str(self.student.id), "commentable_id": "course"}, {"type": "comment", "thread_id": "dummy"} @@ -1162,8 +859,9 @@ class ViewPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleStor ) assert response.status_code == 200 - def test_endorse_response_as_student(self, mock_request): + def test_endorse_response_as_student(self, mock_is_forum_v2_enabled, mock_request): self._set_mock_request_thread_and_comment( + mock_is_forum_v2_enabled, mock_request, {"type": "thread", "thread_type": "question", "user_id": str(self.moderator.id), "commentable_id": "course"}, @@ -1175,8 +873,9 @@ class ViewPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleStor ) assert response.status_code == 401 - def test_endorse_response_as_student_question_author(self, mock_request): + def test_endorse_response_as_student_question_author(self, mock_is_forum_v2_enabled, mock_request): self._set_mock_request_thread_and_comment( + mock_is_forum_v2_enabled, mock_request, {"type": "thread", "thread_type": "question", "user_id": str(self.student.id), "commentable_id": "course"}, {"type": "comment", "thread_id": "dummy"} @@ -1209,10 +908,12 @@ class CreateThreadUnicodeTestCase( CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request,): + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def _test_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request,): """ Test to make sure unicode data in a thread doesn't break it. """ + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, {}) request = RequestFactory().post("dummy_url", {"thread_type": "discussion", "body": text, "title": text}) request.user = self.student @@ -1235,6 +936,13 @@ class UpdateThreadUnicodeTestCase( UnicodeTestMixin, MockRequestSetupMixin ): + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) @classmethod def setUpClass(cls): @@ -1255,7 +963,9 @@ class UpdateThreadUnicodeTestCase( return_value=["test_commentable"], ) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request, mock_get_discussion_id_map): + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def _test_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request, mock_get_discussion_id_map): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "user_id": str(self.student.id), "closed": False, @@ -1280,6 +990,13 @@ class CreateCommentUnicodeTestCase( UnicodeTestMixin, MockRequestSetupMixin ): + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) @classmethod def setUpClass(cls): @@ -1296,7 +1013,9 @@ class CreateCommentUnicodeTestCase( CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request): + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def _test_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False commentable_id = "non_team_dummy_id" self._set_mock_request_data(mock_request, { "closed": False, @@ -1327,6 +1046,13 @@ class UpdateCommentUnicodeTestCase( UnicodeTestMixin, MockRequestSetupMixin ): + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) @classmethod def setUpClass(cls): @@ -1343,7 +1069,9 @@ class UpdateCommentUnicodeTestCase( CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request): + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def _test_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request): + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "user_id": str(self.student.id), "closed": False, @@ -1358,48 +1086,6 @@ class UpdateCommentUnicodeTestCase( assert mock_request.call_args[1]['data']['body'] == text -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -class CommentActionTestCase( - MockRequestSetupMixin, - CohortedTestCase, - GroupIdAssertionMixin -): - def call_view( - self, - view_name, - mock_request, - user=None, - post_params=None, - view_args=None - ): - self._set_mock_request_data( - mock_request, - { - "user_id": str(self.student.id), - "group_id": self.student_cohort.id, - "closed": False, - "type": "thread", - "commentable_id": "non_team_dummy_id", - "body": "test body", - } - ) - request = RequestFactory().post("dummy_url", post_params or {}) - request.user = user or self.student - request.view_name = view_name - - return getattr(views, view_name)( - request, - course_id=str(self.course.id), - comment_id="dummy", - **(view_args or {}) - ) - - def test_flag(self, mock_request): - with mock.patch('openedx.core.djangoapps.django_comment_common.signals.comment_flagged.send') as signal_mock: - self.call_view("flag_abuse_for_comment", mock_request) - self.assertEqual(signal_mock.call_count, 1) - - @disable_signal(views, 'comment_created') class CreateSubCommentUnicodeTestCase( ForumsEnableMixin, @@ -1410,6 +1096,14 @@ class CreateSubCommentUnicodeTestCase( """ Make sure comments under a response can handle unicode. """ + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + @classmethod def setUpClass(cls): # pylint: disable=super-method-not-called @@ -1425,10 +1119,12 @@ class CreateSubCommentUnicodeTestCase( CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request): + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def _test_unicode_data(self, text, mock_is_forum_v2_enabled, mock_request): """ Create a comment with unicode in it. """ + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "closed": False, "depth": 1, @@ -1453,6 +1149,7 @@ class CreateSubCommentUnicodeTestCase( @ddt.ddt @patch("openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request", autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) @disable_signal(views, 'thread_voted') @disable_signal(views, 'thread_edited') @disable_signal(views, 'comment_created') @@ -1562,13 +1259,24 @@ class TeamsPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleSto users=[cls.group_moderator, cls.cohorted] ) - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() - def _setup_mock(self, user, mock_request, data): + def _setup_mock(self, user, mock_is_forum_v2_enabled, mock_request, data): user = getattr(self, user) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, data) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.client.login(username=user.username, password=self.password) @ddt.data( @@ -1593,7 +1301,7 @@ class TeamsPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleSto ('group_moderator', 'cohorted', 'course_commentable_id', 401, CourseDiscussionSettings.NONE) ) @ddt.unpack - def test_update_thread(self, user, thread_author, commentable_id, status_code, division_scheme, mock_request): + def test_update_thread(self, user, thread_author, commentable_id, status_code, division_scheme, mock_is_forum_v2_enabled, mock_request): """ Verify that update_thread is limited to thread authors and privileged users (team membership does not matter). """ @@ -1603,7 +1311,7 @@ class TeamsPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleSto thread_author = getattr(self, thread_author) self._setup_mock( - user, mock_request, # user is the person making the request. + user, mock_is_forum_v2_enabled, mock_request, # user is the person making the request. { "user_id": str(thread_author.id), "closed": False, "commentable_id": commentable_id, @@ -1643,12 +1351,12 @@ class TeamsPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleSto ('group_moderator', 'cohorted', 'team_commentable_id', 401, CourseDiscussionSettings.NONE) ) @ddt.unpack - def test_delete_comment(self, user, comment_author, commentable_id, status_code, division_scheme, mock_request): + def test_delete_comment(self, user, comment_author, commentable_id, status_code, division_scheme, mock_is_forum_v2_enabled, mock_request): commentable_id = getattr(self, commentable_id) comment_author = getattr(self, comment_author) self.change_divided_discussion_settings(division_scheme) - self._setup_mock(user, mock_request, { + self._setup_mock(user, mock_is_forum_v2_enabled, mock_request, { "closed": False, "commentable_id": commentable_id, "user_id": str(comment_author.id), @@ -1671,12 +1379,12 @@ class TeamsPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleSto @ddt.data(*ddt_permissions_args) @ddt.unpack - def test_create_comment(self, user, commentable_id, status_code, mock_request): + def test_create_comment(self, user, commentable_id, status_code, mock_is_forum_v2_enabled, mock_request): """ Verify that create_comment is limited to members of the team or users with 'edit_content' permission. """ commentable_id = getattr(self, commentable_id) - self._setup_mock(user, mock_request, {"closed": False, "commentable_id": commentable_id}) + self._setup_mock(user, mock_is_forum_v2_enabled, mock_request, {"closed": False, "commentable_id": commentable_id}) response = self.client.post( reverse( @@ -1692,13 +1400,13 @@ class TeamsPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleSto @ddt.data(*ddt_permissions_args) @ddt.unpack - def test_create_sub_comment(self, user, commentable_id, status_code, mock_request): + def test_create_sub_comment(self, user, commentable_id, status_code, mock_is_forum_v2_enabled, mock_request): """ Verify that create_subcomment is limited to members of the team or users with 'edit_content' permission. """ commentable_id = getattr(self, commentable_id) self._setup_mock( - user, mock_request, + user, mock_is_forum_v2_enabled, mock_request, {"closed": False, "commentable_id": commentable_id, "thread_id": "dummy_thread"}, ) response = self.client.post( @@ -1715,45 +1423,17 @@ class TeamsPermissionsTestCase(ForumsEnableMixin, UrlResetMixin, SharedModuleSto @ddt.data(*ddt_permissions_args) @ddt.unpack - def test_comment_actions(self, user, commentable_id, status_code, mock_request): - """ - Verify that voting and flagging of comments is limited to members of the team or users with - 'edit_content' permission. - """ - commentable_id = getattr(self, commentable_id) - self._setup_mock( - user, mock_request, - { - "closed": False, - "commentable_id": commentable_id, - "thread_id": "dummy_thread", - "body": 'dummy body', - "course_id": str(self.course.id) - }, - ) - for action in ["upvote_comment", "downvote_comment", "un_flag_abuse_for_comment", "flag_abuse_for_comment"]: - response = self.client.post( - reverse( - action, - kwargs={"course_id": str(self.course.id), "comment_id": "dummy_comment"} - ) - ) - assert response.status_code == status_code - - @ddt.data(*ddt_permissions_args) - @ddt.unpack - def test_threads_actions(self, user, commentable_id, status_code, mock_request): + def test_threads_actions(self, user, commentable_id, status_code, mock_is_forum_v2_enabled, mock_request): """ Verify that voting, flagging, and following of threads is limited to members of the team or users with 'edit_content' permission. """ commentable_id = getattr(self, commentable_id) self._setup_mock( - user, mock_request, + user, mock_is_forum_v2_enabled, mock_request, {"closed": False, "commentable_id": commentable_id, "body": "dummy body", "course_id": str(self.course.id)} ) - for action in ["upvote_thread", "downvote_thread", "un_flag_abuse_for_thread", "flag_abuse_for_thread", - "follow_thread", "unfollow_thread"]: + for action in ["follow_thread", "unfollow_thread"]: response = self.client.post( reverse( action, @@ -1772,6 +1452,19 @@ class ForumEventTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockReque """ Forum actions are expected to launch analytics events. Test these here. """ + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + @classmethod def setUpClass(cls): # pylint: disable=super-method-not-called @@ -1791,12 +1484,14 @@ class ForumEventTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockReque @patch('eventtracking.tracker.emit') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_response_event(self, mock_request, mock_emit): + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_response_event(self, mock_is_forum_v2_enabled, mock_request, mock_emit): """ Check to make sure an event is fired when a user responds to a thread. """ event_receiver = Mock() FORUM_THREAD_RESPONSE_CREATED.connect(event_receiver) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "closed": False, "commentable_id": 'test_commentable_id', @@ -1833,12 +1528,14 @@ class ForumEventTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockReque @patch('eventtracking.tracker.emit') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_comment_event(self, mock_request, mock_emit): + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_comment_event(self, mock_is_forum_v2_enabled, mock_request, mock_emit): """ Ensure an event is fired when someone comments on a response. """ event_receiver = Mock() FORUM_RESPONSE_COMMENT_CREATED.connect(event_receiver) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "closed": False, "depth": 1, @@ -1875,6 +1572,7 @@ class ForumEventTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockReque @patch('eventtracking.tracker.emit') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) @ddt.data(( 'create_thread', 'edx.forum.thread.created', { @@ -1896,7 +1594,7 @@ class ForumEventTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockReque {'comment_id': 'dummy_comment_id'} )) @ddt.unpack - def test_team_events(self, view_name, event_name, view_data, view_kwargs, mock_request, mock_emit): + def test_team_events(self, view_name, event_name, view_data, view_kwargs, mock_is_forum_v2_enabled, mock_request, mock_emit): user = self.student team = CourseTeamFactory.create(discussion_topic_id=TEAM_COMMENTABLE_ID) CourseTeamMembershipFactory.create(team=team, user=user) @@ -1905,6 +1603,7 @@ class ForumEventTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockReque forum_event = views.TRACKING_LOG_TO_EVENT_MAPS.get(event_name) forum_event.connect(event_receiver) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { 'closed': False, 'commentable_id': TEAM_COMMENTABLE_ID, @@ -1934,48 +1633,16 @@ class ForumEventTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockReque event_receiver.call_args.kwargs ) - @ddt.data( - ('vote_for_thread', 'thread_id', 'thread'), - ('undo_vote_for_thread', 'thread_id', 'thread'), - ('vote_for_comment', 'comment_id', 'response'), - ('undo_vote_for_comment', 'comment_id', 'response'), - ) - @ddt.unpack - @patch('eventtracking.tracker.emit') - @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_thread_voted_event(self, view_name, obj_id_name, obj_type, mock_request, mock_emit): - undo = view_name.startswith('undo') - - self._set_mock_request_data(mock_request, { - 'closed': False, - 'commentable_id': 'test_commentable_id', - 'username': 'gumprecht', - }) - request = RequestFactory().post('dummy_url', {}) - request.user = self.student - request.view_name = view_name - view_function = getattr(views, view_name) - kwargs = dict(course_id=str(self.course.id)) - kwargs[obj_id_name] = obj_id_name - if not undo: - kwargs.update(value='up') - view_function(request, **kwargs) - - assert mock_emit.called - event_name, event = mock_emit.call_args[0] - assert event_name == f'edx.forum.{obj_type}.voted' - assert event['target_username'] == 'gumprecht' - assert event['undo_vote'] == undo - assert event['vote_value'] == 'up' - @ddt.data('follow_thread', 'unfollow_thread',) @patch('eventtracking.tracker.emit') @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_thread_followed_event(self, view_name, mock_request, mock_emit): + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_thread_followed_event(self, view_name, mock_is_forum_v2_enabled, mock_request, mock_emit): event_receiver = Mock() for signal in views.TRACKING_LOG_TO_EVENT_MAPS.values(): signal.connect(event_receiver) + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { 'closed': False, 'commentable_id': 'test_commentable_id', @@ -2025,10 +1692,11 @@ class UsersEndpointTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockRe cls.other_user = UserFactory.create(username="other") CourseEnrollmentFactory(user=cls.other_user, course_id=cls.course.id) - def set_post_counts(self, mock_request, threads_count=1, comments_count=1): + def set_post_counts(self, mock_is_forum_v2_enabled, mock_request, threads_count=1, comments_count=1): """ sets up a mock response from the comments service for getting post counts for our other_user """ + mock_is_forum_v2_enabled.return_value = False self._set_mock_request_data(mock_request, { "threads_count": threads_count, "comments_count": comments_count, @@ -2042,15 +1710,17 @@ class UsersEndpointTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockRe return views.users(request, course_id=str(course_id)) @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_finds_exact_match(self, mock_request): - self.set_post_counts(mock_request) + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_finds_exact_match(self, mock_is_forum_v2_enabled, mock_request): + self.set_post_counts(mock_is_forum_v2_enabled, mock_request) response = self.make_request(username="other") assert response.status_code == 200 assert json.loads(response.content.decode('utf-8'))['users'] == [{'id': self.other_user.id, 'username': self.other_user.username}] @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_finds_no_match(self, mock_request): - self.set_post_counts(mock_request) + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_finds_no_match(self, mock_is_forum_v2_enabled, mock_request): + self.set_post_counts(mock_is_forum_v2_enabled, mock_request) response = self.make_request(username="othor") assert response.status_code == 200 assert json.loads(response.content.decode('utf-8'))['users'] == [] @@ -2086,8 +1756,9 @@ class UsersEndpointTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockRe assert 'users' not in content @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def test_requires_matched_user_has_forum_content(self, mock_request): - self.set_post_counts(mock_request, 0, 0) + @patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) + def test_requires_matched_user_has_forum_content(self, mock_is_forum_v2_enabled, mock_request): + self.set_post_counts(mock_is_forum_v2_enabled, mock_request, 0, 0) response = self.make_request(username="other") assert response.status_code == 200 assert json.loads(response.content.decode('utf-8'))['users'] == [] diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py new file mode 100644 index 0000000000..0d1faa55d7 --- /dev/null +++ b/lms/djangoapps/discussion/django_comment_client/base/tests_v2.py @@ -0,0 +1,1040 @@ +# pylint: skip-file +"""Tests for django comment client views.""" + +import json +import logging +from contextlib import contextmanager +from unittest import mock +from unittest.mock import ANY, Mock, patch + +import ddt +from django.contrib.auth.models import User +from django.core.management import call_command +from django.test.client import RequestFactory +from django.urls import reverse +from eventtracking.processors.exceptions import EventEmissionExit +from opaque_keys.edx.keys import CourseKey +from openedx_events.learning.signals import ( + FORUM_THREAD_CREATED, + FORUM_THREAD_RESPONSE_CREATED, + FORUM_RESPONSE_COMMENT_CREATED, +) + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.tests.factories import CourseModeFactory +from common.djangoapps.student.roles import CourseStaffRole, UserBasedRole +from common.djangoapps.student.tests.factories import ( + CourseAccessRoleFactory, + CourseEnrollmentFactory, + UserFactory, +) +from common.djangoapps.track.middleware import TrackMiddleware +from common.djangoapps.track.views import segmentio +from common.djangoapps.track.views.tests.base import ( + SEGMENTIO_TEST_USER_ID, + SegmentIOTrackingTestCaseBase, +) +from common.djangoapps.util.testing import UrlResetMixin +from common.test.utils import MockSignalHandlerMixin, disable_signal +from lms.djangoapps.discussion.django_comment_client.base import views +from lms.djangoapps.discussion.django_comment_client.tests.group_id import ( + CohortedTopicGroupIdTestMixin, + GroupIdAssertionMixin, + NonCohortedTopicGroupIdTestMixin, +) +from lms.djangoapps.discussion.django_comment_client.tests.unicode import ( + UnicodeTestMixin, +) +from lms.djangoapps.discussion.django_comment_client.tests.utils import ( + CohortedTestCase, + ForumsEnableMixin, +) +from lms.djangoapps.teams.tests.factories import ( + CourseTeamFactory, + CourseTeamMembershipFactory, +) +from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted +from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory +from openedx.core.djangoapps.django_comment_common.comment_client import Thread +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_STUDENT, + CourseDiscussionSettings, + Role, + assign_role, +) +from openedx.core.djangoapps.django_comment_common.utils import ( + ThreadContext, + seed_permissions_roles, +) +from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES +from openedx.core.lib.teams_config import TeamsConfig +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ( + TEST_DATA_SPLIT_MODULESTORE, + ModuleStoreTestCase, + SharedModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import ( + CourseFactory, + BlockFactory, + check_mongo_calls, +) + +from .event_transformers import ForumThreadViewedEventTransformer +from lms.djangoapps.discussion.django_comment_client.tests.mixins import ( + MockForumApiMixin, +) + +from lms.djangoapps.discussion.tests.utils import ( + make_minimal_cs_thread, + make_minimal_cs_comment, +) + + +@disable_signal(views, "thread_edited") +@disable_signal(views, "thread_voted") +@disable_signal(views, "thread_deleted") +class ThreadActionGroupIdTestCase( + CohortedTestCase, GroupIdAssertionMixin, MockForumApiMixin +): + """Test case for thread actions with group ID assertions.""" + + @classmethod + def setUpClass(cls): + """Set up class and forum mock.""" + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + def call_view( + self, view_name, mock_function, user=None, post_params=None, view_args=None + ): + """Call a view with the given parameters.""" + thread_response = make_minimal_cs_thread( + {"user_id": str(self.student.id), "group_id": self.student_cohort.id} + ) + + self.set_mock_return_value("get_course_id_by_thread", str(self.course.id)) + self.set_mock_return_value("get_thread", thread_response) + self.set_mock_return_value(mock_function, thread_response) + + request = RequestFactory().post("dummy_url", post_params or {}) + request.user = user or self.student + request.view_name = view_name + + return getattr(views, view_name)( + request, + course_id=str(self.course.id), + thread_id="dummy", + **(view_args or {}), + ) + + def test_flag(self): + with mock.patch( + "openedx.core.djangoapps.django_comment_common.signals.thread_flagged.send" + ) as signal_mock: + response = self.call_view("flag_abuse_for_thread", "update_thread_flag") + self._assert_json_response_contains_group_info(response) + self.assertEqual(signal_mock.call_count, 1) + response = self.call_view("un_flag_abuse_for_thread", "update_thread_flag") + self._assert_json_response_contains_group_info(response) + + def test_pin_thread(self): + """Test pinning a thread.""" + response = self.call_view("pin_thread", "pin_thread", user=self.moderator) + assert response.status_code == 200 + self._assert_json_response_contains_group_info(response) + + response = self.call_view("un_pin_thread", "unpin_thread", user=self.moderator) + assert response.status_code == 200 + self._assert_json_response_contains_group_info(response) + + def test_vote(self): + response = self.call_view( + "vote_for_thread", "update_thread_votes", view_args={"value": "up"} + ) + self._assert_json_response_contains_group_info(response) + response = self.call_view("undo_vote_for_thread", "delete_thread_vote") + self._assert_json_response_contains_group_info(response) + + +class ViewsTestCaseMixin: + + def set_up_course(self, block_count=0): + """ + Creates a course, optionally with block_count discussion blocks, and + a user with appropriate permissions. + """ + + # create a course + self.course = CourseFactory.create( + org="MITx", + course="999", + discussion_topics={"Some Topic": {"id": "some_topic"}}, + display_name="Robot Super Course", + ) + self.course_id = self.course.id + + # add some discussion blocks + for i in range(block_count): + BlockFactory.create( + parent_location=self.course.location, + category="discussion", + discussion_id=f"id_module_{i}", + discussion_category=f"Category {i}", + discussion_target=f"Discussion {i}", + ) + + # seed the forums permissions and roles + call_command("seed_permissions_roles", str(self.course_id)) + + # Patch the comment client user save method so it does not try + # to create a new cc user when creating a django user + with patch("common.djangoapps.student.models.user.cc.User.save"): + uname = "student" + email = "student@edx.org" + self.password = "Password1234" + + # Create the user and make them active so we can log them in. + self.student = UserFactory.create( + username=uname, email=email, password=self.password + ) + self.student.is_active = True + self.student.save() + + # Add a discussion moderator + self.moderator = UserFactory.create(password=self.password) + + # Enroll the student in the course + CourseEnrollmentFactory(user=self.student, course_id=self.course_id) + + # Enroll the moderator and give them the appropriate roles + CourseEnrollmentFactory(user=self.moderator, course_id=self.course.id) + self.moderator.roles.add( + Role.objects.get(name="Moderator", course_id=self.course.id) + ) + + assert self.client.login(username="student", password=self.password) + + def _setup_mock_request(self, mock_function, include_depth=False): + """ + Ensure that mock_request returns the data necessary to make views + function correctly + """ + data = { + "user_id": str(self.student.id), + "closed": False, + "commentable_id": "non_team_dummy_id", + "thread_id": "dummy", + "thread_type": "discussion", + } + if include_depth: + data["depth"] = 0 + self.set_mock_return_value(mock_function, data) + + +@ddt.ddt +@disable_signal(views, "comment_flagged") +@disable_signal(views, "thread_flagged") +class ViewsTestCase( + ForumsEnableMixin, + MockForumApiMixin, + UrlResetMixin, + SharedModuleStoreTestCase, + ViewsTestCaseMixin, + MockSignalHandlerMixin, +): + + @classmethod + def setUpClass(cls): + # pylint: disable=super-method-not-called + super().setUpClassAndForumMock() + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create( + org="MITx", + course="999", + discussion_topics={"Some Topic": {"id": "some_topic"}}, + display_name="Robot Super Course", + ) + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.course_id = cls.course.id + + # seed the forums permissions and roles + call_command("seed_permissions_roles", str(cls.course_id)) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + # Patching the ENABLE_DISCUSSION_SERVICE value affects the contents of urls.py, + # so we need to call super.setUp() which reloads urls.py (because + # of the UrlResetMixin) + super().setUp() + # Patch the comment client user save method so it does not try + # to create a new cc user when creating a django user + with patch("common.djangoapps.student.models.user.cc.User.save"): + uname = "student" + email = "student@edx.org" + self.password = "Password1234" + + # Create the user and make them active so we can log them in. + self.student = UserFactory.create( + username=uname, email=email, password=self.password + ) + self.student.is_active = True + self.student.save() + + # Add a discussion moderator + self.moderator = UserFactory.create(password=self.password) + + # Enroll the student in the course + CourseEnrollmentFactory(user=self.student, course_id=self.course_id) + + # Enroll the moderator and give them the appropriate roles + CourseEnrollmentFactory(user=self.moderator, course_id=self.course.id) + self.moderator.roles.add( + Role.objects.get(name="Moderator", course_id=self.course.id) + ) + + assert self.client.login(username="student", password=self.password) + + self.set_mock_return_value("get_course_id_by_thread", str(self.course.id)) + self.set_mock_return_value("get_course_id_by_comment", str(self.course.id)) + + @contextmanager + def assert_discussion_signals(self, signal, user=None): + if user is None: + user = self.student + with self.assert_signal_sent( + views, signal, sender=None, user=user, exclude_args=("post",) + ): + yield + + def test_flag_thread_open(self): + self.flag_thread(False) + + def test_flag_thread_close(self): + self.flag_thread(True) + + def flag_thread(self, is_closed): + thread_data = make_minimal_cs_thread( + { + "id": "518d4237b023791dca00000d", + "course_id": str(self.course_id), + "closed": is_closed, + "user_id": "1", + "username": "robot", + "abuse_flaggers": [1], + } + ) + self.set_mock_return_value("get_thread", thread_data) + self.set_mock_return_value("update_thread_flag", thread_data) + url = reverse( + "flag_abuse_for_thread", + kwargs={ + "thread_id": "518d4237b023791dca00000d", + "course_id": str(self.course_id), + }, + ) + response = self.client.post(url) + self.check_mock_called("update_thread_flag") + + self.check_mock_called_with( + "get_thread", + 0, + thread_id="518d4237b023791dca00000d", + params={ + "mark_as_read": True, + "with_responses": False, + "reverse_order": False, + "merge_question_type_responses": False, + }, + course_id=str(self.course_id), + ) + + self.check_mock_called_with( + "update_thread_flag", + 0, + thread_id="518d4237b023791dca00000d", + action="flag", + user_id=ANY, + course_id=str(self.course_id), + ) + + self.check_mock_called_with( + "get_thread", + 1, + thread_id="518d4237b023791dca00000d", + params={ + "mark_as_read": True, + "with_responses": False, + "reverse_order": False, + "merge_question_type_responses": False, + }, + course_id=str(self.course_id), + ) + + assert response.status_code == 200 + + def test_un_flag_thread_open(self): + self.un_flag_thread(False) + + def test_un_flag_thread_close(self): + self.un_flag_thread(True) + + def un_flag_thread(self, is_closed): + thread_data = make_minimal_cs_thread( + { + "id": "518d4237b023791dca00000d", + "course_id": str(self.course_id), + "closed": is_closed, + "user_id": "1", + "username": "robot", + "abuse_flaggers": [1], + } + ) + + self.set_mock_return_value("get_thread", thread_data) + self.set_mock_return_value("update_thread_flag", thread_data) + url = reverse( + "un_flag_abuse_for_thread", + kwargs={ + "thread_id": "518d4237b023791dca00000d", + "course_id": str(self.course_id), + }, + ) + response = self.client.post(url) + self.check_mock_called("update_thread_flag") + + self.check_mock_called_with( + "get_thread", + 0, + thread_id="518d4237b023791dca00000d", + params={ + "mark_as_read": True, + "with_responses": False, + "reverse_order": False, + "merge_question_type_responses": False, + }, + course_id=str(self.course_id), + ) + + self.check_mock_called_with( + "update_thread_flag", + 0, + thread_id="518d4237b023791dca00000d", + action="unflag", + user_id=ANY, + update_all=False, + course_id=str(self.course_id), + ) + + self.check_mock_called_with( + "get_thread", + 1, + thread_id="518d4237b023791dca00000d", + params={ + "mark_as_read": True, + "with_responses": False, + "reverse_order": False, + "merge_question_type_responses": False, + }, + course_id=str(self.course_id), + ) + + assert response.status_code == 200 + + def test_flag_comment_open(self): + self.flag_comment(False) + + def test_flag_comment_close(self): + self.flag_comment(True) + + def flag_comment(self, is_closed): + comment_data = make_minimal_cs_comment( + { + "id": "518d4237b023791dca00000d", + "body": "this is a comment", + "course_id": str(self.course_id), + "closed": is_closed, + "user_id": "1", + "username": "robot", + "abuse_flaggers": [1], + } + ) + + self.set_mock_return_value("get_parent_comment", comment_data) + self.set_mock_return_value("update_comment_flag", comment_data) + url = reverse( + "flag_abuse_for_comment", + kwargs={ + "comment_id": "518d4237b023791dca00000d", + "course_id": str(self.course_id), + }, + ) + + response = self.client.post(url) + self.check_mock_called("update_thread_flag") + + self.check_mock_called_with( + "get_parent_comment", + 0, + comment_id="518d4237b023791dca00000d", + course_id=str(self.course_id), + ) + + self.check_mock_called_with( + "update_comment_flag", + 0, + comment_id="518d4237b023791dca00000d", + action="flag", + user_id=ANY, + course_id=str(self.course_id), + ) + + self.check_mock_called_with( + "get_parent_comment", + 1, + comment_id="518d4237b023791dca00000d", + course_id=str(self.course_id), + ) + + assert response.status_code == 200 + + def test_un_flag_comment_open(self): + self.un_flag_comment(False) + + def test_un_flag_comment_close(self): + self.un_flag_comment(True) + + def un_flag_comment(self, is_closed): + comment_data = make_minimal_cs_comment( + { + "id": "518d4237b023791dca00000d", + "body": "this is a comment", + "course_id": str(self.course_id), + "closed": is_closed, + "user_id": "1", + "username": "robot", + "abuse_flaggers": [], + } + ) + + self.set_mock_return_value("get_parent_comment", comment_data) + self.set_mock_return_value("update_comment_flag", comment_data) + url = reverse( + "un_flag_abuse_for_comment", + kwargs={ + "comment_id": "518d4237b023791dca00000d", + "course_id": str(self.course_id), + }, + ) + + response = self.client.post(url) + self.check_mock_called("update_thread_flag") + + self.check_mock_called_with( + "get_parent_comment", + 0, + comment_id="518d4237b023791dca00000d", + course_id=str(self.course_id), + ) + + self.check_mock_called_with( + "update_comment_flag", + 0, + comment_id="518d4237b023791dca00000d", + action="unflag", + update_all=False, + user_id=ANY, + course_id=str(self.course_id), + ) + + self.check_mock_called_with( + "get_parent_comment", + 1, + comment_id="518d4237b023791dca00000d", + course_id=str(self.course_id), + ) + + assert response.status_code == 200 + + @ddt.data( + ("upvote_thread", "update_thread_votes", "thread_id", "thread_voted"), + ("upvote_comment", "update_comment_votes", "comment_id", "comment_voted"), + ("downvote_thread", "update_thread_votes", "thread_id", "thread_voted"), + ("downvote_comment", "update_comment_votes", "comment_id", "comment_voted"), + ) + @ddt.unpack + def test_voting(self, view_name, function_name, item_id, signal): + self._setup_mock_request("get_thread") + self._setup_mock_request("get_parent_comment") + self._setup_mock_request(function_name) + with self.assert_discussion_signals(signal): + response = self.client.post( + reverse( + view_name, + kwargs={item_id: "dummy", "course_id": str(self.course_id)}, + ) + ) + assert response.status_code == 200 + + +@disable_signal(views, "comment_endorsed") +class ViewPermissionsTestCase( + ForumsEnableMixin, + UrlResetMixin, + SharedModuleStoreTestCase, + MockForumApiMixin, +): + """Test case for view permissions.""" + + @classmethod + def setUpClass(cls): # pylint: disable=super-method-not-called + """Set up class and forum mock.""" + super().setUpClassAndForumMock() + + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + """Set up test data.""" + super().setUpTestData() + + seed_permissions_roles(cls.course.id) + + cls.password = "test password" + cls.student = UserFactory.create(password=cls.password) + cls.moderator = UserFactory.create(password=cls.password) + + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + CourseEnrollmentFactory(user=cls.moderator, course_id=cls.course.id) + + cls.moderator.roles.add( + Role.objects.get(name="Moderator", course_id=cls.course.id) + ) + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + """Set up the test case.""" + super().setUp() + + # Set return values dynamically using the mixin method + self.set_mock_return_value("get_course_id_by_comment", self.course.id) + self.set_mock_return_value("get_course_id_by_thread", self.course.id) + self.set_mock_return_value("get_thread", {}) + self.set_mock_return_value("pin_thread", {}) + self.set_mock_return_value("unpin_thread", {}) + + def test_pin_thread_as_student(self): + """Test pinning a thread as a student.""" + self.client.login(username=self.student.username, password=self.password) + response = self.client.post( + reverse( + "pin_thread", + kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}, + ) + ) + assert response.status_code == 401 + + def test_pin_thread_as_moderator(self): + """Test pinning a thread as a moderator.""" + self.client.login(username=self.moderator.username, password=self.password) + response = self.client.post( + reverse( + "pin_thread", + kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}, + ) + ) + assert response.status_code == 200 + + def test_un_pin_thread_as_student(self): + """Test unpinning a thread as a student.""" + self.client.login(username=self.student.username, password=self.password) + response = self.client.post( + reverse( + "un_pin_thread", + kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}, + ) + ) + assert response.status_code == 401 + + def test_un_pin_thread_as_moderator(self): + """Test unpinning a thread as a moderator.""" + self.client.login(username=self.moderator.username, password=self.password) + response = self.client.post( + reverse( + "un_pin_thread", + kwargs={"course_id": str(self.course.id), "thread_id": "dummy"}, + ) + ) + assert response.status_code == 200 + + +class CommentActionTestCase(CohortedTestCase, MockForumApiMixin): + """Test case for thread actions with group ID assertions.""" + + @classmethod + def setUpClass(cls): + """Set up class and forum mock.""" + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + def call_view( + self, view_name, mock_function, user=None, post_params=None, view_args=None + ): + """Call a view with the given parameters.""" + comment_response = make_minimal_cs_comment( + {"user_id": str(self.student.id), "group_id": self.student_cohort.id} + ) + + self.set_mock_return_value("get_course_id_by_comment", str(self.course.id)) + self.set_mock_return_value("get_parent_comment", comment_response) + self.set_mock_return_value(mock_function, comment_response) + + request = RequestFactory().post("dummy_url", post_params or {}) + request.user = user or self.student + request.view_name = view_name + + return getattr(views, view_name)( + request, + course_id=str(self.course.id), + comment_id="dummy", + **(view_args or {}), + ) + + def test_flag(self): + with mock.patch( + "openedx.core.djangoapps.django_comment_common.signals.comment_flagged.send" + ) as signal_mock: + self.call_view("flag_abuse_for_comment", "update_comment_flag") + self.assertEqual(signal_mock.call_count, 1) + + +@ddt.ddt +@disable_signal(views, "thread_voted") +@disable_signal(views, "thread_edited") +@disable_signal(views, "comment_created") +@disable_signal(views, "comment_voted") +@disable_signal(views, "comment_deleted") +@disable_signal(views, "comment_flagged") +@disable_signal(views, "thread_flagged") +class TeamsPermissionsTestCase( + UrlResetMixin, SharedModuleStoreTestCase, MockForumApiMixin +): + # Most of the test points use the same ddt data. + # args: user, commentable_id, status_code + ddt_permissions_args = [ + # Student in team can do operations on threads/comments within the team commentable. + ("student_in_team", "team_commentable_id", 200), + # Non-team commentables can be edited by any student. + ("student_in_team", "course_commentable_id", 200), + # Student not in team cannot do operations within the team commentable. + ("student_not_in_team", "team_commentable_id", 401), + # Non-team commentables can be edited by any student. + ("student_not_in_team", "course_commentable_id", 200), + # Moderators can always operator on threads within a team, regardless of team membership. + ("moderator", "team_commentable_id", 200), + # Group moderators have regular student privileges for creating a thread and commenting + ("group_moderator", "course_commentable_id", 200), + ] + + def change_divided_discussion_settings(self, scheme): + """ + Change divided discussion settings for the current course. + If dividing by cohorts, create and assign users to a cohort. + """ + enable_cohorts = True if scheme is CourseDiscussionSettings.COHORT else False + discussion_settings = CourseDiscussionSettings.get(self.course.id) + discussion_settings.update( + { + "enable_cohorts": enable_cohorts, + "divided_discussions": [], + "always_divide_inline_discussions": True, + "division_scheme": scheme, + } + ) + set_course_cohorted(self.course.id, enable_cohorts) + + @classmethod + def setUpClass(cls): + super().setUpClassAndForumMock() + # pylint: disable=super-method-not-called + with super().setUpClassAndTestData(): + teams_config_data = { + "topics": [ + { + "id": "topic_id", + "name": "Solar Power", + "description": "Solar power is hot", + } + ] + } + cls.course = CourseFactory.create( + teams_configuration=TeamsConfig(teams_config_data) + ) + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + cls.password = "test password" + seed_permissions_roles(cls.course.id) + + # Create enrollment tracks + CourseModeFactory.create(course_id=cls.course.id, mode_slug=CourseMode.VERIFIED) + CourseModeFactory.create(course_id=cls.course.id, mode_slug=CourseMode.AUDIT) + + # Create 6 users-- + # student in team (in the team, audit) + # student not in team (not in the team, audit) + # cohorted (in the cohort, audit) + # verified (not in the cohort, verified) + # moderator (in the cohort, audit, moderator permissions) + # group moderator (in the cohort, verified, group moderator permissions) + def create_users_and_enroll(coursemode): + student = UserFactory.create(password=cls.password) + CourseEnrollmentFactory( + course_id=cls.course.id, user=student, mode=coursemode + ) + return student + + cls.student_in_team, cls.student_not_in_team, cls.moderator, cls.cohorted = [ + create_users_and_enroll(CourseMode.AUDIT) for _ in range(4) + ] + cls.verified, cls.group_moderator = [ + create_users_and_enroll(CourseMode.VERIFIED) for _ in range(2) + ] + + # Give moderator and group moderator permissions + cls.moderator.roles.add( + Role.objects.get(name="Moderator", course_id=cls.course.id) + ) + assign_role(cls.course.id, cls.group_moderator, "Group Moderator") + + # Create a team + cls.team_commentable_id = "team_discussion_id" + cls.team = CourseTeamFactory.create( + name="The Only Team", + course_id=cls.course.id, + topic_id="topic_id", + discussion_topic_id=cls.team_commentable_id, + ) + CourseTeamMembershipFactory.create(team=cls.team, user=cls.student_in_team) + + # Dummy commentable ID not linked to a team + cls.course_commentable_id = "course_level_commentable" + + # Create cohort and add students to it + CohortFactory( + course_id=cls.course.id, + name="Test Cohort", + users=[cls.group_moderator, cls.cohorted], + ) + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + + def _setup_mock(self, user, mock_functions=[], data=None): + user = getattr(self, user) + mock_functions = mock_functions or [] + for mock_func in mock_functions: + self.set_mock_return_value(mock_func, data or {}) + self.client.login(username=user.username, password=self.password) + + @ddt.data(*ddt_permissions_args) + @ddt.unpack + def test_comment_actions(self, user, commentable_id, status_code): + """ + Verify that voting and flagging of comments is limited to members of the team or users with + 'edit_content' permission. + """ + commentable_id = getattr(self, commentable_id) + self._setup_mock( + user, + [ + "get_parent_comment", + "update_comment_flag", + "update_comment_votes", + "delete_comment_vote", + ], + make_minimal_cs_comment( + { + "closed": False, + "commentable_id": commentable_id, + "course_id": str(self.course.id), + } + ), + ) + # "un_flag_abuse_for_comment", "flag_abuse_for_comment", + for action in ["upvote_comment", "downvote_comment"]: + response = self.client.post( + reverse( + action, + kwargs={ + "course_id": str(self.course.id), + "comment_id": "dummy", + }, + ) + ) + assert response.status_code == status_code + + @ddt.data(*ddt_permissions_args) + @ddt.unpack + def test_threads_actions(self, user, commentable_id, status_code): + """ + Verify that voting, flagging, and following of threads is limited to members of the team or users with + 'edit_content' permission. + """ + commentable_id = getattr(self, commentable_id) + self._setup_mock( + user, + [ + "get_thread", + "update_thread_flag", + "update_thread_votes", + "delete_thread_vote", + ], + make_minimal_cs_thread( + { + "commentable_id": commentable_id, + "course_id": str(self.course.id), + } + ), + ) + + for action in [ + "un_flag_abuse_for_thread", + "flag_abuse_for_thread", + "upvote_thread", + "downvote_thread", + ]: + response = self.client.post( + reverse( + action, + kwargs={ + "course_id": str(self.course.id), + "thread_id": "dummy", + }, + ) + ) + assert response.status_code == status_code + + +@disable_signal(views, "comment_created") +@ddt.ddt +class ForumEventTestCase( + ForumsEnableMixin, SharedModuleStoreTestCase, MockForumApiMixin +): + """ + Forum actions are expected to launch analytics events. Test these here. + """ + + @classmethod + def setUpClass(cls): + super().setUpClassAndForumMock() + # pylint: disable=super-method-not-called + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + seed_permissions_roles(cls.course.id) + + cls.student = UserFactory.create() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + cls.student.roles.add(Role.objects.get(name="Student", course_id=cls.course.id)) + CourseAccessRoleFactory( + course_id=cls.course.id, user=cls.student, role="Wizard" + ) + + @ddt.data( + ("vote_for_thread", "update_thread_votes", "thread_id", "thread"), + ("undo_vote_for_thread", "delete_thread_vote", "thread_id", "thread"), + ("vote_for_comment", "update_comment_votes", "comment_id", "response"), + ("undo_vote_for_comment", "delete_comment_vote", "comment_id", "response"), + ) + @ddt.unpack + @patch("eventtracking.tracker.emit") + def test_thread_voted_event( + self, view_name, function_name, obj_id_name, obj_type, mock_emit + ): + undo = view_name.startswith("undo") + cs_thread = make_minimal_cs_thread( + { + "commentable_id": "test_commentable_id", + "username": "gumprecht", + } + ) + cs_comment = make_minimal_cs_comment( + { + "closed": False, + "commentable_id": "test_commentable_id", + "username": "gumprecht", + } + ) + self.set_mock_return_value("get_thread", cs_thread) + self.set_mock_return_value("get_parent_comment", cs_comment) + self.set_mock_return_value( + function_name, cs_thread if "thread" in view_name else cs_comment + ) + + request = RequestFactory().post("dummy_url", {}) + request.user = self.student + request.view_name = view_name + view_function = getattr(views, view_name) + kwargs = dict(course_id=str(self.course.id)) + kwargs[obj_id_name] = obj_id_name + if not undo: + kwargs.update(value="up") + view_function(request, **kwargs) + + assert mock_emit.called + event_name, event = mock_emit.call_args[0] + assert event_name == f"edx.forum.{obj_type}.voted" + assert event["target_username"] == "gumprecht" + assert event["undo_vote"] == undo + assert event["vote_value"] == "up" diff --git a/lms/djangoapps/discussion/django_comment_client/base/views.py b/lms/djangoapps/discussion/django_comment_client/base/views.py index e3e52a5400..b81a8fd144 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/views.py +++ b/lms/djangoapps/discussion/django_comment_client/base/views.py @@ -113,6 +113,14 @@ def track_forum_event(request, event_name, course, obj, data, id_map=None): forum_event = TRACKING_LOG_TO_EVENT_MAPS.get(event_name, None) if forum_event is not None: + # .. event_implemented_name: FORUM_THREAD_CREATED + # .. event_type: org.openedx.learning.forum.thread.created.v1 + + # .. event_implemented_name: FORUM_THREAD_RESPONSE_CREATED + # .. event_type: org.openedx.learning.forum.thread.response.created.v1 + + # .. event_implemented_name: FORUM_RESPONSE_COMMENT_CREATED + # .. event_type: org.openedx.learning.forum.thread.response.comment.created.v1 forum_event.send_event( thread=DiscussionThreadData( anonymous=data.get('anonymous'), @@ -161,7 +169,7 @@ def add_truncated_title_to_event_data(event_data, full_title): event_data['title'] = full_title[:TRACKING_MAX_FORUM_TITLE] -def track_thread_created_event(request, course, thread, followed, from_mfe_sidebar=False): +def track_thread_created_event(request, course, thread, followed, from_mfe_sidebar=False, notify_all_learners=False): """ Send analytics event for a newly created thread. """ @@ -172,7 +180,10 @@ def track_thread_created_event(request, course, thread, followed, from_mfe_sideb 'thread_type': thread.thread_type, 'anonymous': thread.anonymous, 'anonymous_to_peers': thread.anonymous_to_peers, - 'options': {'followed': followed}, + 'options': { + 'followed': followed, + 'notify_all_learners': notify_all_learners + }, 'from_mfe_sidebar': from_mfe_sidebar, # There is a stated desire for an 'origin' property that will state # whether this thread was created via courseware or the forum. @@ -562,7 +573,6 @@ def create_thread(request, course_id, commentable_id): params['context'] = ThreadContext.STANDALONE else: params['context'] = ThreadContext.COURSE - thread = cc.Thread(**params) # Divide the thread if required @@ -585,7 +595,7 @@ def create_thread(request, course_id, commentable_id): if follow: cc_user = cc.User.from_django_user(user) - cc_user.follow(thread) + cc_user.follow(thread, course_id) thread_followed.send(sender=None, user=user, post=thread) data = thread.to_dict() @@ -674,7 +684,7 @@ def _create_comment(request, course_key, thread_id=None, parent_id=None): parent_id=parent_id, body=sanitize_body(post["body"]), ) - comment.save() + comment.save(params={"course_id": str(course_key)}) comment_created.send(sender=None, user=user, post=comment) @@ -716,7 +726,7 @@ def delete_thread(request, course_id, thread_id): course_key = CourseKey.from_string(course_id) course = get_course_with_access(request.user, 'load', course_key) thread = cc.Thread.find(thread_id) - thread.delete() + thread.delete(course_id=course_id) thread_deleted.send(sender=None, user=request.user, post=thread) track_thread_deleted_event(request, course, thread) @@ -737,7 +747,7 @@ def update_comment(request, course_id, comment_id): if 'body' not in request.POST or not request.POST['body'].strip(): return JsonError(_("Body can't be empty")) comment.body = sanitize_body(request.POST["body"]) - comment.save() + comment.save(params={"course_id": course_id}) comment_edited.send(sender=None, user=request.user, post=comment) @@ -763,7 +773,7 @@ def endorse_comment(request, course_id, comment_id): endorsed = request.POST.get('endorsed', 'false').lower() == 'true' comment.endorsed = endorsed comment.endorsement_user_id = user.id - comment.save() + comment.save(params={"course_id": course_id}) comment_endorsed.send(sender=None, user=user, post=comment) track_forum_response_mark_event(request, course, comment, endorsed) return JsonResponse(prepare_content(comment.to_dict(), course_key)) @@ -782,7 +792,7 @@ def openclose_thread(request, course_id, thread_id): thread = cc.Thread.find(thread_id) close_thread = request.POST.get('closed', 'false').lower() == 'true' thread.closed = close_thread - thread.save() + thread.save(params={"course_id": course_id}) track_thread_lock_unlock_event(request, course, thread, None, close_thread) return JsonResponse({ @@ -815,7 +825,7 @@ def delete_comment(request, course_id, comment_id): course_key = CourseKey.from_string(course_id) course = get_course_with_access(request.user, 'load', course_key) comment = cc.Comment.find(comment_id) - comment.delete() + comment.delete(course_id=course_id) comment_deleted.send(sender=None, user=request.user, post=comment) track_comment_deleted_event(request, course, comment) return JsonResponse(prepare_content(comment.to_dict(), course_key)) @@ -829,12 +839,12 @@ def _vote_or_unvote(request, course_id, obj, value='up', undo_vote=False): course = get_course_with_access(request.user, 'load', course_key) user = cc.User.from_django_user(request.user) if undo_vote: - user.unvote(obj) + user.unvote(obj, course_id) # TODO(smarnach): Determine the value of the vote that is undone. Currently, you can # only cast upvotes in the user interface, so it is assumed that the vote value is 'up'. # (People could theoretically downvote by handcrafting AJAX requests.) else: - user.vote(obj, value) + user.vote(obj, value, course_id) thread_voted.send(sender=None, user=request.user, post=obj) track_voted_event(request, course, obj, value, undo_vote) return JsonResponse(prepare_content(obj.to_dict(), course_key)) @@ -900,7 +910,7 @@ def flag_abuse_for_thread(request, course_id, thread_id): user = cc.User.from_django_user(request.user) course = get_course_by_id(course_key) thread = cc.Thread.find(thread_id) - thread.flagAbuse(user, thread) + thread.flagAbuse(user, thread, course_id) track_discussion_reported_event(request, course, thread) thread_flagged.send(sender='flag_abuse_for_thread', user=request.user, post=thread) return JsonResponse(prepare_content(thread.to_dict(), course_key)) @@ -922,7 +932,7 @@ def un_flag_abuse_for_thread(request, course_id, thread_id): has_permission(request.user, 'openclose_thread', course_key) or has_access(request.user, 'staff', course) ) - thread.unFlagAbuse(user, thread, remove_all) + thread.unFlagAbuse(user, thread, remove_all, course_id) track_discussion_unreported_event(request, course, thread) return JsonResponse(prepare_content(thread.to_dict(), course_key)) @@ -939,7 +949,7 @@ def flag_abuse_for_comment(request, course_id, comment_id): user = cc.User.from_django_user(request.user) course = get_course_by_id(course_key) comment = cc.Comment.find(comment_id) - comment.flagAbuse(user, comment) + comment.flagAbuse(user, comment, course_id) track_discussion_reported_event(request, course, comment) comment_flagged.send(sender='flag_abuse_for_comment', user=request.user, post=comment) return JsonResponse(prepare_content(comment.to_dict(), course_key)) @@ -961,7 +971,7 @@ def un_flag_abuse_for_comment(request, course_id, comment_id): has_access(request.user, 'staff', course) ) comment = cc.Comment.find(comment_id) - comment.unFlagAbuse(user, comment, remove_all) + comment.unFlagAbuse(user, comment, remove_all, course_id) track_discussion_unreported_event(request, course, comment) return JsonResponse(prepare_content(comment.to_dict(), course_key)) @@ -977,7 +987,7 @@ def pin_thread(request, course_id, thread_id): course_key = CourseKey.from_string(course_id) user = cc.User.from_django_user(request.user) thread = cc.Thread.find(thread_id) - thread.pin(user, thread_id) + thread.pin(user, thread_id, course_id) return JsonResponse(prepare_content(thread.to_dict(), course_key)) @@ -993,7 +1003,7 @@ def un_pin_thread(request, course_id, thread_id): course_key = CourseKey.from_string(course_id) user = cc.User.from_django_user(request.user) thread = cc.Thread.find(thread_id) - thread.un_pin(user, thread_id) + thread.un_pin(user, thread_id, course_id) return JsonResponse(prepare_content(thread.to_dict(), course_key)) @@ -1006,7 +1016,7 @@ def follow_thread(request, course_id, thread_id): # lint-amnesty, pylint: disab course_key = CourseKey.from_string(course_id) course = get_course_by_id(course_key) thread = cc.Thread.find(thread_id) - user.follow(thread) + user.follow(thread, course_id=course_id) thread_followed.send(sender=None, user=request.user, post=thread) track_thread_followed_event(request, course, thread, True) return JsonResponse({}) @@ -1022,7 +1032,7 @@ def follow_commentable(request, course_id, commentable_id): # lint-amnesty, pyl """ user = cc.User.from_django_user(request.user) commentable = cc.Commentable.find(commentable_id) - user.follow(commentable) + user.follow(commentable, course_id=course_id) return JsonResponse({}) @@ -1038,7 +1048,7 @@ def unfollow_thread(request, course_id, thread_id): # lint-amnesty, pylint: dis course = get_course_by_id(course_key) user = cc.User.from_django_user(request.user) thread = cc.Thread.find(thread_id) - user.unfollow(thread) + user.unfollow(thread, course_id=course_id) thread_unfollowed.send(sender=None, user=request.user, post=thread) track_thread_followed_event(request, course, thread, False) return JsonResponse({}) @@ -1054,7 +1064,7 @@ def unfollow_commentable(request, course_id, commentable_id): # lint-amnesty, p """ user = cc.User.from_django_user(request.user) commentable = cc.Commentable.find(commentable_id) - user.unfollow(commentable) + user.unfollow(commentable, course_id=course_id) return JsonResponse({}) diff --git a/lms/djangoapps/discussion/django_comment_client/tests/group_id.py b/lms/djangoapps/discussion/django_comment_client/tests/group_id.py index 78853293ec..9907db95bb 100644 --- a/lms/djangoapps/discussion/django_comment_client/tests/group_id.py +++ b/lms/djangoapps/discussion/django_comment_client/tests/group_id.py @@ -60,51 +60,76 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): Provides test cases to verify that views pass the correct `group_id` to the comments service when requesting content in cohorted discussions. """ - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True): + def call_view(self, mock_is_forum_v2_enabled, mock_request, commentable_id, user, group_id, pass_group_id=True): """ Call the view for the implementing test class, constructing a request from the parameters. """ pass # lint-amnesty, pylint: disable=unnecessary-pass - def test_cohorted_topic_student_without_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.student, '', pass_group_id=False) + def test_cohorted_topic_student_without_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, '', pass_group_id=False) self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_student_none_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.student, "") + def test_cohorted_topic_student_none_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, "") self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_student_with_own_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.student, self.student_cohort.id) + def test_cohorted_topic_student_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, self.student_cohort.id) self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_student_with_other_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.student, self.moderator_cohort.id) + def test_cohorted_topic_student_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "cohorted_topic", + self.student, + self.moderator_cohort.id + ) self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_moderator_without_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.moderator, '', pass_group_id=False) + def test_cohorted_topic_moderator_without_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "cohorted_topic", + self.moderator, + '', + pass_group_id=False + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_cohorted_topic_moderator_none_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.moderator, "") + def test_cohorted_topic_moderator_none_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, "") self._assert_comments_service_called_without_group_id(mock_request) - def test_cohorted_topic_moderator_with_own_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.moderator, self.moderator_cohort.id) + def test_cohorted_topic_moderator_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "cohorted_topic", + self.moderator, + self.moderator_cohort.id + ) self._assert_comments_service_called_with_group_id(mock_request, self.moderator_cohort.id) - def test_cohorted_topic_moderator_with_other_group_id(self, mock_request): - self.call_view(mock_request, "cohorted_topic", self.moderator, self.student_cohort.id) + def test_cohorted_topic_moderator_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "cohorted_topic", + self.moderator, + self.student_cohort.id + ) self._assert_comments_service_called_with_group_id(mock_request, self.student_cohort.id) - def test_cohorted_topic_moderator_with_invalid_group_id(self, mock_request): + def test_cohorted_topic_moderator_with_invalid_group_id(self, mock_is_forum_v2_enabled, mock_request): invalid_id = self.student_cohort.id + self.moderator_cohort.id - response = self.call_view(mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return + response = self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return assert response.status_code == 500 - def test_cohorted_topic_enrollment_track_invalid_group_id(self, mock_request): + def test_cohorted_topic_enrollment_track_invalid_group_id(self, mock_is_forum_v2_enabled, mock_request): CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT) CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED) discussion_settings = CourseDiscussionSettings.get(self.course.id) @@ -115,7 +140,7 @@ class CohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): }) invalid_id = -1000 - response = self.call_view(mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return + response = self.call_view(mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return assert response.status_code == 500 @@ -124,57 +149,311 @@ class NonCohortedTopicGroupIdTestMixin(GroupIdAssertionMixin): Provides test cases to verify that views pass the correct `group_id` to the comments service when requesting content in non-cohorted discussions. """ - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True): + def call_view(self, mock_is_forum_v2_enabled, mock_request, commentable_id, user, group_id, pass_group_id=True): """ Call the view for the implementing test class, constructing a request from the parameters. """ pass # lint-amnesty, pylint: disable=unnecessary-pass - def test_non_cohorted_topic_student_without_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.student, '', pass_group_id=False) + def test_non_cohorted_topic_student_without_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.student, + '', + pass_group_id=False + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_student_none_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.student, '') + def test_non_cohorted_topic_student_none_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.student, '') self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_student_with_own_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.student, self.student_cohort.id) + def test_non_cohorted_topic_student_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.student, + self.student_cohort.id + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_student_with_other_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.student, self.moderator_cohort.id) + def test_non_cohorted_topic_student_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.student, + self.moderator_cohort.id + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_without_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.moderator, '', pass_group_id=False) + def test_non_cohorted_topic_moderator_without_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.moderator, + "", + pass_group_id=False, + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_none_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.moderator, '') + def test_non_cohorted_topic_moderator_none_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view(mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.moderator, '') self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_with_own_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.moderator, self.moderator_cohort.id) + def test_non_cohorted_topic_moderator_with_own_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.moderator, + self.moderator_cohort.id, + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_with_other_group_id(self, mock_request): - self.call_view(mock_request, "non_cohorted_topic", self.moderator, self.student_cohort.id) + def test_non_cohorted_topic_moderator_with_other_group_id(self, mock_is_forum_v2_enabled, mock_request): + self.call_view( + mock_is_forum_v2_enabled, + mock_request, + "non_cohorted_topic", + self.moderator, + self.student_cohort.id, + ) self._assert_comments_service_called_without_group_id(mock_request) - def test_non_cohorted_topic_moderator_with_invalid_group_id(self, mock_request): + def test_non_cohorted_topic_moderator_with_invalid_group_id(self, mock_is_forum_v2_enabled, mock_request): invalid_id = self.student_cohort.id + self.moderator_cohort.id - self.call_view(mock_request, "non_cohorted_topic", self.moderator, invalid_id) + self.call_view(mock_is_forum_v2_enabled, mock_request, "non_cohorted_topic", self.moderator, invalid_id) self._assert_comments_service_called_without_group_id(mock_request) - def test_team_discussion_id_not_cohorted(self, mock_request): + def test_team_discussion_id_not_cohorted(self, mock_is_forum_v2_enabled, mock_request): team = CourseTeamFactory( course_id=self.course.id, topic_id='topic-id' ) team.add_user(self.student) - self.call_view(mock_request, team.discussion_topic_id, self.student, '') + self.call_view(mock_is_forum_v2_enabled, mock_request, team.discussion_topic_id, self.student, '') self._assert_comments_service_called_without_group_id(mock_request) + + +class GroupIdAssertionMixinV2: + """ + Provides assertion methods for testing group_id functionality in forum v2. + + This mixin contains helper methods to verify that the comments service is called + with the correct group_id parameters and that responses contain the expected + group information. + """ + def _get_params_last_call(self, function_name): + """ + Returns the data or params dict that `mock_request` was called with. + """ + return self.get_mock_func_calls(function_name)[-1][1] + + def _assert_comments_service_called_with_group_id(self, group_id): + assert self.check_mock_called('get_user_threads') + assert self._get_params_last_call('get_user_threads')['group_id'] == group_id + + def _assert_comments_service_called_without_group_id(self): + assert self.check_mock_called('get_user_threads') + assert 'group_id' not in self._get_params_last_call('get_user_threads') + + def _assert_html_response_contains_group_info(self, response): + group_info = {"group_id": None, "group_name": None} + match = re.search(r'"group_id": (\d*),', response.content.decode('utf-8')) + if match and match.group(1) != '': + group_info["group_id"] = int(match.group(1)) + match = re.search(r'"group_name": "(\w*)"', response.content.decode('utf-8')) + if match: + group_info["group_name"] = match.group(1) + self._assert_thread_contains_group_info(group_info) + + def _assert_json_response_contains_group_info(self, response, extract_thread=None): + """ + :param extract_thread: a function which accepts a dictionary (complete + json response payload) and returns another dictionary (first + occurrence of a thread model within that payload). if None is + passed, the identity function is assumed. + """ + payload = json.loads(response.content.decode('utf-8')) + thread = extract_thread(payload) if extract_thread else payload + self._assert_thread_contains_group_info(thread) + + def _assert_thread_contains_group_info(self, thread): + assert thread['group_id'] == self.student_cohort.id + assert thread['group_name'] == self.student_cohort.name + + +class CohortedTopicGroupIdTestMixinV2(GroupIdAssertionMixinV2): + """ + Provides test cases to verify that views pass the correct `group_id` to + the comments service when requesting content in cohorted discussions for forum v2. + """ + def call_view(self, commentable_id, user, group_id, pass_group_id=True): + """ + Call the view for the implementing test class, constructing a request + from the parameters. + """ + pass # lint-amnesty, pylint: disable=unnecessary-pass + + def test_cohorted_topic_student_without_group_id(self): + self.call_view("cohorted_topic", self.student, '', pass_group_id=False) + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_student_none_group_id(self): + self.call_view("cohorted_topic", self.student, "") + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_student_with_own_group_id(self): + self.call_view("cohorted_topic", self.student, self.student_cohort.id) + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_student_with_other_group_id(self): + self.call_view( + "cohorted_topic", + self.student, + self.moderator_cohort.id + ) + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_moderator_without_group_id(self): + self.call_view( + "cohorted_topic", + self.moderator, + '', + pass_group_id=False + ) + self._assert_comments_service_called_without_group_id() + + def test_cohorted_topic_moderator_none_group_id(self): + self.call_view("cohorted_topic", self.moderator, "") + self._assert_comments_service_called_without_group_id() + + def test_cohorted_topic_moderator_with_own_group_id(self): + self.call_view( + "cohorted_topic", + self.moderator, + self.moderator_cohort.id + ) + self._assert_comments_service_called_with_group_id(self.moderator_cohort.id) + + def test_cohorted_topic_moderator_with_other_group_id(self): + self.call_view( + "cohorted_topic", + self.moderator, + self.student_cohort.id + ) + self._assert_comments_service_called_with_group_id(self.student_cohort.id) + + def test_cohorted_topic_moderator_with_invalid_group_id(self): + invalid_id = self.student_cohort.id + self.moderator_cohort.id + response = self.call_view("cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return + assert response.status_code == 500 + + def test_cohorted_topic_enrollment_track_invalid_group_id(self): + CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.AUDIT) + CourseModeFactory.create(course_id=self.course.id, mode_slug=CourseMode.VERIFIED) + discussion_settings = CourseDiscussionSettings.get(self.course.id) + discussion_settings.update({ + 'divided_discussions': ['cohorted_topic'], + 'division_scheme': CourseDiscussionSettings.ENROLLMENT_TRACK, + 'always_divide_inline_discussions': True, + }) + + invalid_id = -1000 + response = self.call_view("cohorted_topic", self.moderator, invalid_id) # lint-amnesty, pylint: disable=assignment-from-no-return + assert response.status_code == 500 + + +class NonCohortedTopicGroupIdTestMixinV2(GroupIdAssertionMixinV2): + """ + Provides test cases to verify that views pass the correct `group_id` to + the comments service when requesting content in non-cohorted discussions for forum v2. + """ + def call_view(self, commentable_id, user, group_id, pass_group_id=True): + """ + Call the view for the implementing test class, constructing a request + from the parameters. + """ + pass # lint-amnesty, pylint: disable=unnecessary-pass + + def test_non_cohorted_topic_student_without_group_id(self): + self.call_view( + "non_cohorted_topic", + self.student, + '', + pass_group_id=False + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_student_none_group_id(self): + self.call_view("non_cohorted_topic", self.student, '') + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_student_with_own_group_id(self): + self.call_view( + "non_cohorted_topic", + self.student, + self.student_cohort.id + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_student_with_other_group_id(self): + self.call_view( + "non_cohorted_topic", + self.student, + self.moderator_cohort.id + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_without_group_id(self): + self.call_view( + "non_cohorted_topic", + self.moderator, + "", + pass_group_id=False, + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_none_group_id(self): + self.call_view("non_cohorted_topic", self.moderator, '') + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_with_own_group_id(self): + self.call_view( + "non_cohorted_topic", + self.moderator, + self.moderator_cohort.id, + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_with_other_group_id(self): + self.call_view( + "non_cohorted_topic", + self.moderator, + self.student_cohort.id, + ) + self._assert_comments_service_called_without_group_id() + + def test_non_cohorted_topic_moderator_with_invalid_group_id(self): + invalid_id = self.student_cohort.id + self.moderator_cohort.id + self.call_view("non_cohorted_topic", self.moderator, invalid_id) + self._assert_comments_service_called_without_group_id() + + def test_team_discussion_id_not_cohorted(self): + team = CourseTeamFactory( + course_id=self.course.id, + topic_id='topic-id' + ) + + team.add_user(self.student) + self.call_view(team.discussion_topic_id, self.student, '') + + self._assert_comments_service_called_without_group_id() diff --git a/lms/djangoapps/discussion/django_comment_client/tests/mixins.py b/lms/djangoapps/discussion/django_comment_client/tests/mixins.py new file mode 100644 index 0000000000..a5ae9ec145 --- /dev/null +++ b/lms/djangoapps/discussion/django_comment_client/tests/mixins.py @@ -0,0 +1,106 @@ +""" +Mixin for django_comment_client tests. +""" + +from unittest import mock + + +class MockForumApiMixin: + """Mixin to mock forum_api across different test cases with a single mock instance.""" + + users_map = {} + + @classmethod + def setUpClassAndForumMock(cls): + """ + Set up the class and apply the forum_api mock. + """ + cls.mock_forum_api = mock.Mock() + + # TODO: Remove this after moving all APIs + cls.flag_v2_patcher = mock.patch( + "openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled" + ) + cls.mock_enable_forum_v2 = cls.flag_v2_patcher.start() + cls.mock_enable_forum_v2.return_value = True + + patch_targets = [ + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api", + "openedx.core.djangoapps.django_comment_common.comment_client.comment.forum_api", + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api", + "openedx.core.djangoapps.django_comment_common.comment_client.course.forum_api", + "openedx.core.djangoapps.django_comment_common.comment_client.subscriptions.forum_api", + "openedx.core.djangoapps.django_comment_common.comment_client.user.forum_api", + ] + cls.forum_api_patchers = [ + mock.patch(target, cls.mock_forum_api) for target in patch_targets + ] + for patcher in cls.forum_api_patchers: + patcher.start() + + @classmethod + def disposeForumMocks(cls): + """Stop patches after tests complete.""" + cls.flag_v2_patcher.stop() + + for patcher in cls.forum_api_patchers: + patcher.stop() + + def set_mock_return_value(self, function_name, return_value): + """ + Set a return value for a specific method in forum_api mock. + + Args: + function_name (str): The method name in the mock to set a return value for. + return_value (Any): The return value for the method. + """ + setattr( + self.mock_forum_api, function_name, mock.Mock(return_value=return_value) + ) + + def set_mock_side_effect(self, function_name, side_effect_fn): + """ + Set a side effect for a specific method in forum_api mock. + + Args: + function_name (str): The method name in the mock to set a side effect for. + side_effect_fn (Callable): A function to be called when the mock is called. + """ + setattr( + self.mock_forum_api, function_name, mock.Mock(side_effect=side_effect_fn) + ) + + def check_mock_called_with(self, function_name, index, *parms, **kwargs): + """ + Check if a specific method in forum_api mock was called with the given parameters. + + Args: + function_name (str): The method name in the mock to check. + parms (tuple): The parameters to check the method was called with. + """ + call_args = getattr(self.mock_forum_api, function_name).call_args_list[index] + assert call_args == mock.call(*parms, **kwargs) + + def check_mock_called(self, function_name): + """ + Check if a specific method in the forum_api mock was called. + + Args: + function_name (str): The method name in the mock to check. + + Returns: + bool: True if the method was called, False otherwise. + """ + return getattr(self.mock_forum_api, function_name).called + + def get_mock_func_calls(self, function_name): + """ + Returns a list of call arguments for a specific method in the mock_forum_api. + + Args: + function_name (str): The name of the method in the mock_forum_api to retrieve call arguments for. + + Returns: + list: A list of call arguments for the specified method. + """ + return getattr(self.mock_forum_api, function_name).call_args_list diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index 19ccf26d19..d0e88f0092 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -35,7 +35,7 @@ from common.djangoapps.student.roles import ( from lms.djangoapps.course_api.blocks.api import get_blocks from lms.djangoapps.courseware.courses import get_course_with_access from lms.djangoapps.courseware.exceptions import CourseAccessRedirect -from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE +from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE, ONLY_VERIFIED_USERS_CAN_POST from lms.djangoapps.discussion.views import is_privileged_user from openedx.core.djangoapps.discussions.models import ( DiscussionsConfiguration, @@ -106,6 +106,7 @@ from .forms import CommentActionsForm, ThreadActionsForm, UserOrdering from .pagination import DiscussionAPIPagination from .permissions import ( can_delete, + can_take_action_on_spam, get_editable_fields, get_initializable_comment_fields, get_initializable_thread_fields @@ -127,7 +128,8 @@ from .utils import ( get_usernames_for_course, get_usernames_from_search_string, set_attribute, - is_posting_allowed + is_posting_allowed, + can_user_notify_all_learners, is_captcha_enabled ) User = get_user_model() @@ -199,7 +201,7 @@ def _get_course(course_key: CourseKey, user: User, check_tab: bool = True) -> Co return course -def _get_thread_and_context(request, thread_id, retrieve_kwargs=None): +def _get_thread_and_context(request, thread_id, retrieve_kwargs=None, course_id=None): """ Retrieve the given thread and build a serializer context for it, returning both. This function also enforces access control for the thread (checking @@ -213,7 +215,7 @@ def _get_thread_and_context(request, thread_id, retrieve_kwargs=None): retrieve_kwargs["with_responses"] = False if "mark_as_read" not in retrieve_kwargs: retrieve_kwargs["mark_as_read"] = False - cc_thread = Thread(id=thread_id).retrieve(**retrieve_kwargs) + cc_thread = Thread(id=thread_id).retrieve(course_id=course_id, **retrieve_kwargs) course_key = CourseKey.from_string(cc_thread["course_id"]) course = _get_course(course_key, request.user) context = get_context(course, request, cc_thread) @@ -333,6 +335,8 @@ def get_course(request, course_key, check_tab=True): course.get_discussion_blackout_datetimes() ) discussion_tab = CourseTabList.get_tab_by_type(course.tabs, 'discussion') + is_course_staff = CourseStaffRole(course_key).has_user(request.user) + is_course_admin = CourseInstructorRole(course_key).has_user(request.user) return { "id": str(course_key), "is_posting_enabled": is_posting_enabled, @@ -351,6 +355,7 @@ def get_course(request, course_key, check_tab=True): "allow_anonymous": course.allow_anonymous, "allow_anonymous_to_peers": course.allow_anonymous_to_peers, "user_roles": user_roles, + "has_bulk_delete_privileges": can_take_action_on_spam(request.user, course_key), "has_moderation_privileges": bool(user_roles & { FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, @@ -358,8 +363,8 @@ def get_course(request, course_key, check_tab=True): }), "is_group_ta": bool(user_roles & {FORUM_ROLE_GROUP_MODERATOR}), "is_user_admin": request.user.is_staff, - "is_course_staff": CourseStaffRole(course_key).has_user(request.user), - "is_course_admin": CourseInstructorRole(course_key).has_user(request.user), + "is_course_staff": is_course_staff, + "is_course_admin": is_course_admin, "provider": course_config.provider_type, "enable_in_context": course_config.enable_in_context, "group_at_subsection": course_config.plugin_configuration.get("group_at_subsection", False), @@ -372,6 +377,15 @@ def get_course(request, course_key, check_tab=True): for (reason_code, label) in CLOSE_REASON_CODES.items() ], 'show_discussions': bool(discussion_tab and discussion_tab.is_enabled(course, request.user)), + 'is_notify_all_learners_enabled': can_user_notify_all_learners( + course_key, user_roles, is_course_staff, is_course_admin + ), + 'captcha_settings': { + 'enabled': is_captcha_enabled(course_key), + 'site_key': settings.RECAPTCHA_SITE_KEY, + }, + "is_email_verified": request.user.is_active, + "only_verified_users_can_post": ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key), } @@ -990,7 +1004,10 @@ def get_thread_list( except ValueError: pass - if (group_id is None) and not context["has_moderation_privilege"]: + if (group_id is None) and ( + not context["has_moderation_privilege"] + or request.user.id in context["ta_user_ids"] + ): group_id = get_group_id_for_user(request.user, CourseDiscussionSettings.get(course.id)) query_params = { @@ -1010,7 +1027,7 @@ def get_thread_list( if view in ["unread", "unanswered", "unresponded"]: query_params[view] = "true" else: - ValidationError({ + raise ValidationError({ "view": [f"Invalid value. '{view}' must be 'unread' or 'unanswered'"] }) @@ -1466,6 +1483,8 @@ def create_thread(request, thread_data): if not discussion_open_for_user(course, user): raise DiscussionBlackOutException + notify_all_learners = thread_data.pop("notify_all_learners", False) + context = get_context(course, request) _check_initializable_thread_fields(thread_data, context) discussion_settings = CourseDiscussionSettings.get(course_key) @@ -1481,12 +1500,12 @@ def create_thread(request, thread_data): raise ValidationError(dict(list(serializer.errors.items()) + list(actions_form.errors.items()))) serializer.save() cc_thread = serializer.instance - thread_created.send(sender=None, user=user, post=cc_thread) + thread_created.send(sender=None, user=user, post=cc_thread, notify_all_learners=notify_all_learners) api_thread = serializer.data _do_extra_actions(api_thread, cc_thread, list(thread_data.keys()), actions_form, context, request) track_thread_created_event(request, course, cc_thread, actions_form.cleaned_data["following"], - from_mfe_sidebar) + from_mfe_sidebar, notify_all_learners) return api_thread @@ -1645,7 +1664,8 @@ def get_thread(request, thread_id, requested_fields=None, course_id=None): retrieve_kwargs={ "with_responses": True, "user_id": str(request.user.id), - } + }, + course_id=course_id, ) if course_id and course_id != cc_thread.course_id: raise ThreadNotFoundError("Thread not found.") diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py index 929e9917a5..8249f17027 100644 --- a/lms/djangoapps/discussion/rest_api/discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py @@ -73,6 +73,8 @@ class DiscussionNotificationSender: app_name="discussion", course_key=self.course.id, ) + # .. event_implemented_name: USER_NOTIFICATION_REQUESTED + # .. event_type: org.openedx.learning.user.notification.requested.v1 USER_NOTIFICATION_REQUESTED.send_event(notification_data=notification_data) def _send_course_wide_notification(self, notification_type, audience_filters=None, extra_context=None): @@ -97,6 +99,8 @@ class DiscussionNotificationSender: app_name="discussion", audience_filters=audience_filters, ) + # .. event_implemented_name: COURSE_NOTIFICATION_REQUESTED + # .. event_type: org.openedx.learning.course.notification.requested.v1 COURSE_NOTIFICATION_REQUESTED.send_event(course_notification_data=notification_data) def _get_parent_response(self): @@ -113,11 +117,13 @@ class DiscussionNotificationSender: Send notification to users who are subscribed to the main thread/post i.e. there is a response to the main thread. """ + notification_type = "new_response" if not self.parent_id and self.creator.id != int(self.thread.user_id): context = { 'email_content': clean_thread_html_body(self.comment.body), } - self._send_notification([self.thread.user_id], "new_response", extra_context=context) + self._populate_context_with_ids_for_mobile(context, notification_type) + self._send_notification([self.thread.user_id], notification_type, extra_context=context) def _response_and_thread_has_same_creator(self) -> bool: """ @@ -132,6 +138,7 @@ class DiscussionNotificationSender: """ Send notification to parent thread creator i.e. comment on the response. """ + notification_type = "new_comment" if ( self.parent_response and self.creator.id != int(self.thread.user_id) @@ -155,15 +162,16 @@ class DiscussionNotificationSender: "author_name": str(author_name), "author_pronoun": str(author_pronoun), "email_content": clean_thread_html_body(self.comment.body), - "group_by_id": self.parent_response.id } - self._send_notification([self.thread.user_id], "new_comment", extra_context=context) + self._populate_context_with_ids_for_mobile(context, notification_type) + self._send_notification([self.thread.user_id], notification_type, extra_context=context) def send_new_comment_on_response_notification(self): """ Send notification to parent response creator i.e. comment on the response. Do not send notification if author of response is same as author of post. """ + notification_type = "new_comment_on_response" if ( self.parent_response and self.creator.id != int(self.parent_response.user_id) and not @@ -172,9 +180,10 @@ class DiscussionNotificationSender: context = { "email_content": clean_thread_html_body(self.comment.body), } + self._populate_context_with_ids_for_mobile(context, notification_type) self._send_notification( [self.parent_response.user_id], - "new_comment_on_response", + notification_type, extra_context=context ) @@ -203,7 +212,7 @@ class DiscussionNotificationSender: while has_more_subscribers: - subscribers = Subscription.fetch(self.thread.id, query_params={'page': page}) + subscribers = Subscription.fetch(self.thread.id, self.course.id, query_params={'page': page}) if page <= subscribers.num_pages: for subscriber in subscribers.collection: # Check if the subscriber is not the thread creator or response creator @@ -217,12 +226,16 @@ class DiscussionNotificationSender: # Remove duplicate users from the list of users to send notification users = list(set(users)) if not self.parent_id: + context = { + "email_content": clean_thread_html_body(self.comment.body), + } + notification_type = "response_on_followed_post" + self._populate_context_with_ids_for_mobile(context, notification_type) self._send_notification( users, - "response_on_followed_post", - extra_context={ - "email_content": clean_thread_html_body(self.comment.body), - }) + notification_type, + extra_context=context + ) else: author_name = f"{self.parent_response.username}'s" # use 'their' if comment author is also response author. @@ -232,14 +245,17 @@ class DiscussionNotificationSender: if self._response_and_comment_has_same_creator() else f"{self.parent_response.username}'s" ) + context = { + "author_name": str(author_name), + "author_pronoun": str(author_pronoun), + "email_content": clean_thread_html_body(self.comment.body), + } + notification_type = "comment_on_followed_post" + self._populate_context_with_ids_for_mobile(context, notification_type) self._send_notification( users, - "comment_on_followed_post", - extra_context={ - "author_name": str(author_name), - "author_pronoun": str(author_pronoun), - "email_content": clean_thread_html_body(self.comment.body), - } + notification_type, + extra_context=context ) def _create_cohort_course_audience(self): @@ -291,7 +307,9 @@ class DiscussionNotificationSender: context = { "email_content": clean_thread_html_body(self.comment.body) } - self._send_notification([self.thread.user_id], "response_endorsed_on_thread", extra_context=context) + notification_type = "response_endorsed_on_thread" + self._populate_context_with_ids_for_mobile(context, notification_type) + self._send_notification([self.thread.user_id], notification_type, extra_context=context) def send_response_endorsed_notification(self): """ @@ -300,19 +318,22 @@ class DiscussionNotificationSender: context = { "email_content": clean_thread_html_body(self.comment.body) } - self._send_notification([self.creator.id], "response_endorsed", extra_context=context) + notification_type = "response_endorsed" + self._populate_context_with_ids_for_mobile(context, notification_type) + self._send_notification([self.creator.id], notification_type, extra_context=context) - def send_new_thread_created_notification(self): + def send_new_thread_created_notification(self, notify_all_learners=False): """ Send notification based on notification_type """ thread_type = self.thread.attributes['thread_type'] - notification_type = ( + + notification_type = "new_instructor_all_learners_post" if notify_all_learners else ( "new_question_post" if thread_type == "question" else ("new_discussion_post" if thread_type == "discussion" else "") ) - if notification_type not in ['new_discussion_post', 'new_question_post']: + if notification_type not in ['new_discussion_post', 'new_question_post', 'new_instructor_all_learners_post']: raise ValueError(f'Invalid notification type {notification_type}') audience_filters = self._create_cohort_course_audience() @@ -331,6 +352,7 @@ class DiscussionNotificationSender: 'post_title': self.thread.title, "email_content": clean_thread_html_body(self.thread.body), } + self._populate_context_with_ids_for_mobile(context, notification_type) self._send_course_wide_notification(notification_type, audience_filters, context) def send_reported_content_notification(self): @@ -356,13 +378,29 @@ class DiscussionNotificationSender: context = { 'username': self.thread.username, 'content_type': content_type, - 'content': thread_body + 'content': thread_body, + 'email_content': clean_thread_html_body(thread_body) } audience_filters = {'discussion_roles': [ FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA ]} self._send_course_wide_notification("content_reported", audience_filters, context) + def _populate_context_with_ids_for_mobile(self, context, notification_type): + """ + Populate notification context with attributes required by mobile apps. + """ + + context['thread_id'] = self.thread.id + context['topic_id'] = self.thread.commentable_id + + if notification_type in ("response_on_followed_post", 'new_response'): + context['response_id'] = self.comment_id + context['comment_id'] = None + else: + context['response_id'] = self.parent_id + context['comment_id'] = self.comment_id + def is_discussion_cohorted(course_key_str): """ diff --git a/lms/djangoapps/discussion/rest_api/permissions.py b/lms/djangoapps/discussion/rest_api/permissions.py index cb6ff4ea96..cfcea5b328 100644 --- a/lms/djangoapps/discussion/rest_api/permissions.py +++ b/lms/djangoapps/discussion/rest_api/permissions.py @@ -6,7 +6,7 @@ from typing import Dict, Set, Union from opaque_keys.edx.keys import CourseKey from rest_framework import permissions -from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment from common.djangoapps.student.roles import ( CourseInstructorRole, CourseStaffRole, @@ -19,7 +19,7 @@ from lms.djangoapps.discussion.django_comment_client.utils import ( from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread from openedx.core.djangoapps.django_comment_common.models import ( - FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR + Role, FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR ) @@ -185,3 +185,46 @@ class IsStaffOrAdmin(permissions.BasePermission): request.user.is_staff or is_user_staff and request.method == "GET" ) + + +def can_take_action_on_spam(user, course_id): + """ + Returns if the user has access to take action against forum spam posts + Parameters: + user: User object + course_id: CourseKey or string of course_id + """ + if GlobalStaff().has_user(user): + return True + + if isinstance(course_id, str): + course_id = CourseKey.from_string(course_id) + org_id = course_id.org + course_ids = CourseEnrollment.objects.filter(user=user).values_list('course_id', flat=True) + course_ids = [c_id for c_id in course_ids if c_id.org == org_id] + user_roles = set( + Role.objects.filter( + users=user, + course_id__in=course_ids, + ).values_list('name', flat=True).distinct() + ) + if bool(user_roles & {FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR}): + return True + + if CourseAccessRole.objects.filter(user=user, course_id__in=course_ids, role__in=["instructor", "staff"]).exists(): + return True + return False + + +class IsAllowedToBulkDelete(permissions.BasePermission): + """ + Permission that checks if the user is staff or an admin. + """ + + def has_permission(self, request, view): + """Returns true if the user can bulk delete posts""" + if not request.user.is_authenticated: + return False + + course_id = view.kwargs.get("course_id") + return can_take_action_on_spam(request.user, course_id) diff --git a/lms/djangoapps/discussion/rest_api/serializers.py b/lms/djangoapps/discussion/rest_api/serializers.py index f8868cbed8..75ef1f3d51 100644 --- a/lms/djangoapps/discussion/rest_api/serializers.py +++ b/lms/djangoapps/discussion/rest_api/serializers.py @@ -1,6 +1,10 @@ """ Discussion API serializers """ +import html +import re + +from bs4 import BeautifulSoup from typing import Dict from urllib.parse import urlencode, urlunparse @@ -68,7 +72,7 @@ def get_context(course, request, thread=None): moderator_user_ids = get_moderator_users_list(course.id) ta_user_ids = get_course_ta_users_list(course.id) requester = request.user - cc_requester = CommentClientUser.from_django_user(requester).retrieve() + cc_requester = CommentClientUser.from_django_user(requester).retrieve(course_id=course.id) cc_requester["course_id"] = course.id course_discussion_settings = CourseDiscussionSettings.get(course.id) is_global_staff = GlobalStaff().has_user(requester) @@ -137,6 +141,46 @@ def _validate_privileged_access(context: Dict) -> bool: return course and is_requester_privileged +def filter_spam_urls_from_html(html_string): + """ + Filters out spam posts from html + Returns: + clean_post, is_spam + """ + html_string = html.unescape(html_string) + soup = BeautifulSoup(html_string, "html.parser") + patterns = [] + is_spam = False + for domain in settings.DISCUSSION_SPAM_URLS: + escaped = domain.replace(".", r"\.") + domain_pattern = rf"(\w+\.)*{escaped}(?:/\S*)*" + patterns.append(re.compile(rf"(https?://)?{domain_pattern}", re.IGNORECASE)) + spaced_parts = list(domain) + spaced_pattern = "".join( + rf"{re.escape(char)}(?:\s| |\u00A0)*" if char != "." else r"\.(?:\s| |\u00A0)*" + for char in spaced_parts + ) + spaced_pattern += r"(?:\/(?:\s| |\u00A0|\w)*)*" + patterns.append(re.compile(spaced_pattern, re.IGNORECASE)) + + for a_tag in soup.find_all("a", href=True): + href = a_tag.get('href') + if href: + if any(p.search(href) for p in patterns): + a_tag.replace_with(a_tag.get_text(strip=True)) + is_spam = True + + for text_node in soup.find_all(string=True): + new_text = text_node + for p in patterns: + new_text = p.sub('', new_text) + if new_text != text_node: + text_node.replace_with(new_text.strip()) + is_spam = True + + return str(soup), is_spam + + class _ContentSerializer(serializers.Serializer): # pylint: disable=abstract-method """ @@ -244,6 +288,9 @@ class _ContentSerializer(serializers.Serializer): """ if self._rendered_body is None: self._rendered_body = render_body(obj["body"]) + self._rendered_body, is_spam = filter_spam_urls_from_html(self._rendered_body) + if is_spam and settings.CONTENT_FOR_SPAM_POSTS: + self._rendered_body = settings.CONTENT_FOR_SPAM_POSTS return self._rendered_body def get_abuse_flagged(self, obj): diff --git a/lms/djangoapps/discussion/rest_api/tasks.py b/lms/djangoapps/discussion/rest_api/tasks.py index cbf4389889..d96fc8df09 100644 --- a/lms/djangoapps/discussion/rest_api/tasks.py +++ b/lms/djangoapps/discussion/rest_api/tasks.py @@ -1,23 +1,32 @@ """ Contain celery tasks """ +import logging + from celery import shared_task from django.contrib.auth import get_user_model from edx_django_utils.monitoring import set_code_owner_attribute from opaque_keys.edx.locator import CourseKey +from eventtracking import tracker +from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole +from common.djangoapps.track import segment from lms.djangoapps.courseware.courses import get_course_with_access +from lms.djangoapps.discussion.django_comment_client.utils import get_user_role_names from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender +from lms.djangoapps.discussion.rest_api.utils import can_user_notify_all_learners from openedx.core.djangoapps.django_comment_common.comment_client import Comment from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS + User = get_user_model() +log = logging.getLogger(__name__) @shared_task @set_code_owner_attribute -def send_thread_created_notification(thread_id, course_key_str, user_id): +def send_thread_created_notification(thread_id, course_key_str, user_id, notify_all_learners=False): """ Send notification when a new thread is created """ @@ -26,9 +35,17 @@ def send_thread_created_notification(thread_id, course_key_str, user_id): return thread = Thread(id=thread_id).retrieve() user = User.objects.get(id=user_id) + + if notify_all_learners: + is_course_staff = CourseStaffRole(course_key).has_user(user) + is_course_admin = CourseInstructorRole(course_key).has_user(user) + user_roles = get_user_role_names(user, course_key) + if not can_user_notify_all_learners(course_key, user_roles, is_course_staff, is_course_admin): + return + course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True) notification_sender = DiscussionNotificationSender(thread, course, user) - notification_sender.send_new_thread_created_notification() + notification_sender.send_new_thread_created_notification(notify_all_learners) @shared_task @@ -73,3 +90,24 @@ def send_response_endorsed_notifications(thread_id, response_id, course_key_str, if int(response.user_id) != endorser.id: notification_sender.creator = User.objects.get(id=response.user_id) notification_sender.send_response_endorsed_notification() + + +@shared_task +@set_code_owner_attribute +def delete_course_post_for_user(user_id, username, course_ids, event_data=None): + """ + Deletes all posts for user in a course. + """ + event_data = event_data or {} + log.info(f"<> Deleting all posts for {username} in course {course_ids}") + threads_deleted = Thread.delete_user_threads(user_id, course_ids) + comments_deleted = Comment.delete_user_comments(user_id, course_ids) + log.info(f"<> Deleted {threads_deleted} posts and {comments_deleted} comments for {username} " + f"in course {course_ids}") + event_data.update({ + "number_of_posts_deleted": threads_deleted, + "number_of_comments_deleted": comments_deleted, + }) + event_name = 'edx.discussion.bulk_delete_user_posts' + tracker.emit(event_name, event_data) + segment.track('None', event_name, event_data) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index 9a9041fd5f..3a766b55c5 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -21,7 +21,6 @@ from opaque_keys.edx.locator import CourseLocator from pytz import UTC from rest_framework.exceptions import PermissionDenied -from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory @@ -48,7 +47,6 @@ from lms.djangoapps.discussion.rest_api.api import ( get_course_topics, get_course_topics_v2, get_thread, - get_thread_list, get_user_comments, update_comment, update_thread @@ -207,6 +205,7 @@ class GetCourseTest(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase) 'enable_in_context': True, 'group_at_subsection': False, 'provider': 'legacy', + "has_bulk_delete_privileges": False, 'has_moderation_privileges': False, "is_course_staff": False, "is_course_admin": False, @@ -216,6 +215,13 @@ class GetCourseTest(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase) 'edit_reasons': [{'code': 'test-edit-reason', 'label': 'Test Edit Reason'}], 'post_close_reasons': [{'code': 'test-close-reason', 'label': 'Test Close Reason'}], 'show_discussions': True, + 'is_notify_all_learners_enabled': False, + 'captcha_settings': { + 'enabled': False, + 'site_key': '', + }, + "is_email_verified": True, + "only_verified_users_can_post": False } @ddt.data( @@ -697,540 +703,6 @@ class GetCourseTopicsTest(CommentsServiceMockMixin, ForumsEnableMixin, UrlResetM } -@ddt.ddt -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class GetThreadListTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMixin, SharedModuleStoreTestCase): - """Test for get_thread_list""" - @classmethod - def setUpClass(cls): - super().setUpClass() - cls.course = CourseFactory.create() - - @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) - self.maxDiff = None # pylint: disable=invalid-name - self.user = UserFactory.create() - self.register_get_user_response(self.user) - self.request = RequestFactory().get("/test_path") - self.request.user = self.user - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) - self.author = UserFactory.create() - self.course.cohort_config = {"cohorted": False} - modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) - self.cohort = CohortFactory.create(course_id=self.course.id) - - def get_thread_list( - self, - threads, - page=1, - page_size=1, - num_pages=1, - course=None, - topic_id_list=None, - ): - """ - Register the appropriate comments service response, then call - get_thread_list and return the result. - """ - course = course or self.course - self.register_get_threads_response(threads, page, num_pages) - ret = get_thread_list(self.request, course.id, page, page_size, topic_id_list) - return ret - - def test_nonexistent_course(self): - with pytest.raises(CourseNotFoundError): - get_thread_list(self.request, CourseLocator.from_string("course-v1:non+existent+course"), 1, 1) - - def test_not_enrolled(self): - self.request.user = UserFactory.create() - with pytest.raises(CourseNotFoundError): - self.get_thread_list([]) - - def test_discussions_disabled(self): - with pytest.raises(DiscussionDisabledError): - self.get_thread_list([], course=_discussion_disabled_course_for(self.user)) - - def test_empty(self): - assert self.get_thread_list( - [], num_pages=0 - ).data == { - 'pagination': { - 'next': None, - 'previous': None, - 'num_pages': 0, - 'count': 0 - }, - 'results': [], - 'text_search_rewrite': None - } - - def test_get_threads_by_topic_id(self): - self.get_thread_list([], topic_id_list=["topic_x", "topic_meow"]) - assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["1"], - "commentable_ids": ["topic_x,topic_meow"] - }) - - def test_basic_query_params(self): - self.get_thread_list([], page=6, page_size=14) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["6"], - "per_page": ["14"], - }) - - def test_thread_content(self): - self.course.cohort_config = {"cohorted": True} - modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) - source_threads = [ - make_minimal_cs_thread({ - "id": "test_thread_id_0", - "course_id": str(self.course.id), - "commentable_id": "topic_x", - "username": self.author.username, - "user_id": str(self.author.id), - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - "endorsed": True, - "read": True, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - }), - make_minimal_cs_thread({ - "id": "test_thread_id_1", - "course_id": str(self.course.id), - "commentable_id": "topic_y", - "group_id": self.cohort.id, - "username": self.author.username, - "user_id": str(self.author.id), - "thread_type": "question", - "title": "Another Test Title", - "body": "More content", - "votes": {"up_count": 9}, - "comments_count": 18, - "created_at": "2015-04-28T22:22:22Z", - "updated_at": "2015-04-28T00:33:33Z", - }) - ] - expected_threads = [ - self.expected_thread_data({ - "id": "test_thread_id_0", - "author": self.author.username, - "topic_id": "topic_x", - "vote_count": 4, - "comment_count": 6, - "unread_comment_count": 3, - "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_0", - "editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"], - "has_endorsed": True, - "read": True, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "abuse_flagged_count": None, - "can_delete": False, - }), - self.expected_thread_data({ - "id": "test_thread_id_1", - "author": self.author.username, - "topic_id": "topic_y", - "group_id": self.cohort.id, - "group_name": self.cohort.name, - "type": "question", - "title": "Another Test Title", - "raw_body": "More content", - "preview_body": "More content", - "rendered_body": "

    More content

    ", - "vote_count": 9, - "comment_count": 19, - "created_at": "2015-04-28T22:22:22Z", - "updated_at": "2015-04-28T00:33:33Z", - "comment_list_url": None, - "endorsed_comment_list_url": ( - "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=True" - ), - "non_endorsed_comment_list_url": ( - "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=False" - ), - "editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"], - "abuse_flagged_count": None, - "can_delete": False, - }), - ] - - expected_result = make_paginated_api_response( - results=expected_threads, count=2, num_pages=1, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert self.get_thread_list(source_threads).data == expected_result - - @ddt.data( - *itertools.product( - [ - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_STUDENT, - ], - [True, False] - ) - ) - @ddt.unpack - def test_request_group(self, role_name, course_is_cohorted): - cohort_course = CourseFactory.create(cohort_config={"cohorted": course_is_cohorted}) - CourseEnrollmentFactory.create(user=self.user, course_id=cohort_course.id) - CohortFactory.create(course_id=cohort_course.id, users=[self.user]) - _assign_role_to_user(user=self.user, course_id=cohort_course.id, role=role_name) - self.get_thread_list([], course=cohort_course) - actual_has_group = "group_id" in httpretty.last_request().querystring # lint-amnesty, pylint: disable=no-member - expected_has_group = (course_is_cohorted and role_name == FORUM_ROLE_STUDENT) - assert actual_has_group == expected_has_group - - def test_pagination(self): - # N.B. Empty thread list is not realistic but convenient for this test - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=3, next_link="http://testserver/test_path?page=2", previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert self.get_thread_list([], page=1, num_pages=3).data == expected_result - - expected_result = make_paginated_api_response( - results=[], - count=0, - num_pages=3, - next_link="http://testserver/test_path?page=3", - previous_link="http://testserver/test_path?page=1" - ) - expected_result.update({"text_search_rewrite": None}) - assert self.get_thread_list([], page=2, num_pages=3).data == expected_result - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=3, next_link=None, previous_link="http://testserver/test_path?page=2" - ) - expected_result.update({"text_search_rewrite": None}) - assert self.get_thread_list([], page=3, num_pages=3).data == expected_result - - # Test page past the last one - self.register_get_threads_response([], page=3, num_pages=3) - with pytest.raises(PageNotFoundError): - get_thread_list(self.request, self.course.id, page=4, page_size=10) - - @ddt.data(None, "rewritten search string") - def test_text_search(self, text_search_rewrite): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": text_search_rewrite}) - self.register_get_threads_search_response([], text_search_rewrite, num_pages=0) - assert get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - text_search='test search string' - ).data == expected_result - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "text": ["test search string"], - }) - - def test_filter_threads_by_author(self): - thread = make_minimal_cs_thread() - self.register_get_threads_response([thread], page=1, num_pages=10) - thread_results = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - author=self.user.username, - ).data.get('results') - assert len(thread_results) == 1 - - expected_last_query_params = { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "author_id": [str(self.user.id)], - } - - self.assert_last_query_params(expected_last_query_params) - - def test_filter_threads_by_missing_author(self): - self.register_get_threads_response([make_minimal_cs_thread()], page=1, num_pages=10) - results = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - author="a fake and missing username", - ).data.get('results') - assert len(results) == 0 - - @ddt.data('question', 'discussion', None) - def test_thread_type(self, thread_type): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - - self.register_get_threads_response([], page=1, num_pages=0) - assert get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - thread_type=thread_type, - ).data == expected_result - - expected_last_query_params = { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "thread_type": [thread_type], - } - - if thread_type is None: - del expected_last_query_params["thread_type"] - - self.assert_last_query_params(expected_last_query_params) - - @ddt.data(True, False, None) - def test_flagged(self, flagged_boolean): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - - self.register_get_threads_response([], page=1, num_pages=0) - assert get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - flagged=flagged_boolean, - ).data == expected_result - - expected_last_query_params = { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "flagged": [str(flagged_boolean)], - } - - if flagged_boolean is None: - del expected_last_query_params["flagged"] - - self.assert_last_query_params(expected_last_query_params) - - @ddt.data( - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_COMMUNITY_TA, - ) - def test_flagged_count(self, role): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - - _assign_role_to_user(self.user, self.course.id, role=role) - - self.register_get_threads_response([], page=1, num_pages=0) - get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - count_flagged=True, - ) - - expected_last_query_params = { - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "count_flagged": ["True"], - "page": ["1"], - "per_page": ["10"], - } - - self.assert_last_query_params(expected_last_query_params) - - def test_flagged_count_denied(self): - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - - _assign_role_to_user(self.user, self.course.id, role=FORUM_ROLE_STUDENT) - - self.register_get_threads_response([], page=1, num_pages=0) - - with pytest.raises(PermissionDenied): - get_thread_list( - self.request, - self.course.id, - page=1, - page_size=10, - count_flagged=True, - ) - - def test_following(self): - self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=11, - following=True, - ).data - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert result == expected_result - assert urlparse( - httpretty.last_request().path # lint-amnesty, pylint: disable=no-member - ).path == f"/api/v1/users/{self.user.id}/subscribed_threads" - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["11"], - }) - - @ddt.data("unanswered", "unread") - def test_view_query(self, query): - self.register_get_threads_response([], page=1, num_pages=0) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=11, - view=query, - ).data - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert result == expected_result - assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["11"], - query: ["true"], - }) - - @ddt.data( - ("last_activity_at", "activity"), - ("comment_count", "comments"), - ("vote_count", "votes") - ) - @ddt.unpack - def test_order_by_query(self, http_query, cc_query): - """ - Tests the order_by parameter - - Arguments: - http_query (str): Query string sent in the http request - cc_query (str): Query string used for the comments client service - """ - self.register_get_threads_response([], page=1, num_pages=0) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=11, - order_by=http_query, - ).data - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert result == expected_result - assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": [cc_query], - "page": ["1"], - "per_page": ["11"], - }) - - def test_order_direction(self): - """ - Only "desc" is supported for order. Also, since it is simply swallowed, - it isn't included in the params. - """ - self.register_get_threads_response([], page=1, num_pages=0) - result = get_thread_list( - self.request, - self.course.id, - page=1, - page_size=11, - order_direction="desc", - ).data - - expected_result = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_result.update({"text_search_rewrite": None}) - assert result == expected_result - assert urlparse(httpretty.last_request().path).path == '/api/v1/threads' # lint-amnesty, pylint: disable=no-member - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["11"], - }) - - def test_invalid_order_direction(self): - """ - Test with invalid order_direction (e.g. "asc") - """ - with pytest.raises(ValidationError) as assertion: - self.register_get_threads_response([], page=1, num_pages=0) - get_thread_list( # pylint: disable=expression-not-assigned - self.request, - self.course.id, - page=1, - page_size=11, - order_direction="asc", - ).data - assert 'order_direction' in assertion.value.message_dict - - @ddt.ddt @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) class GetCommentListTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModuleStoreTestCase): @@ -1248,6 +720,22 @@ class GetCommentListTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedModu httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.maxDiff = None # pylint: disable=invalid-name self.user = UserFactory.create() self.register_get_user_response(self.user) @@ -1771,10 +1259,10 @@ class GetUserCommentsTest(ForumsEnableMixin, CommentsServiceMockMixin, SharedMod if page in (1, 2): assert response.data["pagination"]["next"] is not None - assert f"page={page+1}" in response.data["pagination"]["next"] + assert f"page={page + 1}" in response.data["pagination"]["next"] if page in (2, 3): assert response.data["pagination"]["previous"] is not None - assert f"page={page-1}" in response.data["pagination"]["previous"] + assert f"page={page - 1}" in response.data["pagination"]["previous"] if page == 1: assert response.data["pagination"]["previous"] is None if page == 3: @@ -1872,6 +1360,12 @@ class CreateThreadTest( httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") @@ -1893,7 +1387,9 @@ class CreateThreadTest( "read": True, }) self.register_post_thread_response(cs_thread) - with self.assert_signal_sent(api, 'thread_created', sender=None, user=self.user, exclude_args=('post',)): + with self.assert_signal_sent( + api, 'thread_created', sender=None, user=self.user, exclude_args=('post', 'notify_all_learners') + ): actual = create_thread(self.request, self.minimal_data) expected = self.expected_thread_data({ "id": "test_id", @@ -1922,7 +1418,10 @@ class CreateThreadTest( 'title_truncated': False, 'anonymous': False, 'anonymous_to_peers': False, - 'options': {'followed': False}, + 'options': { + 'followed': False, + 'notify_all_learners': False + }, 'id': 'test_id', 'truncated': False, 'body': 'Test body', @@ -1959,7 +1458,9 @@ class CreateThreadTest( _assign_role_to_user(user=self.user, course_id=self.course.id, role=FORUM_ROLE_MODERATOR) - with self.assert_signal_sent(api, 'thread_created', sender=None, user=self.user, exclude_args=('post',)): + with self.assert_signal_sent( + api, 'thread_created', sender=None, user=self.user, exclude_args=('post', 'notify_all_learners') + ): actual = create_thread(self.request, self.minimal_data) expected = self.expected_thread_data({ "author_label": "Moderator", @@ -2009,7 +1510,10 @@ class CreateThreadTest( "title_truncated": False, "anonymous": False, "anonymous_to_peers": False, - "options": {"followed": False}, + "options": { + "followed": False, + "notify_all_learners": False + }, "id": "test_id", "truncated": False, "body": "Test body", @@ -2031,7 +1535,9 @@ class CreateThreadTest( "read": True, }) self.register_post_thread_response(cs_thread) - with self.assert_signal_sent(api, 'thread_created', sender=None, user=self.user, exclude_args=('post',)): + with self.assert_signal_sent( + api, 'thread_created', sender=None, user=self.user, exclude_args=('post', 'notify_all_learners') + ): create_thread(self.request, data) event_name, event_data = mock_emit.call_args[0] assert event_name == 'edx.forum.thread.created' @@ -2043,7 +1549,10 @@ class CreateThreadTest( 'title_truncated': True, 'anonymous': False, 'anonymous_to_peers': False, - 'options': {'followed': False}, + 'options': { + 'followed': False, + 'notify_all_learners': False + }, 'id': 'test_id', 'truncated': False, 'body': 'Test body', @@ -2128,18 +1637,6 @@ class CreateThreadTest( assert cs_request.method == 'POST' assert parsed_body(cs_request) == {'source_type': ['thread'], 'source_id': ['test_id']} - def test_abuse_flagged(self): - self.register_post_thread_response({"id": "test_id", "username": self.user.username}) - self.register_thread_flag_response("test_id") - data = self.minimal_data.copy() - data["abuse_flagged"] = "True" - result = create_thread(self.request, data) - assert result['abuse_flagged'] is True - cs_request = httpretty.last_request() - assert urlparse(cs_request.path).path == '/api/v1/threads/test_id/abuse_flag' # lint-amnesty, pylint: disable=no-member - assert cs_request.method == 'PUT' - assert parsed_body(cs_request) == {'user_id': [str(self.user.id)]} - def test_course_id_missing(self): with pytest.raises(ValidationError) as assertion: create_thread(self.request, {}) @@ -2198,6 +1695,22 @@ class CreateCommentTest( self.course = CourseFactory.create() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") @@ -2472,18 +1985,6 @@ class CreateCommentTest( except ValidationError: assert expected_error - def test_abuse_flagged(self): - self.register_post_comment_response({"id": "test_comment", "username": self.user.username}, "test_thread") - self.register_comment_flag_response("test_comment") - data = self.minimal_data.copy() - data["abuse_flagged"] = "True" - result = create_comment(self.request, data) - assert result['abuse_flagged'] is True - cs_request = httpretty.last_request() - assert urlparse(cs_request.path).path == '/api/v1/comments/test_comment/abuse_flag' # lint-amnesty, pylint: disable=no-member - assert cs_request.method == 'PUT' - assert parsed_body(cs_request) == {'user_id': [str(self.user.id)]} - def test_thread_id_missing(self): with pytest.raises(ValidationError) as assertion: create_comment(self.request, {}) @@ -2589,6 +2090,17 @@ class UpdateThreadTest( httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) @@ -2785,231 +2297,6 @@ class UpdateThreadTest( assert event_data['followed'] == new_following assert event_data['user_forums_roles'] == ['Student'] - @ddt.data(*itertools.product([True, False], [True, False])) - @ddt.unpack - @mock.patch("eventtracking.tracker.emit") - def test_voted(self, current_vote_status, new_vote_status, mock_emit): - """ - Test attempts to edit the "voted" field. - - current_vote_status indicates whether the thread should be upvoted at - the start of the test. new_vote_status indicates the value for the - "voted" field in the update. If current_vote_status and new_vote_status - are the same, no update should be made. Otherwise, a vote should be PUT - or DELETEd according to the new_vote_status value. - """ - #setup - user1, request1 = self.create_user_with_request() - - if current_vote_status: - self.register_get_user_response(user1, upvoted_ids=["test_thread"]) - self.register_thread_votes_response("test_thread") - self.register_thread() - data = {"voted": new_vote_status} - result = update_thread(request1, "test_thread", data) - assert result['voted'] == new_vote_status - last_request_path = urlparse(httpretty.last_request().path).path # lint-amnesty, pylint: disable=no-member - votes_url = "/api/v1/threads/test_thread/votes" - if current_vote_status == new_vote_status: - assert last_request_path != votes_url - else: - assert last_request_path == votes_url - assert httpretty.last_request().method == ('PUT' if new_vote_status else 'DELETE') - actual_request_data = ( - parsed_body(httpretty.last_request()) if new_vote_status else - parse_qs(urlparse(httpretty.last_request().path).query) # lint-amnesty, pylint: disable=no-member - ) - actual_request_data.pop("request_id", None) - expected_request_data = {"user_id": [str(user1.id)]} - if new_vote_status: - expected_request_data["value"] = ["up"] - assert actual_request_data == expected_request_data - - event_name, event_data = mock_emit.call_args[0] - assert event_name == 'edx.forum.thread.voted' - assert event_data == { - 'undo_vote': (not new_vote_status), - 'url': '', - 'target_username': self.user.username, - 'vote_value': 'up', - 'user_forums_roles': [FORUM_ROLE_STUDENT], - 'user_course_roles': [], - 'commentable_id': 'original_topic', - 'id': 'test_thread' - } - - @ddt.data(*itertools.product([True, False], [True, False], [True, False])) - @ddt.unpack - def test_vote_count(self, current_vote_status, first_vote, second_vote): - """ - Tests vote_count increases and decreases correctly from the same user - """ - #setup - starting_vote_count = 0 - user, request = self.create_user_with_request() - if current_vote_status: - self.register_get_user_response(user, upvoted_ids=["test_thread"]) - starting_vote_count = 1 - self.register_thread_votes_response("test_thread") - self.register_thread(overrides={"votes": {"up_count": starting_vote_count}}) - - #first vote - data = {"voted": first_vote} - result = update_thread(request, "test_thread", data) - self.register_thread(overrides={"voted": first_vote}) - assert result['vote_count'] == (1 if first_vote else 0) - - #second vote - data = {"voted": second_vote} - result = update_thread(request, "test_thread", data) - assert result['vote_count'] == (1 if second_vote else 0) - - @ddt.data(*itertools.product([True, False], [True, False], [True, False], [True, False])) - @ddt.unpack - def test_vote_count_two_users( - self, - current_user1_vote, - current_user2_vote, - user1_vote, - user2_vote - ): - """ - Tests vote_count increases and decreases correctly from different users - """ - #setup - user1, request1 = self.create_user_with_request() - user2, request2 = self.create_user_with_request() - - vote_count = 0 - if current_user1_vote: - self.register_get_user_response(user1, upvoted_ids=["test_thread"]) - vote_count += 1 - if current_user2_vote: - self.register_get_user_response(user2, upvoted_ids=["test_thread"]) - vote_count += 1 - - for (current_vote, user_vote, request) in \ - [(current_user1_vote, user1_vote, request1), - (current_user2_vote, user2_vote, request2)]: - - self.register_thread_votes_response("test_thread") - self.register_thread(overrides={"votes": {"up_count": vote_count}}) - - data = {"voted": user_vote} - result = update_thread(request, "test_thread", data) - if current_vote == user_vote: - assert result['vote_count'] == vote_count - elif user_vote: - vote_count += 1 - assert result['vote_count'] == vote_count - self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) - else: - vote_count -= 1 - assert result['vote_count'] == vote_count - self.register_get_user_response(self.user, upvoted_ids=[]) - - @ddt.data(*itertools.product([True, False], [True, False])) - @ddt.unpack - @mock.patch("eventtracking.tracker.emit") - def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit): - """ - Test attempts to edit the "abuse_flagged" field. - - old_flagged indicates whether the thread should be flagged at the start - of the test. new_flagged indicates the value for the "abuse_flagged" - field in the update. If old_flagged and new_flagged are the same, no - update should be made. Otherwise, a PUT should be made to the flag or - or unflag endpoint according to the new_flagged value. - """ - self.register_get_user_response(self.user) - self.register_thread_flag_response("test_thread") - self.register_thread({"abuse_flaggers": [str(self.user.id)] if old_flagged else []}) - data = {"abuse_flagged": new_flagged} - result = update_thread(self.request, "test_thread", data) - assert result['abuse_flagged'] == new_flagged - last_request_path = urlparse(httpretty.last_request().path).path # lint-amnesty, pylint: disable=no-member - flag_url = "/api/v1/threads/test_thread/abuse_flag" - unflag_url = "/api/v1/threads/test_thread/abuse_unflag" - if old_flagged == new_flagged: - assert last_request_path != flag_url - assert last_request_path != unflag_url - else: - assert last_request_path == (flag_url if new_flagged else unflag_url) - assert httpretty.last_request().method == 'PUT' - assert parsed_body(httpretty.last_request()) == {'user_id': [str(self.user.id)]} - - expected_event_name = 'edx.forum.thread.reported' if new_flagged else 'edx.forum.thread.unreported' - expected_event_data = { - 'body': 'Original body', - 'id': 'test_thread', - 'content_type': 'Post', - 'commentable_id': 'original_topic', - 'url': '', - 'user_course_roles': [], - 'user_forums_roles': [FORUM_ROLE_STUDENT], - 'target_username': self.user.username, - 'title_truncated': False, - 'title': 'Original Title', - 'thread_type': 'discussion', - 'group_id': None, - 'truncated': False, - } - if not new_flagged: - expected_event_data['reported_status_cleared'] = False - - actual_event_name, actual_event_data = mock_emit.call_args[0] - self.assertEqual(actual_event_name, expected_event_name) - self.assertEqual(actual_event_data, expected_event_data) - - @ddt.data( - (False, True), - (True, True), - ) - @ddt.unpack - @mock.patch("eventtracking.tracker.emit") - def test_thread_un_abuse_flag_for_moderator_role(self, is_author, remove_all, mock_emit): - """ - Test un-abuse flag for moderator role. - - When moderator unflags a reported thread, it should - pass the "all" flag to the api. This will indicate - to the api to clear all abuse_flaggers, and mark the - thread as unreported. - """ - _assign_role_to_user(user=self.user, course_id=self.course.id, role=FORUM_ROLE_ADMINISTRATOR) - self.register_get_user_response(self.user) - self.register_thread_flag_response("test_thread") - self.register_thread({"abuse_flaggers": ["11"], "user_id": str(self.user.id) if is_author else "12"}) - data = {"abuse_flagged": False} - update_thread(self.request, "test_thread", data) - assert httpretty.last_request().method == 'PUT' - query_params = {'user_id': [str(self.user.id)]} - if remove_all: - query_params.update({'all': ['True']}) - assert parsed_body(httpretty.last_request()) == query_params - - expected_event_name = 'edx.forum.thread.unreported' - expected_event_data = { - 'body': 'Original body', - 'id': 'test_thread', - 'content_type': 'Post', - 'commentable_id': 'original_topic', - 'url': '', - 'user_course_roles': [], - 'user_forums_roles': [FORUM_ROLE_STUDENT, FORUM_ROLE_ADMINISTRATOR], - 'target_username': self.user.username, - 'title_truncated': False, - 'title': 'Original Title', - 'reported_status_cleared': False, - 'thread_type': 'discussion', - 'group_id': None, - 'truncated': False, - } - - actual_event_name, actual_event_data = mock_emit.call_args[0] - self.assertEqual(actual_event_name, expected_event_name) - self.assertEqual(actual_event_data, expected_event_data) - def test_invalid_field(self): self.register_thread() with pytest.raises(ValidationError) as assertion: @@ -3153,6 +2440,22 @@ class UpdateCommentTest( self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") @@ -3377,224 +2680,6 @@ class UpdateCommentTest( assert expected_error assert err.message_dict == {'endorsed': ['This field is not editable.']} - @ddt.data(*itertools.product([True, False], [True, False])) - @ddt.unpack - @mock.patch("eventtracking.tracker.emit") - def test_voted(self, current_vote_status, new_vote_status, mock_emit): - """ - Test attempts to edit the "voted" field. - - current_vote_status indicates whether the comment should be upvoted at - the start of the test. new_vote_status indicates the value for the - "voted" field in the update. If current_vote_status and new_vote_status - are the same, no update should be made. Otherwise, a vote should be PUT - or DELETEd according to the new_vote_status value. - """ - vote_count = 0 - user1, request1 = self.create_user_with_request() - if current_vote_status: - self.register_get_user_response(user1, upvoted_ids=["test_comment"]) - vote_count = 1 - self.register_comment_votes_response("test_comment") - self.register_comment(overrides={"votes": {"up_count": vote_count}}) - data = {"voted": new_vote_status} - result = update_comment(request1, "test_comment", data) - assert result['vote_count'] == (1 if new_vote_status else 0) - assert result['voted'] == new_vote_status - last_request_path = urlparse(httpretty.last_request().path).path # lint-amnesty, pylint: disable=no-member - votes_url = "/api/v1/comments/test_comment/votes" - if current_vote_status == new_vote_status: - assert last_request_path != votes_url - else: - assert last_request_path == votes_url - assert httpretty.last_request().method == ('PUT' if new_vote_status else 'DELETE') - actual_request_data = ( - parsed_body(httpretty.last_request()) if new_vote_status else - parse_qs(urlparse(httpretty.last_request().path).query) # lint-amnesty, pylint: disable=no-member - ) - actual_request_data.pop("request_id", None) - expected_request_data = {"user_id": [str(user1.id)]} - if new_vote_status: - expected_request_data["value"] = ["up"] - assert actual_request_data == expected_request_data - - event_name, event_data = mock_emit.call_args[0] - assert event_name == 'edx.forum.response.voted' - - assert event_data == { - 'undo_vote': (not new_vote_status), - 'url': '', - 'target_username': self.user.username, - 'vote_value': 'up', - 'user_forums_roles': [FORUM_ROLE_STUDENT], - 'user_course_roles': [], - 'commentable_id': 'dummy', - 'id': 'test_comment' - } - - @ddt.data(*itertools.product([True, False], [True, False], [True, False])) - @ddt.unpack - def test_vote_count(self, current_vote_status, first_vote, second_vote): - """ - Tests vote_count increases and decreases correctly from the same user - """ - #setup - starting_vote_count = 0 - user1, request1 = self.create_user_with_request() - if current_vote_status: - self.register_get_user_response(user1, upvoted_ids=["test_comment"]) - starting_vote_count = 1 - self.register_comment_votes_response("test_comment") - self.register_comment(overrides={"votes": {"up_count": starting_vote_count}}) - - #first vote - data = {"voted": first_vote} - result = update_comment(request1, "test_comment", data) - self.register_comment(overrides={"voted": first_vote}) - assert result['vote_count'] == (1 if first_vote else 0) - - #second vote - data = {"voted": second_vote} - result = update_comment(request1, "test_comment", data) - assert result['vote_count'] == (1 if second_vote else 0) - - @ddt.data(*itertools.product([True, False], [True, False], [True, False], [True, False])) - @ddt.unpack - def test_vote_count_two_users( - self, - current_user1_vote, - current_user2_vote, - user1_vote, - user2_vote - ): - """ - Tests vote_count increases and decreases correctly from different users - """ - user1, request1 = self.create_user_with_request() - user2, request2 = self.create_user_with_request() - - vote_count = 0 - if current_user1_vote: - self.register_get_user_response(user1, upvoted_ids=["test_comment"]) - vote_count += 1 - if current_user2_vote: - self.register_get_user_response(user2, upvoted_ids=["test_comment"]) - vote_count += 1 - - for (current_vote, user_vote, request) in \ - [(current_user1_vote, user1_vote, request1), - (current_user2_vote, user2_vote, request2)]: - - self.register_comment_votes_response("test_comment") - self.register_comment(overrides={"votes": {"up_count": vote_count}}) - - data = {"voted": user_vote} - result = update_comment(request, "test_comment", data) - if current_vote == user_vote: - assert result['vote_count'] == vote_count - elif user_vote: - vote_count += 1 - assert result['vote_count'] == vote_count - self.register_get_user_response(self.user, upvoted_ids=["test_comment"]) - else: - vote_count -= 1 - assert result['vote_count'] == vote_count - self.register_get_user_response(self.user, upvoted_ids=[]) - - @ddt.data(*itertools.product([True, False], [True, False])) - @ddt.unpack - @mock.patch("eventtracking.tracker.emit") - def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit): - """ - Test attempts to edit the "abuse_flagged" field. - - old_flagged indicates whether the comment should be flagged at the start - of the test. new_flagged indicates the value for the "abuse_flagged" - field in the update. If old_flagged and new_flagged are the same, no - update should be made. Otherwise, a PUT should be made to the flag or - or unflag endpoint according to the new_flagged value. - """ - self.register_get_user_response(self.user) - self.register_comment_flag_response("test_comment") - self.register_comment({"abuse_flaggers": [str(self.user.id)] if old_flagged else []}) - data = {"abuse_flagged": new_flagged} - result = update_comment(self.request, "test_comment", data) - assert result['abuse_flagged'] == new_flagged - last_request_path = urlparse(httpretty.last_request().path).path # lint-amnesty, pylint: disable=no-member - flag_url = "/api/v1/comments/test_comment/abuse_flag" - unflag_url = "/api/v1/comments/test_comment/abuse_unflag" - if old_flagged == new_flagged: - assert last_request_path != flag_url - assert last_request_path != unflag_url - else: - assert last_request_path == (flag_url if new_flagged else unflag_url) - assert httpretty.last_request().method == 'PUT' - assert parsed_body(httpretty.last_request()) == {'user_id': [str(self.user.id)]} - - expected_event_name = 'edx.forum.response.reported' if new_flagged else 'edx.forum.response.unreported' - expected_event_data = { - 'body': 'Original body', - 'id': 'test_comment', - 'content_type': 'Response', - 'commentable_id': 'dummy', - 'url': '', - 'truncated': False, - 'user_course_roles': [], - 'user_forums_roles': [FORUM_ROLE_STUDENT], - 'target_username': self.user.username, - } - if not new_flagged: - expected_event_data['reported_status_cleared'] = False - - actual_event_name, actual_event_data = mock_emit.call_args[0] - self.assertEqual(actual_event_name, expected_event_name) - self.assertEqual(actual_event_data, expected_event_data) - - @ddt.data( - (False, True), - (True, True), - ) - @ddt.unpack - @mock.patch("eventtracking.tracker.emit") - def test_comment_un_abuse_flag_for_moderator_role(self, is_author, remove_all, mock_emit): - """ - Test un-abuse flag for moderator role. - - When moderator unflags a reported comment, it should - pass the "all" flag to the api. This will indicate - to the api to clear all abuse_flaggers, and mark the - comment as unreported. - """ - _assign_role_to_user(user=self.user, course_id=self.course.id, role=FORUM_ROLE_ADMINISTRATOR) - self.register_get_user_response(self.user) - self.register_comment_flag_response("test_comment") - self.register_comment({"abuse_flaggers": ["11"], "user_id": str(self.user.id) if is_author else "12"}) - data = {"abuse_flagged": False} - update_comment(self.request, "test_comment", data) - assert httpretty.last_request().method == 'PUT' - query_params = {'user_id': [str(self.user.id)]} - if remove_all: - query_params.update({'all': ['True']}) - assert parsed_body(httpretty.last_request()) == query_params - - expected_event_name = 'edx.forum.response.unreported' - expected_event_data = { - 'body': 'Original body', - 'id': 'test_comment', - 'content_type': 'Response', - 'commentable_id': 'dummy', - 'truncated': False, - 'url': '', - 'user_course_roles': [], - 'user_forums_roles': [FORUM_ROLE_STUDENT, FORUM_ROLE_ADMINISTRATOR], - 'target_username': self.user.username, - 'reported_status_cleared': False, - } - - actual_event_name, actual_event_data = mock_emit.call_args[0] - self.assertEqual(actual_event_name, expected_event_name) - self.assertEqual(actual_event_data, expected_event_data) - @ddt.data( FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, @@ -3670,6 +2755,22 @@ class DeleteThreadTest( httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") @@ -3823,6 +2924,22 @@ class DeleteCommentTest( httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") @@ -3991,6 +3108,17 @@ class RetrieveThreadTest( httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/test_path") diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py new file mode 100644 index 0000000000..4efadd6385 --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/tests/test_api_v2.py @@ -0,0 +1,1693 @@ +# pylint: skip-file +""" +Tests for the internal interface of the Discussion API (rest_api/api.py). + +This module directly tests the internal API functions of the Discussion API, such as create_thread, +create_comment, update_thread, update_comment, and related helpers, by invoking them with various data and request objects. +""" + +import itertools +import random +from datetime import datetime, timedelta +from unittest import mock +from urllib.parse import parse_qs, urlencode, urlparse, urlunparse + +import ddt +import httpretty +import pytest +from django.test import override_settings +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.test.client import RequestFactory +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import CourseLocator +from pytz import UTC +from rest_framework.exceptions import PermissionDenied + +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, + SharedModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory +from xmodule.partitions.partitions import Group, UserPartition + +from common.djangoapps.student.tests.factories import ( + AdminFactory, + BetaTesterFactory, + CourseEnrollmentFactory, + StaffFactory, + UserFactory, +) +from common.djangoapps.util.testing import UrlResetMixin +from common.test.utils import MockSignalHandlerMixin, disable_signal +from lms.djangoapps.discussion.django_comment_client.tests.utils import ( + ForumsEnableMixin, +) +from lms.djangoapps.discussion.tests.utils import ( + make_minimal_cs_comment, + make_minimal_cs_thread, +) +from lms.djangoapps.discussion.rest_api import api +from lms.djangoapps.discussion.rest_api.api import ( + create_comment, + create_thread, + delete_comment, + delete_thread, + get_comment_list, + get_course, + get_course_topics, + get_course_topics_v2, + get_thread, + get_thread_list, + get_user_comments, + update_comment, + update_thread, +) +from lms.djangoapps.discussion.rest_api.exceptions import ( + CommentNotFoundError, + DiscussionBlackOutException, + DiscussionDisabledError, + ThreadNotFoundError, +) +from lms.djangoapps.discussion.rest_api.serializers import TopicOrdering +from lms.djangoapps.discussion.rest_api.tests.utils import ( + CommentsServiceMockMixin, + ForumMockUtilsMixin, + make_paginated_api_response, + parsed_body, +) +from openedx.core.djangoapps.course_groups.models import CourseUserGroupPartitionGroup +from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory +from openedx.core.djangoapps.discussions.models import ( + DiscussionsConfiguration, + DiscussionTopicLink, + Provider, + PostingRestriction, +) +from openedx.core.djangoapps.discussions.tasks import ( + update_discussions_settings_from_course_task, +) +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_GROUP_MODERATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_STUDENT, + Role, +) +from openedx.core.lib.exceptions import CourseNotFoundError, PageNotFoundError + +User = get_user_model() + + +def _remove_discussion_tab(course, user_id): + """ + Remove the discussion tab for the course. + + user_id is passed to the modulestore as the editor of the xblock. + """ + course.tabs = [tab for tab in course.tabs if not tab.type == "discussion"] + modulestore().update_item(course, user_id) + + +def _discussion_disabled_course_for(user): + """ + Create and return a course with discussions disabled. + + The user passed in will be enrolled in the course. + """ + course_with_disabled_forums = CourseFactory.create() + CourseEnrollmentFactory.create(user=user, course_id=course_with_disabled_forums.id) + _remove_discussion_tab(course_with_disabled_forums, user.id) + + return course_with_disabled_forums + + +def _assign_role_to_user(user, course_id, role): + """ + Assign a discussion role to a user for a given course. + + Arguments: + user: User to assign role to + course_id: Course id of the course user will be assigned role in + role: Role assigned to user for course + """ + role = Role.objects.create(name=role, course_id=course_id) + role.users.set([user]) + + +def _create_course_and_cohort_with_user_role(course_is_cohorted, user, role_name): + """ + Creates a course with the value of `course_is_cohorted`, plus `always_cohort_inline_discussions` + set to True (which is no longer the default value). Then 1) enrolls the user in that course, + 2) creates a cohort that the user is placed in, and 3) adds the user to the given role. + + Returns: a tuple of the created course and the created cohort + """ + cohort_course = CourseFactory.create( + cohort_config={ + "cohorted": course_is_cohorted, + "always_cohort_inline_discussions": True, + } + ) + CourseEnrollmentFactory.create(user=user, course_id=cohort_course.id) + cohort = CohortFactory.create(course_id=cohort_course.id, users=[user]) + _assign_role_to_user(user=user, course_id=cohort_course.id, role=role_name) + + return [cohort_course, cohort] + + +def _set_course_discussion_blackout(course, user_id): + """ + Set the blackout period for course discussions. + + Arguments: + course: Course for which blackout period is set + user_id: User id of user enrolled in the course + """ + course.discussion_blackouts = [ + datetime.now(UTC) - timedelta(days=3), + datetime.now(UTC) + timedelta(days=3), + ] + configuration = DiscussionsConfiguration.get(course.id) + configuration.posting_restrictions = PostingRestriction.SCHEDULED + configuration.save() + modulestore().update_item(course, user_id) + + +@ddt.ddt +@disable_signal(api, "thread_created") +@disable_signal(api, "thread_voted") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class CreateThreadTest( + ForumsEnableMixin, + UrlResetMixin, + SharedModuleStoreTestCase, + MockSignalHandlerMixin, + ForumMockUtilsMixin, +): + """Tests for create_thread""" + + LONG_TITLE = ( + "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. " + "Aenean commodo ligula eget dolor. Aenean massa. Cum sociis " + "natoque penatibus et magnis dis parturient montes, nascetur " + "ridiculus mus. Donec quam felis, ultricies nec, " + "pellentesque eu, pretium quis, sem. Nulla consequat massa " + "quis enim. Donec pede justo, fringilla vel, aliquet nec, " + "vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet " + "a, venenatis vitae, justo. Nullam dictum felis eu pede " + "mollis pretium. Integer tincidunt. Cras dapibus. Vivamus " + "elementum semper nisi. Aenean vulputate eleifend tellus. " + "Aenean leo ligula, porttitor eu, consequat vitae, eleifend " + "ac, enim. Aliquam lorem ante, dapibus in, viverra quis, " + "feugiat a, tellus. Phasellus viverra nulla ut metus varius " + "laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies " + "nisi vel augue. Curabitur ullamcorper ultricies nisi. Nam " + "eget dui. Etiam rhoncus. Maecenas tempus, tellus eget " + "condimentum rhoncus, sem quam semper libero, sit amet " + "adipiscing sem neque sed ipsum. Nam quam nunc, blandit vel, " + "luctus pulvinar, hendrerit id, lorem. Maecenas nec odio et " + "ante tincidunt tempus. Donec vitae sapien ut libero " + "venenatis faucibus. Nullam quis ante. Etiam sit amet orci " + "eget eros faucibus tincidunt. Duis leo. Sed fringilla " + "mauris sit amet nibh. Donec sodales sagittis magna. Sed " + "consequat, leo eget bibendum sodales, augue velit cursus " + "nunc, quis gravida magna mi a libero. Fusce vulputate " + "eleifend sapien. Vestibulum purus quam, scelerisque ut, " + "mollis sed, nonummy id, metus. Nullam accumsan lorem in " + "dui. Cras ultricies mi eu turpis hendrerit fringilla. " + "Vestibulum ante ipsum primis in faucibus orci luctus et " + "ultrices posuere cubilia Curae; In ac dui quis mi " + "consectetuer lacinia. Nam pretium turpis et arcu. Duis arcu " + "tortor, suscipit eget, imperdiet nec, imperdiet iaculis, " + "ipsum. Sed aliquam ultrices mauris. Integer ante arcu, " + "accumsan a, consectetuer eget, posuere ut, mauris. Praesent " + "adipiscing. Phasellus ullamcorper ipsum rutrum nunc. Nunc " + "nonummy metus." + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/test_path") + self.request.user = self.user + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + self.minimal_data = { + "course_id": str(self.course.id), + "topic_id": "test_topic", + "type": "discussion", + "title": "Test Title", + "raw_body": "Test body", + } + + def test_abuse_flagged(self): + self.register_post_thread_response( + {"id": "test_id", "username": self.user.username} + ) + self.register_thread_flag_response("test_id") + data = self.minimal_data.copy() + data["abuse_flagged"] = "True" + result = create_thread(self.request, data) + assert result["abuse_flagged"] is True + + self.check_mock_called("update_thread_flag") + params = { + "thread_id": "test_id", + "action": "flag", + "user_id": "1", + "course_id": str(self.course.id), + } + self.check_mock_called_with("update_thread_flag", -1, **params) + + +@ddt.ddt +@disable_signal(api, "comment_created") +@disable_signal(api, "comment_voted") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +@mock.patch( + "lms.djangoapps.discussion.signals.handlers.send_response_notifications", + new=mock.Mock(), +) +class CreateCommentTest( + ForumsEnableMixin, + UrlResetMixin, + SharedModuleStoreTestCase, + MockSignalHandlerMixin, + ForumMockUtilsMixin, +): + """Tests for create_comment""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + self.course = CourseFactory.create() + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/test_path") + self.request.user = self.user + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + self.register_get_thread_response( + make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + } + ) + ) + self.minimal_data = { + "thread_id": "test_thread", + "raw_body": "Test body", + } + + mock_response = { + "collection": [], + "page": 1, + "num_pages": 1, + "subscriptions_count": 1, + "corrected_text": None, + } + self.register_get_subscriptions("cohort_thread", mock_response) + self.register_get_subscriptions("test_thread", mock_response) + + def test_abuse_flagged(self): + self.register_post_comment_response( + {"id": "test_comment", "username": self.user.username}, "test_thread" + ) + self.register_comment_flag_response("test_comment") + data = self.minimal_data.copy() + data["abuse_flagged"] = "True" + result = create_comment(self.request, data) + assert result["abuse_flagged"] is True + + self.check_mock_called("update_comment_flag") + params = { + "comment_id": "test_comment", + "action": "flag", + "user_id": "1", + "course_id": str(self.course.id), + } + self.check_mock_called_with("update_comment_flag", -1, **params) + + +@ddt.ddt +@disable_signal(api, "thread_edited") +@disable_signal(api, "thread_voted") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class UpdateThreadTest( + ForumsEnableMixin, + UrlResetMixin, + SharedModuleStoreTestCase, + MockSignalHandlerMixin, + ForumMockUtilsMixin, +): + """Tests for update_thread""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/test_path") + self.request.user = self.user + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + + def register_thread(self, overrides=None): + """ + Make a thread with appropriate data overridden by the overrides + parameter and register mock responses for both GET and PUT on its + endpoint. + """ + cs_data = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "original_topic", + "username": self.user.username, + "user_id": str(self.user.id), + "thread_type": "discussion", + "title": "Original Title", + "body": "Original body", + } + ) + cs_data.update(overrides or {}) + self.register_get_thread_response(cs_data) + self.register_put_thread_response(cs_data) + + def create_user_with_request(self): + """ + Create a user and an associated request for a specific course enrollment. + """ + user = UserFactory.create() + self.register_get_user_response(user) + request = RequestFactory().get("/test_path") + request.user = user + CourseEnrollmentFactory.create(user=user, course_id=self.course.id) + return user, request + + @ddt.data(*itertools.product([True, False], [True, False])) + @ddt.unpack + @mock.patch("eventtracking.tracker.emit") + def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit): + """ + Test attempts to edit the "abuse_flagged" field. + + old_flagged indicates whether the thread should be flagged at the start + of the test. new_flagged indicates the value for the "abuse_flagged" + field in the update. If old_flagged and new_flagged are the same, no + update should be made. Otherwise, a PUT should be made to the flag or + or unflag endpoint according to the new_flagged value. + """ + self.register_get_user_response(self.user) + self.register_thread_flag_response("test_thread") + self.register_thread( + {"abuse_flaggers": [str(self.user.id)] if old_flagged else []} + ) + data = {"abuse_flagged": new_flagged} + result = update_thread(self.request, "test_thread", data) + assert result["abuse_flagged"] == new_flagged + + flag_func_calls = self.get_mock_func_calls("update_thread_flag") + last_function_args = flag_func_calls[-1] if flag_func_calls else None + + if old_flagged == new_flagged: + assert last_function_args is None + else: + assert last_function_args[1]["action"] == ( + "flag" if new_flagged else "unflag" + ) + params = { + "thread_id": "test_thread", + "action": "flag" if new_flagged else "unflag", + "user_id": "1", + "course_id": str(self.course.id), + } + if not new_flagged: + params["update_all"] = False + self.check_mock_called_with("update_thread_flag", -1, **params) + + expected_event_name = ( + "edx.forum.thread.reported" + if new_flagged + else "edx.forum.thread.unreported" + ) + expected_event_data = { + "body": "Original body", + "id": "test_thread", + "content_type": "Post", + "commentable_id": "original_topic", + "url": "", + "user_course_roles": [], + "user_forums_roles": [FORUM_ROLE_STUDENT], + "target_username": self.user.username, + "title_truncated": False, + "title": "Original Title", + "thread_type": "discussion", + "group_id": None, + "truncated": False, + } + if not new_flagged: + expected_event_data["reported_status_cleared"] = False + + actual_event_name, actual_event_data = mock_emit.call_args[0] + self.assertEqual(actual_event_name, expected_event_name) + self.assertEqual(actual_event_data, expected_event_data) + + @ddt.data( + (False, True), + (True, True), + ) + @ddt.unpack + @mock.patch("eventtracking.tracker.emit") + def test_thread_un_abuse_flag_for_moderator_role( + self, is_author, remove_all, mock_emit + ): + """ + Test un-abuse flag for moderator role. + + When moderator unflags a reported thread, it should + pass the "all" flag to the api. This will indicate + to the api to clear all abuse_flaggers, and mark the + thread as unreported. + """ + _assign_role_to_user( + user=self.user, course_id=self.course.id, role=FORUM_ROLE_ADMINISTRATOR + ) + self.register_get_user_response(self.user) + self.register_thread_flag_response("test_thread") + self.register_thread( + { + "abuse_flaggers": ["11"], + "user_id": str(self.user.id) if is_author else "12", + } + ) + data = {"abuse_flagged": False} + update_thread(self.request, "test_thread", data) + + params = { + "thread_id": "test_thread", + "action": "unflag", + "user_id": "1", + "update_all": True if remove_all else False, + "course_id": str(self.course.id), + } + + self.check_mock_called_with("update_thread_flag", -1, **params) + + expected_event_name = "edx.forum.thread.unreported" + expected_event_data = { + "body": "Original body", + "id": "test_thread", + "content_type": "Post", + "commentable_id": "original_topic", + "url": "", + "user_course_roles": [], + "user_forums_roles": [FORUM_ROLE_STUDENT, FORUM_ROLE_ADMINISTRATOR], + "target_username": self.user.username, + "title_truncated": False, + "title": "Original Title", + "reported_status_cleared": False, + "thread_type": "discussion", + "group_id": None, + "truncated": False, + } + + actual_event_name, actual_event_data = mock_emit.call_args[0] + self.assertEqual(actual_event_name, expected_event_name) + self.assertEqual(actual_event_data, expected_event_data) + + @ddt.data(*itertools.product([True, False], [True, False])) + @ddt.unpack + @mock.patch("eventtracking.tracker.emit") + def test_voted(self, current_vote_status, new_vote_status, mock_emit): + """ + Test attempts to edit the "voted" field. + + current_vote_status indicates whether the thread should be upvoted at + the start of the test. new_vote_status indicates the value for the + "voted" field in the update. If current_vote_status and new_vote_status + are the same, no update should be made. Otherwise, a vote should be PUT + or DELETEd according to the new_vote_status value. + """ + # setup + user1, request1 = self.create_user_with_request() + if current_vote_status: + self.register_get_user_response(user1, upvoted_ids=["test_thread"]) + self.register_thread_votes_response("test_thread") + self.register_thread() + data = {"voted": new_vote_status} + result = update_thread(request1, "test_thread", data) + assert result["voted"] == new_vote_status + + vote_update_func_calls = self.get_mock_func_calls("update_thread_votes") + last_function_args = ( + vote_update_func_calls[-1] if vote_update_func_calls else None + ) + + if current_vote_status == new_vote_status: + assert last_function_args is None + else: + if vote_update_func_calls: + assert last_function_args[1]["value"] == ( + "up" if new_vote_status else "down" + ) + params = { + "thread_id": "test_thread", + "value": "up" if new_vote_status else "down", + "user_id": str(user1.id), + "course_id": str(self.course.id), + } + self.check_mock_called_with("update_thread_votes", -1, **params) + else: + params = { + "thread_id": "test_thread", + "user_id": str(user1.id), + "course_id": str(self.course.id), + } + self.check_mock_called_with("delete_thread_vote", -1, **params) + event_name, event_data = mock_emit.call_args[0] + assert event_name == "edx.forum.thread.voted" + assert event_data == { + "undo_vote": (not new_vote_status), + "url": "", + "target_username": self.user.username, + "vote_value": "up", + "user_forums_roles": [FORUM_ROLE_STUDENT], + "user_course_roles": [], + "commentable_id": "original_topic", + "id": "test_thread", + } + + @ddt.data(*itertools.product([True, False], [True, False], [True, False])) + @ddt.unpack + def test_vote_count(self, current_vote_status, first_vote, second_vote): + """ + Tests vote_count increases and decreases correctly from the same user + """ + # setup + starting_vote_count = 0 + user, request = self.create_user_with_request() + if current_vote_status: + self.register_get_user_response(user, upvoted_ids=["test_thread"]) + starting_vote_count = 1 + self.register_thread_votes_response("test_thread") + self.register_thread(overrides={"votes": {"up_count": starting_vote_count}}) + + # first vote + data = {"voted": first_vote} + result = update_thread(request, "test_thread", data) + self.register_thread(overrides={"voted": first_vote}) + assert result["vote_count"] == (1 if first_vote else 0) + + # second vote + # In the previous tests, where we mocked request objects, + # the mocked user API returned a user with upvoted_ids=[]. In our case, + # we have used register_get_user_response again to set upvoted_ids to None. + data = {"voted": second_vote} + self.register_get_user_response(user) + self.register_thread(overrides={"voted": False}) + result = update_thread(request, "test_thread", data) + assert result["vote_count"] == (1 if second_vote else 0) + + @ddt.data( + *itertools.product([True, False], [True, False], [True, False], [True, False]) + ) + @ddt.unpack + def test_vote_count_two_users( + self, current_user1_vote, current_user2_vote, user1_vote, user2_vote + ): + """ + Tests vote_count increases and decreases correctly from different users + """ + # setup + user1, request1 = self.create_user_with_request() + user2, request2 = self.create_user_with_request() + + vote_count = 0 + if current_user1_vote: + self.register_get_user_response(user1, upvoted_ids=["test_thread"]) + vote_count += 1 + if current_user2_vote: + self.register_get_user_response(user2, upvoted_ids=["test_thread"]) + vote_count += 1 + + for current_vote, user_vote, request in [ + (current_user1_vote, user1_vote, request1), + (current_user2_vote, user2_vote, request2), + ]: + + self.register_thread_votes_response("test_thread") + self.register_thread(overrides={"votes": {"up_count": vote_count}}) + + data = {"voted": user_vote} + result = update_thread(request, "test_thread", data) + if current_vote == user_vote: + assert result["vote_count"] == vote_count + elif user_vote: + vote_count += 1 + assert result["vote_count"] == vote_count + self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) + else: + vote_count -= 1 + assert result["vote_count"] == vote_count + self.register_get_user_response(self.user, upvoted_ids=[]) + + +@ddt.ddt +@disable_signal(api, "comment_edited") +@disable_signal(api, "comment_voted") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class UpdateCommentTest( + ForumsEnableMixin, + UrlResetMixin, + SharedModuleStoreTestCase, + MockSignalHandlerMixin, + ForumMockUtilsMixin, +): + """Tests for update_comment""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/test_path") + self.request.user = self.user + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + + def register_comment(self, overrides=None, thread_overrides=None, course=None): + """ + Make a comment with appropriate data overridden by the overrides + parameter and register mock responses for both GET and PUT on its + endpoint. Also mock GET for the related thread with thread_overrides. + """ + if course is None: + course = self.course + + cs_thread_data = make_minimal_cs_thread( + {"id": "test_thread", "course_id": str(course.id)} + ) + cs_thread_data.update(thread_overrides or {}) + self.register_get_thread_response(cs_thread_data) + cs_comment_data = make_minimal_cs_comment( + { + "id": "test_comment", + "course_id": cs_thread_data["course_id"], + "thread_id": cs_thread_data["id"], + "username": self.user.username, + "user_id": str(self.user.id), + "created_at": "2015-06-03T00:00:00Z", + "updated_at": "2015-06-03T00:00:00Z", + "body": "Original body", + } + ) + cs_comment_data.update(overrides or {}) + self.register_get_comment_response(cs_comment_data) + self.register_put_comment_response(cs_comment_data) + + def create_user_with_request(self): + """ + Create a user and an associated request for a specific course enrollment. + """ + user = UserFactory.create() + self.register_get_user_response(user) + request = RequestFactory().get("/test_path") + request.user = user + CourseEnrollmentFactory.create(user=user, course_id=self.course.id) + return user, request + + @ddt.data(*itertools.product([True, False], [True, False])) + @ddt.unpack + @mock.patch("eventtracking.tracker.emit") + def test_abuse_flagged(self, old_flagged, new_flagged, mock_emit): + """ + Test attempts to edit the "abuse_flagged" field. + + old_flagged indicates whether the comment should be flagged at the start + of the test. new_flagged indicates the value for the "abuse_flagged" + field in the update. If old_flagged and new_flagged are the same, no + update should be made. Otherwise, a PUT should be made to the flag or + or unflag endpoint according to the new_flagged value. + """ + self.register_get_user_response(self.user) + self.register_comment_flag_response("test_comment") + self.register_comment( + {"abuse_flaggers": [str(self.user.id)] if old_flagged else []} + ) + data = {"abuse_flagged": new_flagged} + result = update_comment(self.request, "test_comment", data) + assert result["abuse_flagged"] == new_flagged + flag_func_calls = self.get_mock_func_calls("update_comment_flag") + last_function_args = flag_func_calls[-1] if flag_func_calls else None + + if old_flagged == new_flagged: + assert last_function_args is None + else: + assert last_function_args[1]["action"] == ( + "flag" if new_flagged else "unflag" + ) + params = { + "comment_id": "test_comment", + "action": "flag" if new_flagged else "unflag", + "user_id": "1", + "course_id": str(self.course.id), + } + if not new_flagged: + params["update_all"] = False + self.check_mock_called_with("update_comment_flag", -1, **params) + + expected_event_name = ( + "edx.forum.response.reported" + if new_flagged + else "edx.forum.response.unreported" + ) + expected_event_data = { + "body": "Original body", + "id": "test_comment", + "content_type": "Response", + "commentable_id": "dummy", + "url": "", + "truncated": False, + "user_course_roles": [], + "user_forums_roles": [FORUM_ROLE_STUDENT], + "target_username": self.user.username, + } + if not new_flagged: + expected_event_data["reported_status_cleared"] = False + + actual_event_name, actual_event_data = mock_emit.call_args[0] + self.assertEqual(actual_event_name, expected_event_name) + self.assertEqual(actual_event_data, expected_event_data) + + @ddt.data( + (False, True), + (True, True), + ) + @ddt.unpack + @mock.patch("eventtracking.tracker.emit") + def test_comment_un_abuse_flag_for_moderator_role( + self, is_author, remove_all, mock_emit + ): + """ + Test un-abuse flag for moderator role. + + When moderator unflags a reported comment, it should + pass the "all" flag to the api. This will indicate + to the api to clear all abuse_flaggers, and mark the + comment as unreported. + """ + _assign_role_to_user( + user=self.user, course_id=self.course.id, role=FORUM_ROLE_ADMINISTRATOR + ) + self.register_get_user_response(self.user) + self.register_comment_flag_response("test_comment") + self.register_comment( + { + "abuse_flaggers": ["11"], + "user_id": str(self.user.id) if is_author else "12", + } + ) + data = {"abuse_flagged": False} + update_comment(self.request, "test_comment", data) + + params = { + "comment_id": "test_comment", + "action": "unflag", + "user_id": "1", + "update_all": True if remove_all else False, + "course_id": str(self.course.id), + } + self.check_mock_called_with("update_comment_flag", -1, **params) + + expected_event_name = "edx.forum.response.unreported" + expected_event_data = { + "body": "Original body", + "id": "test_comment", + "content_type": "Response", + "commentable_id": "dummy", + "truncated": False, + "url": "", + "user_course_roles": [], + "user_forums_roles": [FORUM_ROLE_STUDENT, FORUM_ROLE_ADMINISTRATOR], + "target_username": self.user.username, + "reported_status_cleared": False, + } + + actual_event_name, actual_event_data = mock_emit.call_args[0] + self.assertEqual(actual_event_name, expected_event_name) + self.assertEqual(actual_event_data, expected_event_data) + + @ddt.data(*itertools.product([True, False], [True, False])) + @ddt.unpack + @mock.patch("eventtracking.tracker.emit") + def test_voted(self, current_vote_status, new_vote_status, mock_emit): + """ + Test attempts to edit the "voted" field. + + current_vote_status indicates whether the comment should be upvoted at + the start of the test. new_vote_status indicates the value for the + "voted" field in the update. If current_vote_status and new_vote_status + are the same, no update should be made. Otherwise, a vote should be PUT + or DELETEd according to the new_vote_status value. + """ + vote_count = 0 + user1, request1 = self.create_user_with_request() + if current_vote_status: + self.register_get_user_response(user1, upvoted_ids=["test_comment"]) + vote_count = 1 + self.register_comment_votes_response("test_comment") + self.register_comment(overrides={"votes": {"up_count": vote_count}}) + data = {"voted": new_vote_status} + result = update_comment(request1, "test_comment", data) + assert result["vote_count"] == (1 if new_vote_status else 0) + assert result["voted"] == new_vote_status + vote_update_func_calls = self.get_mock_func_calls("update_comment_votes") + last_function_args = ( + vote_update_func_calls[-1] if vote_update_func_calls else None + ) + if current_vote_status == new_vote_status: + assert last_function_args is None + else: + + if vote_update_func_calls: + assert last_function_args[1]["value"] == ( + "up" if new_vote_status else "down" + ) + params = { + "comment_id": "test_comment", + "value": "up" if new_vote_status else "down", + "user_id": str(user1.id), + "course_id": str(self.course.id), + } + self.check_mock_called_with("update_comment_votes", -1, **params) + else: + params = { + "comment_id": "test_comment", + "user_id": str(user1.id), + "course_id": str(self.course.id), + } + self.check_mock_called_with("delete_comment_vote", -1, **params) + + event_name, event_data = mock_emit.call_args[0] + assert event_name == "edx.forum.response.voted" + + assert event_data == { + "undo_vote": (not new_vote_status), + "url": "", + "target_username": self.user.username, + "vote_value": "up", + "user_forums_roles": [FORUM_ROLE_STUDENT], + "user_course_roles": [], + "commentable_id": "dummy", + "id": "test_comment", + } + + @ddt.data(*itertools.product([True, False], [True, False], [True, False])) + @ddt.unpack + def test_vote_count(self, current_vote_status, first_vote, second_vote): + """ + Tests vote_count increases and decreases correctly from the same user + """ + # setup + starting_vote_count = 0 + user1, request1 = self.create_user_with_request() + if current_vote_status: + self.register_get_user_response(user1, upvoted_ids=["test_comment"]) + starting_vote_count = 1 + self.register_comment_votes_response("test_comment") + self.register_comment(overrides={"votes": {"up_count": starting_vote_count}}) + + # first vote + data = {"voted": first_vote} + result = update_comment(request1, "test_comment", data) + self.register_comment(overrides={"voted": first_vote}) + assert result["vote_count"] == (1 if first_vote else 0) + + # second vote + # In the previous tests, where we mocked request objects, + # the mocked user API returned a user with upvoted_ids=[]. In our case, + # we have used register_get_user_response again to set upvoted_ids to None. + data = {"voted": second_vote} + self.register_get_user_response(user1) + result = update_comment(request1, "test_comment", data) + assert result["vote_count"] == (1 if second_vote else 0) + + # TODO: Refactor test logic to avoid complex conditionals and in-test logic. + # Aim for simpler, more explicit test cases, even if it means more code, + # to reduce the risk of introducing logic bugs within the tests themselves. + @ddt.data( + *itertools.product([True, False], [True, False], [True, False], [True, False]) + ) + @ddt.unpack + def test_vote_count_two_users( + self, current_user1_vote, current_user2_vote, user1_vote, user2_vote + ): + """ + Tests vote_count increases and decreases correctly from different users + """ + user1, request1 = self.create_user_with_request() + user2, request2 = self.create_user_with_request() + + vote_count = 0 + if current_user1_vote: + self.register_get_user_response(user1, upvoted_ids=["test_comment"]) + vote_count += 1 + if current_user2_vote: + self.register_get_user_response(user2, upvoted_ids=["test_comment"]) + vote_count += 1 + + for current_vote, user_vote, request in [ + (current_user1_vote, user1_vote, request1), + (current_user2_vote, user2_vote, request2), + ]: + + self.register_comment_votes_response("test_comment") + self.register_comment(overrides={"votes": {"up_count": vote_count}}) + + data = {"voted": user_vote} + result = update_comment(request, "test_comment", data) + if current_vote == user_vote: + assert result["vote_count"] == vote_count + elif user_vote: + vote_count += 1 + assert result["vote_count"] == vote_count + self.register_get_user_response(self.user, upvoted_ids=["test_comment"]) + else: + vote_count -= 1 + assert result["vote_count"] == vote_count + self.register_get_user_response(self.user, upvoted_ids=[]) + + +@ddt.ddt +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class GetThreadListTest( + ForumsEnableMixin, ForumMockUtilsMixin, UrlResetMixin, SharedModuleStoreTestCase +): + """Test for get_thread_list""" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + """Stop patches after tests complete.""" + super().tearDownClass() + super().disposeForumMocks() + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + httpretty.reset() + httpretty.enable() + self.addCleanup(httpretty.reset) + self.addCleanup(httpretty.disable) + self.maxDiff = None # pylint: disable=invalid-name + self.user = UserFactory.create() + self.register_get_user_response(self.user) + self.request = RequestFactory().get("/test_path") + self.request.user = self.user + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + self.author = UserFactory.create() + self.course.cohort_config = {"cohorted": False} + modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) + self.cohort = CohortFactory.create(course_id=self.course.id) + + def get_thread_list( + self, + threads, + page=1, + page_size=1, + num_pages=1, + course=None, + topic_id_list=None, + ): + """ + Register the appropriate comments service response, then call + get_thread_list and return the result. + """ + course = course or self.course + self.register_get_threads_response(threads, page, num_pages) + ret = get_thread_list(self.request, course.id, page, page_size, topic_id_list) + return ret + + def test_nonexistent_course(self): + with pytest.raises(CourseNotFoundError): + get_thread_list( + self.request, + CourseLocator.from_string("course-v1:non+existent+course"), + 1, + 1, + ) + + def test_not_enrolled(self): + self.request.user = UserFactory.create() + with pytest.raises(CourseNotFoundError): + self.get_thread_list([]) + + def test_discussions_disabled(self): + with pytest.raises(DiscussionDisabledError): + self.get_thread_list([], course=_discussion_disabled_course_for(self.user)) + + def test_empty(self): + assert self.get_thread_list([], num_pages=0).data == { + "pagination": {"next": None, "previous": None, "num_pages": 0, "count": 0}, + "results": [], + "text_search_rewrite": None, + } + + def test_get_threads_by_topic_id(self): + self.get_thread_list([], topic_id_list=["topic_x", "topic_meow"]) + self.check_mock_called("get_user_threads") + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 1, + "commentable_ids": ["topic_x", "topic_meow"], + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_basic_query_params(self): + self.get_thread_list([], page=6, page_size=14) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 6, + "per_page": 14, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_thread_content(self): + self.course.cohort_config = {"cohorted": True} + modulestore().update_item(self.course, ModuleStoreEnum.UserID.test) + source_threads = [ + make_minimal_cs_thread( + { + "id": "test_thread_id_0", + "course_id": str(self.course.id), + "commentable_id": "topic_x", + "username": self.author.username, + "user_id": str(self.author.id), + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + "endorsed": True, + "read": True, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + } + ), + make_minimal_cs_thread( + { + "id": "test_thread_id_1", + "course_id": str(self.course.id), + "commentable_id": "topic_y", + "group_id": self.cohort.id, + "username": self.author.username, + "user_id": str(self.author.id), + "thread_type": "question", + "title": "Another Test Title", + "body": "More content", + "votes": {"up_count": 9}, + "comments_count": 18, + "created_at": "2015-04-28T22:22:22Z", + "updated_at": "2015-04-28T00:33:33Z", + } + ), + ] + expected_threads = [ + self.expected_thread_data( + { + "id": "test_thread_id_0", + "author": self.author.username, + "topic_id": "topic_x", + "vote_count": 4, + "comment_count": 6, + "unread_comment_count": 3, + "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_0", + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "has_endorsed": True, + "read": True, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "abuse_flagged_count": None, + "can_delete": False, + } + ), + self.expected_thread_data( + { + "id": "test_thread_id_1", + "author": self.author.username, + "topic_id": "topic_y", + "group_id": self.cohort.id, + "group_name": self.cohort.name, + "type": "question", + "title": "Another Test Title", + "raw_body": "More content", + "preview_body": "More content", + "rendered_body": "

    More content

    ", + "vote_count": 9, + "comment_count": 19, + "created_at": "2015-04-28T22:22:22Z", + "updated_at": "2015-04-28T00:33:33Z", + "comment_list_url": None, + "endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=True" + ), + "non_endorsed_comment_list_url": ( + "http://testserver/api/discussion/v1/comments/?thread_id=test_thread_id_1&endorsed=False" + ), + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "abuse_flagged_count": None, + "can_delete": False, + } + ), + ] + + expected_result = make_paginated_api_response( + results=expected_threads, + count=2, + num_pages=1, + next_link=None, + previous_link=None, + ) + expected_result.update({"text_search_rewrite": None}) + assert self.get_thread_list(source_threads).data == expected_result + + @ddt.data( + *itertools.product( + [ + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_STUDENT, + ], + [True, False], + ) + ) + @ddt.unpack + def test_request_group(self, role_name, course_is_cohorted): + cohort_course = CourseFactory.create( + cohort_config={"cohorted": course_is_cohorted} + ) + CourseEnrollmentFactory.create(user=self.user, course_id=cohort_course.id) + CohortFactory.create(course_id=cohort_course.id, users=[self.user]) + _assign_role_to_user(user=self.user, course_id=cohort_course.id, role=role_name) + self.get_thread_list([], course=cohort_course) + thread_func_params = self.get_mock_func_calls("get_user_threads")[-1][1] + actual_has_group = "group_id" in thread_func_params + expected_has_group = ( + course_is_cohorted and role_name in ( + FORUM_ROLE_STUDENT, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_GROUP_MODERATOR + ) + ) + assert actual_has_group == expected_has_group + + def test_pagination(self): + # N.B. Empty thread list is not realistic but convenient for this test + expected_result = make_paginated_api_response( + results=[], + count=0, + num_pages=3, + next_link="http://testserver/test_path?page=2", + previous_link=None, + ) + expected_result.update({"text_search_rewrite": None}) + assert self.get_thread_list([], page=1, num_pages=3).data == expected_result + + expected_result = make_paginated_api_response( + results=[], + count=0, + num_pages=3, + next_link="http://testserver/test_path?page=3", + previous_link="http://testserver/test_path?page=1", + ) + expected_result.update({"text_search_rewrite": None}) + assert self.get_thread_list([], page=2, num_pages=3).data == expected_result + + expected_result = make_paginated_api_response( + results=[], + count=0, + num_pages=3, + next_link=None, + previous_link="http://testserver/test_path?page=2", + ) + expected_result.update({"text_search_rewrite": None}) + assert self.get_thread_list([], page=3, num_pages=3).data == expected_result + + # Test page past the last one + self.register_get_threads_response([], page=3, num_pages=3) + with pytest.raises(PageNotFoundError): + get_thread_list(self.request, self.course.id, page=4, page_size=10) + + @ddt.data(None, "rewritten search string") + def test_text_search(self, text_search_rewrite): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": text_search_rewrite}) + self.register_get_threads_search_response([], text_search_rewrite, num_pages=0) + assert ( + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + text_search="test search string", + ).data + == expected_result + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "text": "test search string", + } + self.check_mock_called_with( + "search_threads", + -1, + **params, + ) + + def test_filter_threads_by_author(self): + thread = make_minimal_cs_thread() + self.register_get_threads_response([thread], page=1, num_pages=10) + thread_results = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + author=self.user.username, + ).data.get("results") + assert len(thread_results) == 1 + + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "author_id": str(self.user.id), + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_filter_threads_by_missing_author(self): + self.register_get_threads_response( + [make_minimal_cs_thread()], page=1, num_pages=10 + ) + results = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + author="a fake and missing username", + ).data.get("results") + assert len(results) == 0 + + @ddt.data("question", "discussion", None) + def test_thread_type(self, thread_type): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + + self.register_get_threads_response([], page=1, num_pages=0) + assert ( + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + thread_type=thread_type, + ).data + == expected_result + ) + + expected_last_query_params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "thread_type": thread_type, + } + + if thread_type is None: + del expected_last_query_params["thread_type"] + + self.check_mock_called_with( + "get_user_threads", + -1, + **expected_last_query_params, + ) + + @ddt.data(True, False, None) + def test_flagged(self, flagged_boolean): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + + self.register_get_threads_response([], page=1, num_pages=0) + assert ( + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + flagged=flagged_boolean, + ).data + == expected_result + ) + + expected_last_query_params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "flagged": flagged_boolean, + } + + if flagged_boolean is None: + del expected_last_query_params["flagged"] + + self.check_mock_called_with( + "get_user_threads", + -1, + **expected_last_query_params, + ) + + @ddt.data( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_MODERATOR, + FORUM_ROLE_COMMUNITY_TA, + ) + def test_flagged_count(self, role): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + + _assign_role_to_user(self.user, self.course.id, role=role) + + self.register_get_threads_response([], page=1, num_pages=0) + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + count_flagged=True, + ) + + expected_last_query_params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "count_flagged": True, + "page": 1, + "per_page": 10, + } + + self.check_mock_called_with( + "get_user_threads", -1, **expected_last_query_params + ) + + def test_flagged_count_denied(self): + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + + _assign_role_to_user(self.user, self.course.id, role=FORUM_ROLE_STUDENT) + + self.register_get_threads_response([], page=1, num_pages=0) + + with pytest.raises(PermissionDenied): + get_thread_list( + self.request, + self.course.id, + page=1, + page_size=10, + count_flagged=True, + ) + + def test_following(self): + self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0) + result = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=11, + following=True, + ).data + + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + assert result == expected_result + self.check_mock_called("get_user_subscriptions") + + params = { + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 11, + } + self.check_mock_called_with( + "get_user_subscriptions", -1, str(self.user.id), str(self.course.id), params + ) + + @ddt.data("unanswered", "unread") + def test_view_query(self, query): + self.register_get_threads_response([], page=1, num_pages=0) + result = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=11, + view=query, + ).data + + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + assert result == expected_result + self.check_mock_called("get_user_threads") + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 11, + query: True, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + @ddt.data( + ("last_activity_at", "activity"), + ("comment_count", "comments"), + ("vote_count", "votes"), + ) + @ddt.unpack + def test_order_by_query(self, http_query, cc_query): + """ + Tests the order_by parameter + + Arguments: + http_query (str): Query string sent in the http request + cc_query (str): Query string used for the comments client service + """ + self.register_get_threads_response([], page=1, num_pages=0) + result = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=11, + order_by=http_query, + ).data + + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + assert result == expected_result + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": cc_query, + "page": 1, + "per_page": 11, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_order_direction(self): + """ + Only "desc" is supported for order. Also, since it is simply swallowed, + it isn't included in the params. + """ + self.register_get_threads_response([], page=1, num_pages=0) + result = get_thread_list( + self.request, + self.course.id, + page=1, + page_size=11, + order_direction="desc", + ).data + + expected_result = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_result.update({"text_search_rewrite": None}) + assert result == expected_result + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 11, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_invalid_order_direction(self): + """ + Test with invalid order_direction (e.g. "asc") + """ + with pytest.raises(ValidationError) as assertion: + self.register_get_threads_response([], page=1, num_pages=0) + get_thread_list( # pylint: disable=expression-not-assigned + self.request, + self.course.id, + page=1, + page_size=11, + order_direction="asc", + ).data + assert "order_direction" in assertion.value.message_dict diff --git a/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py b/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py index 9e4a76aa40..5e0640c64e 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py @@ -47,7 +47,8 @@ class TestDiscussionNotificationSender(unittest.TestCase): self.assertEqual(context, { 'username': self.thread.username, 'content_type': expected_content_type, - 'content': 'Thread body' + 'content': 'Thread body', + 'email_content': 'Thread body', }) self.assertEqual(audience_filters, { 'discussion_roles': ['Administrator', 'Moderator', 'Community TA'] diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py index 8103eb6927..0333c62d73 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py @@ -9,6 +9,7 @@ from urllib.parse import urlparse import ddt import httpretty from django.test.client import RequestFactory +from django.test.utils import override_settings from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase @@ -17,7 +18,12 @@ from xmodule.modulestore.tests.factories import CourseFactory from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.util.testing import UrlResetMixin from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin -from lms.djangoapps.discussion.rest_api.serializers import CommentSerializer, ThreadSerializer, get_context +from lms.djangoapps.discussion.rest_api.serializers import ( + CommentSerializer, + ThreadSerializer, + filter_spam_urls_from_html, + get_context +) from lms.djangoapps.discussion.rest_api.tests.utils import ( CommentsServiceMockMixin, make_minimal_cs_comment, @@ -54,6 +60,12 @@ class SerializerTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetM httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.maxDiff = None # pylint: disable=invalid-name self.user = UserFactory.create() self.register_get_user_response(self.user) @@ -571,6 +583,12 @@ class ThreadSerializerDeserializationTest( httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/dummy") @@ -802,6 +820,22 @@ class CommentSerializerDeserializationTest(ForumsEnableMixin, CommentsServiceMoc httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create() self.register_get_user_response(self.user) self.request = RequestFactory().get("/dummy") @@ -1080,3 +1114,23 @@ class CommentSerializerDeserializationTest(ForumsEnableMixin, CommentsServiceMoc ) assert not serializer.is_valid() assert serializer.errors == {field: ['This field is not allowed in an update.']} + + +class FilterSpamTest(SharedModuleStoreTestCase): + """ + Tests for the filter_spam method + """ + @override_settings(DISCUSSION_SPAM_URLS=['example.com']) + def test_filter(self): + self.assertEqual( + filter_spam_urls_from_html('
    ')[0], + '
    abc
    ' + ) + self.assertEqual( + filter_spam_urls_from_html('
    example.com/abc/def
    ')[0], + '
    ' + ) + self.assertEqual( + filter_spam_urls_from_html('
    e x a m p l e . c o m / a b c / d e f
    ')[0], + '
    ' + ) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py index 3a9eac3245..70aeb8dd4a 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py @@ -29,7 +29,7 @@ from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_STUDENT, CourseDiscussionSettings ) -from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS +from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS, ENABLE_NOTIFY_ALL_LEARNERS from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -58,10 +58,27 @@ class TestNewThreadCreatedNotification(DiscussionAPIViewTestMixin, ModuleStoreTe Setup test case """ super().setUp() - + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) # Creating a course self.course = CourseFactory.create() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=self.course.id + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=self.course.id + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) # Creating relative discussion and cohort settings CourseCohortsSettings.objects.create(course_id=str(self.course.id)) CourseDiscussionSettings.objects.create(course_id=str(self.course.id), _divided_discussions='[]') @@ -173,29 +190,46 @@ class TestNewThreadCreatedNotification(DiscussionAPIViewTestMixin, ModuleStoreTe """ @ddt.data( - ('new_question_post',), - ('new_discussion_post',), + ('new_question_post', False, False), + ('new_discussion_post', False, False), + ('new_discussion_post', True, True), + ('new_discussion_post', True, False), ) @ddt.unpack - def test_notification_is_send_to_all_enrollments(self, notification_type): + def test_notification_is_send_to_all_enrollments( + self, notification_type, notify_all_learners, waffle_flag_enabled + ): """ Tests notification is sent to all users if course is not cohorted """ self._assign_enrollments() thread_type = ( - "discussion" - if notification_type == "new_discussion_post" - else ("question" if notification_type == "new_question_post" else "") + "discussion" if notification_type == "new_discussion_post" else "question" ) - thread = self._create_thread(thread_type=thread_type) - handler = mock.Mock() - COURSE_NOTIFICATION_REQUESTED.connect(handler) - send_thread_created_notification(thread['id'], str(self.course.id), self.author.id) - self.assertEqual(handler.call_count, 1) - course_notification_data = handler.call_args[1]['course_notification_data'] - assert notification_type == course_notification_data.notification_type - notification_audience_filters = {} - assert notification_audience_filters == course_notification_data.audience_filters + + with override_waffle_flag(ENABLE_NOTIFY_ALL_LEARNERS, active=waffle_flag_enabled): + thread = self._create_thread(thread_type=thread_type) + handler = mock.Mock() + COURSE_NOTIFICATION_REQUESTED.connect(handler) + + send_thread_created_notification( + thread['id'], + str(self.course.id), + self.author.id, + notify_all_learners + ) + expected_handler_calls = 0 if notify_all_learners and not waffle_flag_enabled else 1 + self.assertEqual(handler.call_count, expected_handler_calls) + + if handler.call_count: + course_notification_data = handler.call_args[1]['course_notification_data'] + expected_type = ( + 'new_instructor_all_learners_post' + if notify_all_learners and waffle_flag_enabled + else notification_type + ) + self.assertEqual(course_notification_data.notification_type, expected_type) + self.assertEqual(course_notification_data.audience_filters, {}) @ddt.data( ('cohort_1', 'new_question_post'), @@ -250,8 +284,26 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC super().setUp() httpretty.reset() httpretty.enable() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=self.course.id + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=self.course.id + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) self.user_1 = UserFactory.create() CourseEnrollment.enroll(self.user_1, self.course.id) self.user_2 = UserFactory.create() @@ -270,6 +322,8 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC "username": thread.username, "thread_type": 'discussion', "title": thread.title, + "commentable_id": thread.commentable_id, + }) self._register_subscriptions_endpoint() @@ -319,7 +373,11 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC 'post_title': 'test thread', 'email_content': self.comment.body, 'course_name': self.course.display_name, - 'sender_id': self.user_2.id + 'sender_id': self.user_2.id, + 'response_id': 4, + 'topic_id': None, + 'thread_id': 1, + 'comment_id': None, } self.assertDictEqual(args.context, expected_context) self.assertEqual( @@ -362,11 +420,14 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC 'replier_name': self.user_3.username, 'post_title': self.thread.title, 'email_content': self.comment.body, - 'group_by_id': self.thread_2.id, 'author_name': 'dummy\'s', 'author_pronoun': 'dummy\'s', 'course_name': self.course.display_name, - 'sender_id': self.user_3.id + 'sender_id': self.user_3.id, + 'response_id': 2, + 'topic_id': None, + 'thread_id': 1, + 'comment_id': 4, } self.assertDictEqual(args_comment.context, expected_context) self.assertEqual( @@ -383,7 +444,11 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC 'post_title': self.thread.title, 'email_content': self.comment.body, 'course_name': self.course.display_name, - 'sender_id': self.user_3.id + 'sender_id': self.user_3.id, + 'response_id': 2, + 'topic_id': None, + 'thread_id': 1, + 'comment_id': 4, } self.assertDictEqual(args_comment_on_response.context, expected_context) self.assertEqual( @@ -439,12 +504,15 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC expected_context = { 'replier_name': self.user_3.username, 'post_title': self.thread.title, - 'group_by_id': self.thread_2.id, 'author_name': 'dummy\'s', 'author_pronoun': 'your', 'course_name': self.course.display_name, 'sender_id': self.user_3.id, - 'email_content': self.comment.body + 'email_content': self.comment.body, + 'response_id': 2, + 'topic_id': None, + 'thread_id': 1, + 'comment_id': 4, } self.assertDictEqual(args_comment.context, expected_context) self.assertEqual( @@ -489,6 +557,10 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC 'email_content': self.comment.body, 'course_name': self.course.display_name, 'sender_id': self.user_2.id, + 'response_id': 4 if notification_type == 'response_on_followed_post' else parent_id, + 'topic_id': None, + 'thread_id': 1, + 'comment_id': 4 if not notification_type == 'response_on_followed_post' else None, } if parent_id: expected_context['author_name'] = 'dummy\'s' @@ -538,8 +610,26 @@ class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCas super().setUp() httpretty.reset() httpretty.enable() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=self.course.id + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=self.course.id + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) self.user_1 = UserFactory.create() CourseEnrollment.enroll(self.user_1, self.course.id) self.user_2 = UserFactory.create() @@ -573,6 +663,8 @@ class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCas "username": thread.username, "thread_type": 'discussion', "title": thread.title, + "commentable_id": thread.commentable_id, + }) self.register_get_comment_response({ 'id': response.id, @@ -605,8 +697,26 @@ class TestResponseEndorsedNotifications(DiscussionAPIViewTestMixin, ModuleStoreT super().setUp() httpretty.reset() httpretty.enable() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread", + return_value=self.course.id + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment", + return_value=self.course.id + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) self.user_1 = UserFactory.create() CourseEnrollment.enroll(self.user_1, self.course.id) self.user_2 = UserFactory.create() @@ -638,6 +748,7 @@ class TestResponseEndorsedNotifications(DiscussionAPIViewTestMixin, ModuleStoreT "username": thread.username, "thread_type": 'discussion', "title": thread.title, + "commentable_id": thread.commentable_id, }) self.register_get_comment_response({ 'id': 1, @@ -665,7 +776,11 @@ class TestResponseEndorsedNotifications(DiscussionAPIViewTestMixin, ModuleStoreT 'post_title': 'test thread', 'course_name': self.course.display_name, 'sender_id': int(self.user_2.id), - 'email_content': 'dummy' + 'email_content': 'dummy', + 'response_id': None, + 'topic_id': None, + 'thread_id': 1, + 'comment_id': 2, } self.assertDictEqual(notification_data.context, expected_context) self.assertEqual(notification_data.content_url, _get_mfe_url(self.course.id, thread.id)) @@ -683,7 +798,11 @@ class TestResponseEndorsedNotifications(DiscussionAPIViewTestMixin, ModuleStoreT 'post_title': 'test thread', 'course_name': self.course.display_name, 'sender_id': int(response.user_id), - 'email_content': 'dummy' + 'email_content': 'dummy', + 'response_id': None, + 'topic_id': None, + 'thread_id': 1, + 'comment_id': 2, } self.assertDictEqual(notification_data.context, expected_context) self.assertEqual(notification_data.content_url, _get_mfe_url(self.course.id, thread.id)) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 2831170007..dce16d0d32 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -18,10 +18,9 @@ from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.keys import CourseKey from pytz import UTC from rest_framework import status -from rest_framework.parsers import JSONParser from rest_framework.test import APIClient, APITestCase -from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE +from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE, ONLY_VERIFIED_USERS_CAN_POST from lms.djangoapps.discussion.rest_api.utils import get_usernames_from_search_string from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore @@ -38,7 +37,7 @@ from common.djangoapps.student.tests.factories import ( SuperuserFactory, UserFactory ) -from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin +from common.djangoapps.util.testing import UrlResetMixin from common.test.utils import disable_signal from lms.djangoapps.discussion.django_comment_client.tests.utils import ( ForumsEnableMixin, @@ -171,6 +170,12 @@ class UploadFileViewTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMi self.user = UserFactory.create(password=self.TEST_PASSWORD) self.course = CourseFactory.create(org='a', course='b', run='c', start=datetime.now(UTC)) self.url = reverse("upload_file", kwargs={"course_id": str(self.course.id)}) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def user_login(self): """ @@ -301,6 +306,7 @@ class UploadFileViewTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMi @ddt.ddt @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_FORUM_V2": False}) class CommentViewSetListByUserTest( ForumsEnableMixin, CommentsServiceMockMixin, @@ -319,6 +325,12 @@ class CommentViewSetListByUserTest( httpretty.enable() self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.user = UserFactory.create(password=self.TEST_PASSWORD) self.register_get_user_response(self.user) @@ -500,6 +512,12 @@ class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): def setUp(self): super().setUp() self.url = reverse("discussion_course", kwargs={"course_id": str(self.course.id)}) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def test_404(self): response = self.client.get( @@ -530,6 +548,7 @@ class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): "provider": "legacy", "allow_anonymous": True, "allow_anonymous_to_peers": False, + "has_bulk_delete_privileges": False, "has_moderation_privileges": False, 'is_course_admin': False, 'is_course_staff': False, @@ -539,6 +558,13 @@ class CourseViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): "edit_reasons": [{"code": "test-edit-reason", "label": "Test Edit Reason"}], "post_close_reasons": [{"code": "test-close-reason", "label": "Test Close Reason"}], 'show_discussions': True, + 'is_notify_all_learners_enabled': False, + 'captcha_settings': { + 'enabled': False, + 'site_key': '', + }, + "is_email_verified": True, + "only_verified_users_can_post": False } ) @@ -561,6 +587,12 @@ class RetireViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): self.superuser_client = APIClient() self.retired_username = get_retired_username_by_username(self.user.username) self.url = reverse("retire_discussion_user") + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def assert_response_correct(self, response, expected_status, expected_content): """ @@ -631,6 +663,12 @@ class ReplaceUsernamesViewTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): self.worker_client = APIClient() self.new_username = "test_username_replacement" self.url = reverse("replace_discussion_username") + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def assert_response_correct(self, response, expected_status, expected_content): """ @@ -733,6 +771,12 @@ class CourseTopicsViewTest(DiscussionAPIViewTestMixin, CommentsServiceMockMixin, "courseware-3": {"discussion": 7, "question": 2}, } self.register_get_course_commentable_counts_response(self.course.id, self.thread_counts_map) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def create_course(self, blocks_count, module_store, topics): """ @@ -988,6 +1032,12 @@ class CourseTopicsViewV3Test(DiscussionAPIViewTestMixin, CommentsServiceMockMixi patcher.start() self.addCleanup(patcher.stop) self.url = reverse("course_topics_v3", kwargs={"course_id": str(self.course.id)}) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): response = self.client.get(self.url) @@ -1015,347 +1065,6 @@ class CourseTopicsViewV3Test(DiscussionAPIViewTestMixin, CommentsServiceMockMixi @ddt.ddt -@httpretty.activate -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class ThreadViewSetListTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin): - """Tests for ThreadViewSet list""" - - def setUp(self): - super().setUp() - self.author = UserFactory.create() - self.url = reverse("thread-list") - - def create_source_thread(self, overrides=None): - """ - Create a sample source cs_thread - """ - thread = make_minimal_cs_thread({ - "id": "test_thread", - "course_id": str(self.course.id), - "commentable_id": "test_topic", - "user_id": str(self.user.id), - "username": self.user.username, - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "title": "Test Title", - "body": "Test body", - "votes": {"up_count": 4}, - "comments_count": 5, - "unread_comments_count": 3, - }) - - thread.update(overrides or {}) - return thread - - def test_course_id_missing(self): - response = self.client.get(self.url) - self.assert_response_correct( - response, - 400, - {"field_errors": {"course_id": {"developer_message": "This field is required."}}} - ) - - def test_404(self): - response = self.client.get(self.url, {"course_id": "non/existent/course"}) - self.assert_response_correct( - response, - 404, - {"developer_message": "Course not found."} - ) - - def test_basic(self): - self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) - source_threads = [ - self.create_source_thread({"user_id": str(self.author.id), "username": self.author.username}) - ] - expected_threads = [self.expected_thread_data({ - "created_at": "2015-04-28T00:00:00Z", - "updated_at": "2015-04-28T11:11:11Z", - "vote_count": 4, - "comment_count": 6, - "can_delete": False, - "unread_comment_count": 3, - "voted": True, - "author": self.author.username, - "editable_fields": ["abuse_flagged", "copy_link", "following", "read", "voted"], - "abuse_flagged_count": None, - })] - self.register_get_threads_response(source_threads, page=1, num_pages=2) - response = self.client.get(self.url, {"course_id": str(self.course.id), "following": ""}) - expected_response = make_paginated_api_response( - results=expected_threads, - count=1, - num_pages=2, - next_link="http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=&page=2", - previous_link=None - ) - expected_response.update({"text_search_rewrite": None}) - self.assert_response_correct( - response, - 200, - expected_response - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - }) - - @ddt.data("unread", "unanswered", "unresponded") - def test_view_query(self, query): - threads = [make_minimal_cs_thread()] - self.register_get_user_response(self.user) - self.register_get_threads_response(threads, page=1, num_pages=1) - self.client.get( - self.url, - { - "course_id": str(self.course.id), - "view": query, - } - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - query: ["true"], - }) - - def test_pagination(self): - self.register_get_user_response(self.user) - self.register_get_threads_response([], page=1, num_pages=1) - response = self.client.get( - self.url, - {"course_id": str(self.course.id), "page": "18", "page_size": "4"} - ) - self.assert_response_correct( - response, - 404, - {"developer_message": "Page not found (No results on this page)."} - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["18"], - "per_page": ["4"], - }) - - def test_text_search(self): - self.register_get_user_response(self.user) - self.register_get_threads_search_response([], None, num_pages=0) - response = self.client.get( - self.url, - {"course_id": str(self.course.id), "text_search": "test search string"} - ) - - expected_response = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_response.update({"text_search_rewrite": None}) - self.assert_response_correct( - response, - 200, - expected_response - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - "text": ["test search string"], - }) - - @ddt.data(True, "true", "1") - def test_following_true(self, following): - self.register_get_user_response(self.user) - self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0) - response = self.client.get( - self.url, - { - "course_id": str(self.course.id), - "following": following, - } - ) - - expected_response = make_paginated_api_response( - results=[], count=0, num_pages=0, next_link=None, previous_link=None - ) - expected_response.update({"text_search_rewrite": None}) - self.assert_response_correct( - response, - 200, - expected_response - ) - assert urlparse( - httpretty.last_request().path # lint-amnesty, pylint: disable=no-member - ).path == f"/api/v1/users/{self.user.id}/subscribed_threads" - - @ddt.data(False, "false", "0") - def test_following_false(self, following): - response = self.client.get( - self.url, - { - "course_id": str(self.course.id), - "following": following, - } - ) - self.assert_response_correct( - response, - 400, - {"field_errors": { - "following": {"developer_message": "The value of the 'following' parameter must be true."} - }} - ) - - def test_following_error(self): - response = self.client.get( - self.url, - { - "course_id": str(self.course.id), - "following": "invalid-boolean", - } - ) - self.assert_response_correct( - response, - 400, - {"field_errors": { - "following": {"developer_message": "Invalid Boolean Value."} - }} - ) - - @ddt.data( - ("last_activity_at", "activity"), - ("comment_count", "comments"), - ("vote_count", "votes") - ) - @ddt.unpack - def test_order_by(self, http_query, cc_query): - """ - Tests the order_by parameter - - Arguments: - http_query (str): Query string sent in the http request - cc_query (str): Query string used for the comments client service - """ - threads = [make_minimal_cs_thread()] - self.register_get_user_response(self.user) - self.register_get_threads_response(threads, page=1, num_pages=1) - self.client.get( - self.url, - { - "course_id": str(self.course.id), - "order_by": http_query, - } - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "page": ["1"], - "per_page": ["10"], - "sort_key": [cc_query], - }) - - def test_order_direction(self): - """ - Test order direction, of which "desc" is the only valid option. The - option actually just gets swallowed, so it doesn't affect the params. - """ - threads = [make_minimal_cs_thread()] - self.register_get_user_response(self.user) - self.register_get_threads_response(threads, page=1, num_pages=1) - self.client.get( - self.url, - { - "course_id": str(self.course.id), - "order_direction": "desc", - } - ) - self.assert_last_query_params({ - "user_id": [str(self.user.id)], - "course_id": [str(self.course.id)], - "sort_key": ["activity"], - "page": ["1"], - "per_page": ["10"], - }) - - def test_mutually_exclusive(self): - """ - Tests GET thread_list api does not allow filtering on mutually exclusive parameters - """ - self.register_get_user_response(self.user) - self.register_get_threads_search_response([], None, num_pages=0) - response = self.client.get(self.url, { - "course_id": str(self.course.id), - "text_search": "test search string", - "topic_id": "topic1, topic2", - }) - self.assert_response_correct( - response, - 400, - { - "developer_message": "The following query parameters are mutually exclusive: topic_id, " - "text_search, following" - } - ) - - def test_profile_image_requested_field(self): - """ - Tests thread has user profile image details if called in requested_fields - """ - user_2 = UserFactory.create(password=self.password) - # Ensure that parental controls don't apply to this user - user_2.profile.year_of_birth = 1970 - user_2.profile.save() - source_threads = [ - self.create_source_thread(), - self.create_source_thread({"user_id": str(user_2.id), "username": user_2.username}), - ] - - self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) - self.register_get_threads_response(source_threads, page=1, num_pages=1) - self.create_profile_image(self.user, get_profile_image_storage()) - self.create_profile_image(user_2, get_profile_image_storage()) - - response = self.client.get( - self.url, - {"course_id": str(self.course.id), "requested_fields": "profile_image"}, - ) - assert response.status_code == 200 - response_threads = json.loads(response.content.decode('utf-8'))['results'] - - for response_thread in response_threads: - expected_profile_data = self.get_expected_user_profile(response_thread['author']) - response_users = response_thread['users'] - assert expected_profile_data == response_users[response_thread['author']] - - def test_profile_image_requested_field_anonymous_user(self): - """ - Tests profile_image in requested_fields for thread created with anonymous user - """ - source_threads = [ - self.create_source_thread( - {"user_id": None, "username": None, "anonymous": True, "anonymous_to_peers": True} - ), - ] - - self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) - self.register_get_threads_response(source_threads, page=1, num_pages=1) - - response = self.client.get( - self.url, - {"course_id": str(self.course.id), "requested_fields": "profile_image"}, - ) - assert response.status_code == 200 - response_thread = json.loads(response.content.decode('utf-8'))['results'][0] - assert response_thread['author'] is None - assert {} == response_thread['users'] - - @httpretty.activate @disable_signal(api, 'thread_created') @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) @@ -1365,6 +1074,12 @@ class ThreadViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): def setUp(self): super().setUp() self.url = reverse("thread-list") + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): self.register_get_user_response(self.user) @@ -1425,150 +1140,40 @@ class ThreadViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): response_data = json.loads(response.content.decode('utf-8')) assert response_data == expected_response_data - -@ddt.ddt -@httpretty.activate -@disable_signal(api, 'thread_edited') -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class ThreadViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin): - """Tests for ThreadViewSet partial_update""" - - def setUp(self): - self.unsupported_media_type = JSONParser.media_type - super().setUp() - self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) - - def test_basic(self): - self.register_get_user_response(self.user) - self.register_thread({ - "created_at": "Test Created Date", - "updated_at": "Test Updated Date", - "read": True, - "resp_total": 2, - }) - request_data = {"raw_body": "Edited body"} - response = self.request_patch(request_data) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == self.expected_thread_data({ - 'raw_body': 'Edited body', - 'rendered_body': '

    Edited body

    ', - 'preview_body': 'Edited body', - 'editable_fields': [ - 'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', 'read', - 'title', 'topic_id', 'type' - ], - 'created_at': 'Test Created Date', - 'updated_at': 'Test Updated Date', - 'comment_count': 1, - 'read': True, - 'response_count': 2, - }) - assert parsed_body(httpretty.last_request()) == { - 'course_id': [str(self.course.id)], - 'commentable_id': ['test_topic'], - 'thread_type': ['discussion'], - 'title': ['Test Title'], - 'body': ['Edited body'], - 'user_id': [str(self.user.id)], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - 'closed': ['False'], - 'pinned': ['False'], - 'read': ['True'], - 'editing_user_id': [str(self.user.id)], - } - - def test_error(self): - self.register_get_user_response(self.user) - self.register_thread() - request_data = {"title": ""} - response = self.request_patch(request_data) - expected_response_data = { - "field_errors": {"title": {"developer_message": "This field may not be blank."}} - } - assert response.status_code == 400 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == expected_response_data - @ddt.data( - ("abuse_flagged", True), - ("abuse_flagged", False), + (False, False, status.HTTP_200_OK), + (False, True, status.HTTP_400_BAD_REQUEST), + (True, False, status.HTTP_200_OK), + (True, True, status.HTTP_200_OK), ) @ddt.unpack - def test_closed_thread(self, field, value): - self.register_get_user_response(self.user) - self.register_thread({"closed": True, "read": True}) - self.register_flag_response("thread", "test_thread") - request_data = {field: value} - response = self.request_patch(request_data) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == self.expected_thread_data({ - 'read': True, - 'closed': True, - 'abuse_flagged': value, - 'editable_fields': ['abuse_flagged', 'copy_link', 'read'], - 'comment_count': 1, 'unread_comment_count': 0 - }) - - @ddt.data( - ("raw_body", "Edited body"), - ("voted", True), - ("following", True), - ) - @ddt.unpack - def test_closed_thread_error(self, field, value): - self.register_get_user_response(self.user) - self.register_thread({"closed": True}) - self.register_flag_response("thread", "test_thread") - request_data = {field: value} - response = self.request_patch(request_data) - assert response.status_code == 400 - - def test_patch_read_owner_user(self): - self.register_get_user_response(self.user) - self.register_thread({"resp_total": 2}) - self.register_read_response(self.user, "thread", "test_thread") - request_data = {"read": True} - - response = self.request_patch(request_data) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == self.expected_thread_data({ - 'comment_count': 1, - 'read': True, - 'editable_fields': [ - 'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', 'read', - 'title', 'topic_id', 'type' - ], - 'response_count': 2 - }) - - def test_patch_read_non_owner_user(self): - self.register_get_user_response(self.user) - thread_owner_user = UserFactory.create(password=self.password) - CourseEnrollmentFactory.create(user=thread_owner_user, course_id=self.course.id) - self.register_get_user_response(thread_owner_user) - self.register_thread({ - "username": thread_owner_user.username, - "user_id": str(thread_owner_user.id), - "resp_total": 2, - }) - self.register_read_response(self.user, "thread", "test_thread") - - request_data = {"read": True} - response = self.request_patch(request_data) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == self.expected_thread_data({ - 'author': str(thread_owner_user.username), - 'comment_count': 1, - 'can_delete': False, - 'read': True, - 'editable_fields': ['abuse_flagged', 'copy_link', 'following', 'read', 'voted'], - 'response_count': 2 - }) + def test_creation_for_non_verified_user(self, email_verified, only_verified_user_can_post, response_status): + """ + Tests posts cannot be created if ONLY_VERIFIED_USERS_CAN_POST is enabled and user email is unverified. + """ + with override_waffle_flag(ONLY_VERIFIED_USERS_CAN_POST, only_verified_user_can_post): + self.user.is_active = email_verified + self.user.save() + self.register_get_user_response(self.user) + cs_thread = make_minimal_cs_thread({ + "id": "test_thread", + "username": self.user.username, + "read": True, + }) + self.register_post_thread_response(cs_thread) + request_data = { + "course_id": str(self.course.id), + "topic_id": "test_topic", + "type": "discussion", + "title": "Test Title", + "raw_body": "# Test \n This is a very long body but will not be truncated for the preview.", + } + response = self.client.post( + self.url, + json.dumps(request_data), + content_type="application/json" + ) + assert response.status_code == response_status @httpretty.activate @@ -1581,6 +1186,17 @@ class ThreadViewSetDeleteTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): super().setUp() self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) self.thread_id = "test_thread" + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): self.register_get_user_response(self.user) @@ -1681,6 +1297,12 @@ class LearnerThreadViewAPITest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): ] self.url = reverse("discussion_learner_threads", kwargs={'course_id': str(self.course.id)}) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def update_thread(self, thread): """ @@ -1923,6 +1545,17 @@ class CommentViewSetListTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, Pr self.url = reverse("comment-list") self.thread_id = "test_thread" self.storage = get_profile_image_storage() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def create_source_comment(self, overrides=None): """ @@ -2377,6 +2010,22 @@ class CommentViewSetDeleteTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): super().setUp() self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) self.comment_id = "test_comment" + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): self.register_get_user_response(self.user) @@ -2406,6 +2055,7 @@ class CommentViewSetDeleteTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): assert response.status_code == 404 +@ddt.ddt @httpretty.activate @disable_signal(api, 'comment_created') @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) @@ -2416,6 +2066,23 @@ class CommentViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): def setUp(self): super().setUp() self.url = reverse("comment-list") + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): self.register_get_user_response(self.user) @@ -2504,131 +2171,68 @@ class CommentViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): ) assert response.status_code == 403 - -@ddt.ddt -@disable_signal(api, 'comment_edited') -@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) -class CommentViewSetPartialUpdateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin): - """Tests for CommentViewSet partial_update""" - - def setUp(self): - self.unsupported_media_type = JSONParser.media_type - super().setUp() - httpretty.reset() - httpretty.enable() - self.addCleanup(httpretty.reset) - self.addCleanup(httpretty.disable) - self.register_get_user_response(self.user) - self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) - - def expected_response_data(self, overrides=None): - """ - create expected response data from comment update endpoint - """ - response_data = { - "id": "test_comment", - "thread_id": "test_thread", - "parent_id": None, - "author": self.user.username, - "author_label": None, - "created_at": "1970-01-01T00:00:00Z", - "updated_at": "1970-01-01T00:00:00Z", - "raw_body": "Original body", - "rendered_body": "

    Original body

    ", - "endorsed": False, - "endorsed_by": None, - "endorsed_by_label": None, - "endorsed_at": None, - "abuse_flagged": False, - "abuse_flagged_any_user": None, - "voted": False, - "vote_count": 0, - "children": [], - "editable_fields": [], - "child_count": 0, - "can_delete": True, - "anonymous": False, - "anonymous_to_peers": False, - "last_edit": None, - "edit_by_label": None, - "profile_image": { - "has_image": False, - "image_url_full": "http://testserver/static/default_500.png", - "image_url_large": "http://testserver/static/default_120.png", - "image_url_medium": "http://testserver/static/default_50.png", - "image_url_small": "http://testserver/static/default_30.png", - }, - } - response_data.update(overrides or {}) - return response_data - - def test_basic(self): - self.register_thread() - self.register_comment({"created_at": "Test Created Date", "updated_at": "Test Updated Date"}) - request_data = {"raw_body": "Edited body"} - response = self.request_patch(request_data) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == self.expected_response_data({ - 'raw_body': 'Edited body', - 'rendered_body': '

    Edited body

    ', - 'editable_fields': ['abuse_flagged', 'anonymous', 'raw_body'], - 'created_at': 'Test Created Date', - 'updated_at': 'Test Updated Date' - }) - assert parsed_body(httpretty.last_request()) == { - 'body': ['Edited body'], - 'course_id': [str(self.course.id)], - 'user_id': [str(self.user.id)], - 'anonymous': ['False'], - 'anonymous_to_peers': ['False'], - 'endorsed': ['False'], - 'editing_user_id': [str(self.user.id)], - } - - def test_error(self): - self.register_thread() - self.register_comment() - request_data = {"raw_body": ""} - response = self.request_patch(request_data) - expected_response_data = { - "field_errors": {"raw_body": {"developer_message": "This field may not be blank."}} - } - assert response.status_code == 400 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == expected_response_data - @ddt.data( - ("abuse_flagged", True), - ("abuse_flagged", False), + (False, False, status.HTTP_200_OK), + (False, True, status.HTTP_400_BAD_REQUEST), + (True, False, status.HTTP_200_OK), + (True, True, status.HTTP_200_OK), ) @ddt.unpack - def test_closed_thread(self, field, value): - self.register_thread({"closed": True}) - self.register_comment() - self.register_flag_response("comment", "test_comment") - request_data = {field: value} - response = self.request_patch(request_data) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data == self.expected_response_data({ - 'abuse_flagged': value, - "abuse_flagged_any_user": None, - 'editable_fields': ['abuse_flagged'] - }) - - @ddt.data( - ("raw_body", "Edited body"), - ("voted", True), - ("following", True), - ) - @ddt.unpack - def test_closed_thread_error(self, field, value): - self.register_thread({"closed": True}) - self.register_comment() - request_data = {field: value} - response = self.request_patch(request_data) - assert response.status_code == 400 + def test_creation_for_non_verified_user(self, email_verified, only_verified_user_can_post, response_status): + """ + Tests comments/replies cannot be created if ONLY_VERIFIED_USERS_CAN_POST is enabled and + user email is unverified. + """ + with override_waffle_flag(ONLY_VERIFIED_USERS_CAN_POST, only_verified_user_can_post): + self.user.is_active = email_verified + self.user.save() + self.register_get_user_response(self.user) + self.register_thread() + self.register_comment() + request_data = { + "thread_id": "test_thread", + "raw_body": "Test body", + } + expected_response_data = { + "id": "test_comment", + "thread_id": "test_thread", + "parent_id": None, + "author": self.user.username, + "author_label": None, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "raw_body": "Test body", + "rendered_body": "

    Test body

    ", + "endorsed": False, + "endorsed_by": None, + "endorsed_by_label": None, + "endorsed_at": None, + "abuse_flagged": False, + "abuse_flagged_any_user": None, + "voted": False, + "vote_count": 0, + "children": [], + "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], + "child_count": 0, + "can_delete": True, + "anonymous": False, + "anonymous_to_peers": False, + "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, + } + response = self.client.post( + self.url, + json.dumps(request_data), + content_type="application/json" + ) + assert response.status_code == response_status @httpretty.activate @@ -2640,6 +2244,22 @@ class ThreadViewSetRetrieveTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase, super().setUp() self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) self.thread_id = "test_thread" + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def test_basic(self): self.register_get_user_response(self.user) @@ -2693,6 +2313,22 @@ class CommentViewSetRetrieveTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) self.thread_id = "test_thread" self.comment_id = "test_comment" + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def make_comment_data(self, comment_id, parent_id=None, children=[]): # pylint: disable=W0102 """ @@ -2838,6 +2474,12 @@ class CourseDiscussionSettingsAPIViewTest(APITestCase, UrlResetMixin, ModuleStor self.path = reverse('discussion_course_settings', kwargs={'course_id': str(self.course.id)}) self.password = self.TEST_PASSWORD self.user = UserFactory(username='staff', password=self.password, is_staff=True) + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) def _get_oauth_headers(self, user): """Return the OAuth headers for testing OAuth authentication""" @@ -3127,6 +2769,12 @@ class CourseDiscussionRolesAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTe @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create( org="x", course="y", @@ -3318,6 +2966,12 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self) -> None: super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create() self.course_key = str(self.course.id) seed_permissions_roles(self.course.id) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py new file mode 100644 index 0000000000..2351d92ee6 --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/tests/test_views_v2.py @@ -0,0 +1,949 @@ +# pylint: skip-file +""" +Tests for the external REST API endpoints of the Discussion API (views_v2.py). + +This module focuses on integration tests for the Django REST Framework views that expose the Discussion API. +It verifies the correct behavior of the API endpoints, including authentication, permissions, request/response formats, +and integration with the underlying discussion service. These tests ensure that the endpoints correctly handle +various user roles, input data, and edge cases, and that they return appropriate HTTP status codes and response bodies. +""" + + +import json +from datetime import datetime +from unittest import mock + +import ddt +from forum.backends.mongodb.comments import Comment +from forum.backends.mongodb.threads import CommentThread +import httpretty +from django.urls import reverse +from pytz import UTC +from rest_framework import status +from rest_framework.parsers import JSONParser +from rest_framework.test import APIClient + +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from common.djangoapps.student.tests.factories import ( + CourseEnrollmentFactory, + UserFactory, +) +from common.djangoapps.util.testing import PatchMediaTypeMixin, UrlResetMixin +from common.test.utils import disable_signal +from lms.djangoapps.discussion.tests.utils import ( + make_minimal_cs_comment, + make_minimal_cs_thread, +) +from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin +from lms.djangoapps.discussion.rest_api import api +from lms.djangoapps.discussion.rest_api.tests.utils import ( + ForumMockUtilsMixin, + ProfileImageTestMixin, + make_paginated_api_response, +) +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_MODERATOR, FORUM_ROLE_STUDENT, + assign_role +) +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage + + +class DiscussionAPIViewTestMixin(ForumsEnableMixin, ForumMockUtilsMixin, UrlResetMixin): + """ + Mixin for common code in tests of Discussion API views. This includes + creation of common structures (e.g. a course, user, and enrollment), logging + in the test client, utility functions, and a test case for unauthenticated + requests. Subclasses must set self.url in their setUp methods. + """ + + client_class = APIClient + + @mock.patch.dict( + "django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True} + ) + def setUp(self): + super().setUp() + self.maxDiff = None # pylint: disable=invalid-name + self.course = CourseFactory.create( + org="x", + course="y", + run="z", + start=datetime.now(UTC), + discussion_topics={"Test Topic": {"id": "test_topic"}}, + ) + self.password = "Password1234" + self.user = UserFactory.create(password=self.password) + # Ensure that parental controls don't apply to this user + self.user.profile.year_of_birth = 1970 + self.user.profile.save() + CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id) + self.client.login(username=self.user.username, password=self.password) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def assert_response_correct(self, response, expected_status, expected_content): + """ + Assert that the response has the given status code and parsed content + """ + assert response.status_code == expected_status + parsed_content = json.loads(response.content.decode("utf-8")) + assert parsed_content == expected_content + + def register_thread(self, overrides=None): + """ + Create cs_thread with minimal fields and register response + """ + cs_thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "username": self.user.username, + "user_id": str(self.user.id), + "thread_type": "discussion", + "title": "Test Title", + "body": "Test body", + } + ) + cs_thread.update(overrides or {}) + self.register_get_thread_response(cs_thread) + self.register_put_thread_response(cs_thread) + + def register_comment(self, overrides=None): + """ + Create cs_comment with minimal fields and register response + """ + cs_comment = make_minimal_cs_comment( + { + "id": "test_comment", + "course_id": str(self.course.id), + "thread_id": "test_thread", + "username": self.user.username, + "user_id": str(self.user.id), + "body": "Original body", + } + ) + cs_comment.update(overrides or {}) + self.register_get_comment_response(cs_comment) + self.register_put_comment_response(cs_comment) + self.register_post_comment_response(cs_comment, thread_id="test_thread") + + def test_not_authenticated(self): + self.client.logout() + response = self.client.get(self.url) + self.assert_response_correct( + response, + 401, + {"developer_message": "Authentication credentials were not provided."}, + ) + + def test_inactive(self): + self.user.is_active = False + self.test_basic() + + +@ddt.ddt +@httpretty.activate +@disable_signal(api, "thread_edited") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class ThreadViewSetPartialUpdateTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin +): + """Tests for ThreadViewSet partial_update""" + + def setUp(self): + self.unsupported_media_type = JSONParser.media_type + super().setUp() + self.url = reverse("thread-detail", kwargs={"thread_id": "test_thread"}) + + def test_basic(self): + self.register_get_user_response(self.user) + self.register_thread( + { + "created_at": "Test Created Date", + "updated_at": "Test Updated Date", + "read": True, + "resp_total": 2, + } + ) + request_data = {"raw_body": "Edited body"} + response = self.request_patch(request_data) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_thread_data( + { + "raw_body": "Edited body", + "rendered_body": "

    Edited body

    ", + "preview_body": "Edited body", + "editable_fields": [ + "abuse_flagged", + "anonymous", + "copy_link", + "following", + "raw_body", + "read", + "title", + "topic_id", + "type", + ], + "created_at": "Test Created Date", + "updated_at": "Test Updated Date", + "comment_count": 1, + "read": True, + "response_count": 2, + } + ) + + params = { + "thread_id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "thread_type": "discussion", + "title": "Test Title", + "body": "Edited body", + "user_id": str(self.user.id), + "anonymous": False, + "anonymous_to_peers": False, + "closed": False, + "pinned": False, + "read": True, + "editing_user_id": str(self.user.id), + } + self.check_mock_called_with("update_thread", -1, **params) + + def test_error(self): + self.register_get_user_response(self.user) + self.register_thread() + request_data = {"title": ""} + response = self.request_patch(request_data) + expected_response_data = { + "field_errors": { + "title": {"developer_message": "This field may not be blank."} + } + } + assert response.status_code == 400 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == expected_response_data + + @ddt.data( + ("abuse_flagged", True), + ("abuse_flagged", False), + ) + @ddt.unpack + def test_closed_thread(self, field, value): + self.register_get_user_response(self.user) + self.register_thread({"closed": True, "read": True}) + self.register_flag_response("thread", "test_thread") + request_data = {field: value} + response = self.request_patch(request_data) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_thread_data( + { + "read": True, + "closed": True, + "abuse_flagged": value, + "editable_fields": ["abuse_flagged", "copy_link", "read"], + "comment_count": 1, + "unread_comment_count": 0, + } + ) + + @ddt.data( + ("raw_body", "Edited body"), + ("voted", True), + ("following", True), + ) + @ddt.unpack + def test_closed_thread_error(self, field, value): + self.register_get_user_response(self.user) + self.register_thread({"closed": True}) + self.register_flag_response("thread", "test_thread") + request_data = {field: value} + response = self.request_patch(request_data) + assert response.status_code == 400 + + def test_patch_read_owner_user(self): + self.register_get_user_response(self.user) + self.register_thread({"resp_total": 2}) + self.register_read_response(self.user, "thread", "test_thread") + request_data = {"read": True} + + response = self.request_patch(request_data) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_thread_data( + { + "comment_count": 1, + "read": True, + "editable_fields": [ + "abuse_flagged", + "anonymous", + "copy_link", + "following", + "raw_body", + "read", + "title", + "topic_id", + "type", + ], + "response_count": 2, + } + ) + + def test_patch_read_non_owner_user(self): + self.register_get_user_response(self.user) + thread_owner_user = UserFactory.create(password=self.password) + CourseEnrollmentFactory.create(user=thread_owner_user, course_id=self.course.id) + self.register_thread( + { + "username": thread_owner_user.username, + "user_id": str(thread_owner_user.id), + "resp_total": 2, + } + ) + self.register_read_response(self.user, "thread", "test_thread") + + request_data = {"read": True} + response = self.request_patch(request_data) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + expected_data = self.expected_thread_data( + { + "author": str(thread_owner_user.username), + "comment_count": 1, + "can_delete": False, + "read": True, + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "response_count": 2, + } + ) + assert response_data == expected_data + + +@ddt.ddt +@disable_signal(api, "comment_edited") +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class CommentViewSetPartialUpdateTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, PatchMediaTypeMixin +): + """Tests for CommentViewSet partial_update""" + + def setUp(self): + self.unsupported_media_type = JSONParser.media_type + super().setUp() + self.register_get_user_response(self.user) + self.url = reverse("comment-detail", kwargs={"comment_id": "test_comment"}) + + def expected_response_data(self, overrides=None): + """ + create expected response data from comment update endpoint + """ + response_data = { + "id": "test_comment", + "thread_id": "test_thread", + "parent_id": None, + "author": self.user.username, + "author_label": None, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "raw_body": "Original body", + "rendered_body": "

    Original body

    ", + "endorsed": False, + "endorsed_by": None, + "endorsed_by_label": None, + "endorsed_at": None, + "abuse_flagged": False, + "abuse_flagged_any_user": None, + "voted": False, + "vote_count": 0, + "children": [], + "editable_fields": [], + "child_count": 0, + "can_delete": True, + "anonymous": False, + "anonymous_to_peers": False, + "last_edit": None, + "edit_by_label": None, + "profile_image": { + "has_image": False, + "image_url_full": "http://testserver/static/default_500.png", + "image_url_large": "http://testserver/static/default_120.png", + "image_url_medium": "http://testserver/static/default_50.png", + "image_url_small": "http://testserver/static/default_30.png", + }, + } + response_data.update(overrides or {}) + return response_data + + def test_basic(self): + self.register_thread() + self.register_comment( + {"created_at": "Test Created Date", "updated_at": "Test Updated Date"} + ) + request_data = {"raw_body": "Edited body"} + response = self.request_patch(request_data) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_response_data( + { + "raw_body": "Edited body", + "rendered_body": "

    Edited body

    ", + "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], + "created_at": "Test Created Date", + "updated_at": "Test Updated Date", + } + ) + params = { + "comment_id": "test_comment", + "body": "Edited body", + "course_id": str(self.course.id), + "user_id": str(self.user.id), + "anonymous": False, + "anonymous_to_peers": False, + "endorsed": False, + "editing_user_id": str(self.user.id), + } + self.check_mock_called_with("update_comment", -1, **params) + + def test_error(self): + self.register_thread() + self.register_comment() + request_data = {"raw_body": ""} + response = self.request_patch(request_data) + expected_response_data = { + "field_errors": { + "raw_body": {"developer_message": "This field may not be blank."} + } + } + assert response.status_code == 400 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == expected_response_data + + @ddt.data( + ("abuse_flagged", True), + ("abuse_flagged", False), + ) + @ddt.unpack + def test_closed_thread(self, field, value): + self.register_thread({"closed": True}) + self.register_comment() + self.register_flag_response("comment", "test_comment") + request_data = {field: value} + response = self.request_patch(request_data) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data == self.expected_response_data( + { + "abuse_flagged": value, + "abuse_flagged_any_user": None, + "editable_fields": ["abuse_flagged"], + } + ) + + @ddt.data( + ("raw_body", "Edited body"), + ("voted", True), + ("following", True), + ) + @ddt.unpack + def test_closed_thread_error(self, field, value): + self.register_thread({"closed": True}) + self.register_comment() + request_data = {field: value} + response = self.request_patch(request_data) + assert response.status_code == 400 + + +@ddt.ddt +@httpretty.activate +@mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +class ThreadViewSetListTest( + DiscussionAPIViewTestMixin, ModuleStoreTestCase, ProfileImageTestMixin +): + """Tests for ThreadViewSet list""" + + def setUp(self): + super().setUp() + self.author = UserFactory.create() + self.url = reverse("thread-list") + + def create_source_thread(self, overrides=None): + """ + Create a sample source cs_thread + """ + thread = make_minimal_cs_thread( + { + "id": "test_thread", + "course_id": str(self.course.id), + "commentable_id": "test_topic", + "user_id": str(self.user.id), + "username": self.user.username, + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "title": "Test Title", + "body": "Test body", + "votes": {"up_count": 4}, + "comments_count": 5, + "unread_comments_count": 3, + } + ) + + thread.update(overrides or {}) + return thread + + def test_course_id_missing(self): + response = self.client.get(self.url) + self.assert_response_correct( + response, + 400, + {"field_errors": {"course_id": {"developer_message": "This field is required."}}} + ) + + def test_404(self): + response = self.client.get(self.url, {"course_id": "non/existent/course"}) + self.assert_response_correct( + response, + 404, + {"developer_message": "Course not found."} + ) + + def test_basic(self): + self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) + source_threads = [ + self.create_source_thread( + {"user_id": str(self.author.id), "username": self.author.username} + ) + ] + expected_threads = [ + self.expected_thread_data( + { + "created_at": "2015-04-28T00:00:00Z", + "updated_at": "2015-04-28T11:11:11Z", + "vote_count": 4, + "comment_count": 6, + "can_delete": False, + "unread_comment_count": 3, + "voted": True, + "author": self.author.username, + "editable_fields": [ + "abuse_flagged", + "copy_link", + "following", + "read", + "voted", + ], + "abuse_flagged_count": None, + } + ) + ] + self.register_get_threads_response(source_threads, page=1, num_pages=2) + response = self.client.get( + self.url, {"course_id": str(self.course.id), "following": ""} + ) + expected_response = make_paginated_api_response( + results=expected_threads, + count=1, + num_pages=2, + next_link="http://testserver/api/discussion/v1/threads/?course_id=course-v1%3Ax%2By%2Bz&following=&page=2", + previous_link=None, + ) + expected_response.update({"text_search_rewrite": None}) + self.assert_response_correct(response, 200, expected_response) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + @ddt.data("unread", "unanswered", "unresponded") + def test_view_query(self, query): + threads = [make_minimal_cs_thread()] + self.register_get_user_response(self.user) + self.register_get_threads_response(threads, page=1, num_pages=1) + self.client.get( + self.url, + { + "course_id": str(self.course.id), + "view": query, + }, + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + query: True, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_pagination(self): + self.register_get_user_response(self.user) + self.register_get_threads_response([], page=1, num_pages=1) + response = self.client.get( + self.url, {"course_id": str(self.course.id), "page": "18", "page_size": "4"} + ) + self.assert_response_correct( + response, + 404, + {"developer_message": "Page not found (No results on this page)."}, + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 18, + "per_page": 4, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_text_search(self): + self.register_get_user_response(self.user) + self.register_get_threads_search_response([], None, num_pages=0) + response = self.client.get( + self.url, + {"course_id": str(self.course.id), "text_search": "test search string"}, + ) + + expected_response = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_response.update({"text_search_rewrite": None}) + self.assert_response_correct(response, 200, expected_response) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + "text": "test search string", + } + self.check_mock_called_with( + "search_threads", + -1, + **params, + ) + + @ddt.data(True, "true", "1") + def test_following_true(self, following): + self.register_get_user_response(self.user) + self.register_subscribed_threads_response(self.user, [], page=1, num_pages=0) + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "following": following, + }, + ) + + expected_response = make_paginated_api_response( + results=[], count=0, num_pages=0, next_link=None, previous_link=None + ) + expected_response.update({"text_search_rewrite": None}) + self.assert_response_correct(response, 200, expected_response) + self.check_mock_called("get_user_subscriptions") + + @ddt.data(False, "false", "0") + def test_following_false(self, following): + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "following": following, + }, + ) + self.assert_response_correct( + response, + 400, + { + "field_errors": { + "following": { + "developer_message": "The value of the 'following' parameter must be true." + } + } + }, + ) + + def test_following_error(self): + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "following": "invalid-boolean", + }, + ) + self.assert_response_correct( + response, + 400, + { + "field_errors": { + "following": {"developer_message": "Invalid Boolean Value."} + } + }, + ) + + @ddt.data( + ("last_activity_at", "activity"), + ("comment_count", "comments"), + ("vote_count", "votes"), + ) + @ddt.unpack + def test_order_by(self, http_query, cc_query): + """ + Tests the order_by parameter + + Arguments: + http_query (str): Query string sent in the http request + cc_query (str): Query string used for the comments client service + """ + threads = [make_minimal_cs_thread()] + self.register_get_user_response(self.user) + self.register_get_threads_response(threads, page=1, num_pages=1) + self.client.get( + self.url, + { + "course_id": str(self.course.id), + "order_by": http_query, + }, + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "page": 1, + "per_page": 10, + "sort_key": cc_query, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_order_direction(self): + """ + Test order direction, of which "desc" is the only valid option. The + option actually just gets swallowed, so it doesn't affect the params. + """ + threads = [make_minimal_cs_thread()] + self.register_get_user_response(self.user) + self.register_get_threads_response(threads, page=1, num_pages=1) + self.client.get( + self.url, + { + "course_id": str(self.course.id), + "order_direction": "desc", + }, + ) + params = { + "user_id": str(self.user.id), + "course_id": str(self.course.id), + "sort_key": "activity", + "page": 1, + "per_page": 10, + } + self.check_mock_called_with( + "get_user_threads", + -1, + **params, + ) + + def test_mutually_exclusive(self): + """ + Tests GET thread_list api does not allow filtering on mutually exclusive parameters + """ + self.register_get_user_response(self.user) + self.register_get_threads_search_response([], None, num_pages=0) + response = self.client.get( + self.url, + { + "course_id": str(self.course.id), + "text_search": "test search string", + "topic_id": "topic1, topic2", + }, + ) + self.assert_response_correct( + response, + 400, + { + "developer_message": "The following query parameters are mutually exclusive: topic_id, " + "text_search, following" + }, + ) + + def test_profile_image_requested_field(self): + """ + Tests thread has user profile image details if called in requested_fields + """ + user_2 = UserFactory.create(password=self.password) + # Ensure that parental controls don't apply to this user + user_2.profile.year_of_birth = 1970 + user_2.profile.save() + source_threads = [ + self.create_source_thread(), + self.create_source_thread( + {"user_id": str(user_2.id), "username": user_2.username} + ), + ] + + self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) + self.register_get_threads_response(source_threads, page=1, num_pages=1) + self.create_profile_image(self.user, get_profile_image_storage()) + self.create_profile_image(user_2, get_profile_image_storage()) + + response = self.client.get( + self.url, + {"course_id": str(self.course.id), "requested_fields": "profile_image"}, + ) + assert response.status_code == 200 + response_threads = json.loads(response.content.decode("utf-8"))["results"] + + for response_thread in response_threads: + expected_profile_data = self.get_expected_user_profile( + response_thread["author"] + ) + response_users = response_thread["users"] + assert expected_profile_data == response_users[response_thread["author"]] + + def test_profile_image_requested_field_anonymous_user(self): + """ + Tests profile_image in requested_fields for thread created with anonymous user + """ + source_threads = [ + self.create_source_thread( + { + "user_id": None, + "username": None, + "anonymous": True, + "anonymous_to_peers": True, + } + ), + ] + + self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) + self.register_get_threads_response(source_threads, page=1, num_pages=1) + + response = self.client.get( + self.url, + {"course_id": str(self.course.id), "requested_fields": "profile_image"}, + ) + assert response.status_code == 200 + response_thread = json.loads(response.content.decode("utf-8"))["results"][0] + assert response_thread["author"] is None + assert {} == response_thread["users"] + + +@ddt.ddt +class BulkDeleteUserPostsTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): + """ + Tests for the BulkDeleteUserPostsViewSet + """ + + def setUp(self): + super().setUp() + self.url = reverse("bulk_delete_user_posts", kwargs={"course_id": str(self.course.id)}) + self.user2 = UserFactory.create(password=self.password) + CourseEnrollmentFactory.create(user=self.user2, course_id=self.course.id) + + def test_basic(self): + """ + Intentionally left empty because this test case is inherited from parent + """ + + def mock_comment_and_thread_count(self, comment_count=1, thread_count=1): + """ + Patches count_documents() for Comment and CommentThread._collection. + """ + thread_collection = mock.MagicMock() + thread_collection.count_documents.return_value = thread_count + patch_thread = mock.patch.object( + CommentThread, "_collection", new_callable=mock.PropertyMock, return_value=thread_collection + ) + + comment_collection = mock.MagicMock() + comment_collection.count_documents.return_value = comment_count + patch_comment = mock.patch.object( + Comment, "_collection", new_callable=mock.PropertyMock, return_value=comment_collection + ) + + thread_mock = patch_thread.start() + comment_mock = patch_comment.start() + + self.addCleanup(patch_comment.stop) + self.addCleanup(patch_thread.stop) + return thread_mock, comment_mock + + @ddt.data(FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_STUDENT) + def test_bulk_delete_denied_for_discussion_roles(self, role): + """ + Test bulk delete user posts denied with discussion roles. + """ + thread_mock, comment_mock = self.mock_comment_and_thread_count(comment_count=1, thread_count=1) + assign_role(self.course.id, self.user, role) + response = self.client.post( + f"{self.url}?username={self.user2.username}", + format="json", + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + thread_mock.count_documents.assert_not_called() + comment_mock.count_documents.assert_not_called() + + @ddt.data(FORUM_ROLE_MODERATOR, FORUM_ROLE_ADMINISTRATOR) + def test_bulk_delete_allowed_for_discussion_roles(self, role): + """ + Test bulk delete user posts passed with discussion roles. + """ + self.mock_comment_and_thread_count(comment_count=1, thread_count=1) + assign_role(self.course.id, self.user, role) + response = self.client.post( + f"{self.url}?username={self.user2.username}", + format="json", + ) + assert response.status_code == status.HTTP_202_ACCEPTED + assert response.json() == {"comment_count": 1, "thread_count": 1} + + @mock.patch('lms.djangoapps.discussion.rest_api.views.delete_course_post_for_user.apply_async') + @ddt.data(True, False) + def test_task_only_runs_if_execute_param_is_true(self, execute, task_mock): + """ + Test bulk delete user posts task runs only if execute parameter is set to true. + """ + assign_role(self.course.id, self.user, FORUM_ROLE_MODERATOR) + self.mock_comment_and_thread_count(comment_count=1, thread_count=1) + response = self.client.post( + f"{self.url}?username={self.user2.username}&execute={str(execute).lower()}", + format="json", + ) + assert response.status_code == status.HTTP_202_ACCEPTED + assert response.json() == {"comment_count": 1, "thread_count": 1} + assert task_mock.called is execute diff --git a/lms/djangoapps/discussion/rest_api/tests/utils.py b/lms/djangoapps/discussion/rest_api/tests/utils.py index 27e34705f5..2cd6628bf8 100644 --- a/lms/djangoapps/discussion/rest_api/tests/utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/utils.py @@ -14,6 +14,7 @@ import httpretty from PIL import Image from pytz import UTC +from lms.djangoapps.discussion.django_comment_client.tests.mixins import MockForumApiMixin from openedx.core.djangoapps.profile_images.images import create_profile_images from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image @@ -51,6 +52,34 @@ def _get_thread_callback(thread_data): return callback +def make_thread_callback(thread_data): + """ + Returns a function that simulates thread creation/update behavior, + applying overrides based on keyword arguments (e.g., mock request body). + """ + + def callback(*args, **kwargs): + # Simulate default thread response + response_data = make_minimal_cs_thread(thread_data) + original_data = response_data.copy() + + for key, val in kwargs.items(): + if key in ["anonymous", "anonymous_to_peers", "closed", "pinned"]: + response_data[key] = val is True or val == "True" + elif key == "edit_reason_code": + response_data["edit_history"] = [{ + "original_body": original_data["body"], + "author": thread_data.get("username"), + "reason_code": val, + }] + else: + response_data[key] = val + + return response_data + + return callback + + def _get_comment_callback(comment_data, thread_id, parent_id): """ Get a callback function that will return a comment containing the given data @@ -86,6 +115,48 @@ def _get_comment_callback(comment_data, thread_id, parent_id): return callback +def make_comment_callback(comment_data, thread_id, parent_id): + """ + Returns a callable that mimics comment creation or update behavior, + applying overrides based on keyword arguments like a parsed request body. + """ + + def callback(*args, **kwargs): + response_data = make_minimal_cs_comment(comment_data) + original_data = response_data.copy() + + # Inject thread_id and parent_id + response_data["thread_id"] = thread_id + response_data["parent_id"] = parent_id + + # Override fields based on "incoming request" + for key, val in kwargs.items(): + if key in ["anonymous", "anonymous_to_peers", "endorsed"]: + response_data[key] = val is True or val == "True" + elif key == "edit_reason_code": + response_data["edit_history"] = [{ + "original_body": original_data["body"], + "author": comment_data.get("username"), + "reason_code": val, + }] + else: + response_data[key] = val + + return response_data + + return callback + + +def make_user_callbacks(user_map): + """ + Returns a callable that mimics user creation. + """ + def callback(*args, **kwargs): + user_id = args[0] if args else kwargs.get('user_id') + return user_map[str(user_id)] + return callback + + class CommentsServiceMockMixin: """Mixin with utility methods for mocking the comments service""" @@ -521,6 +592,228 @@ class CommentsServiceMockMixin: return response_data +class ForumMockUtilsMixin(MockForumApiMixin): + """Mixin with utility methods for mocking the comments service""" + + def register_get_threads_response(self, threads, page, num_pages): + """Register a mock response for GET on the CS thread list endpoint""" + self.set_mock_return_value('get_user_threads', { + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + }) + + def register_get_course_commentable_counts_response(self, course_id, thread_counts): + """Register a mock response for GET on the CS thread list endpoint""" + self.set_mock_return_value('get_commentables_stats', thread_counts) + + def register_get_threads_search_response(self, threads, rewrite, num_pages=1): + """Register a mock response for GET on the CS thread search endpoint""" + self.set_mock_return_value('search_threads', { + "collection": threads, + "page": 1, + "num_pages": num_pages, + "corrected_text": rewrite, + "thread_count": len(threads), + }) + + def register_post_thread_response(self, thread_data): + self.set_mock_side_effect('create_thread', make_thread_callback(thread_data)) + + def register_put_thread_response(self, thread_data): + self.set_mock_side_effect('update_thread', make_thread_callback(thread_data)) + + def register_get_thread_error_response(self, thread_id, status_code): + self.set_mock_return_value('get_thread', Exception(f"Error {status_code}")) + + def register_get_thread_response(self, thread): + self.set_mock_return_value('get_thread', thread) + + def register_get_comments_response(self, comments, page, num_pages): + self.set_mock_return_value('get_parent_comment', { + "collection": comments, + "page": page, + "num_pages": num_pages, + "comment_count": len(comments), + }) + + def register_post_comment_response(self, comment_data, thread_id, parent_id=None): + self.set_mock_side_effect( + 'create_child_comment' if parent_id else 'create_parent_comment', + make_comment_callback(comment_data, thread_id, parent_id) + ) + + def register_put_comment_response(self, comment_data): + thread_id = comment_data["thread_id"] + parent_id = comment_data.get("parent_id") + self.set_mock_side_effect( + 'update_comment', + make_comment_callback(comment_data, thread_id, parent_id) + ) + + def register_get_comment_error_response(self, comment_id, status_code): + self.set_mock_return_value('get_parent_comment', Exception(f"Error {status_code}")) + + def register_get_comment_response(self, response_overrides): + comment = make_minimal_cs_comment(response_overrides) + self.set_mock_return_value('get_parent_comment', comment) + + def register_get_user_response(self, user, subscribed_thread_ids=None, upvoted_ids=None): + """Register a mock response for GET on the CS user endpoint""" + self.users_map[str(user.id)] = { + "id": str(user.id), + "subscribed_thread_ids": subscribed_thread_ids or [], + "upvoted_ids": upvoted_ids or [], + } + self.set_mock_side_effect('get_user', make_user_callbacks(self.users_map)) + + def register_get_user_retire_response(self, user, body=""): + self.set_mock_return_value('retire_user', body) + + def register_get_username_replacement_response(self, user, status=200, body=""): + self.set_mock_return_value('update_username', body) + + def register_subscribed_threads_response(self, user, threads, page, num_pages): + self.set_mock_return_value('get_user_subscriptions', { + "collection": threads, + "page": page, + "num_pages": num_pages, + "thread_count": len(threads), + }) + + def register_course_stats_response(self, course_key, stats, page, num_pages): + self.set_mock_return_value('get_user_course_stats', { + "user_stats": stats, + "page": page, + "num_pages": num_pages, + "count": len(stats), + }) + + def register_subscription_response(self, user): + self.set_mock_return_value('create_subscription', {}) + self.set_mock_return_value('delete_subscription', {}) + + def register_thread_votes_response(self, thread_id): + self.set_mock_return_value('update_thread_votes', {}) + self.set_mock_return_value('delete_thread_vote', {}) + + def register_comment_votes_response(self, comment_id): + self.set_mock_return_value('update_comment_votes', {}) + self.set_mock_return_value('delete_comment_vote', {}) + + def register_flag_response(self, content_type, content_id): + if content_type == 'thread': + self.set_mock_return_value('update_thread_flag', {}) + elif content_type == 'comment': + self.set_mock_return_value('update_comment_flag', {}) + + def register_read_response(self, user, content_type, content_id): + self.set_mock_return_value('mark_thread_as_read', {}) + + def register_delete_thread_response(self, thread_id): + self.set_mock_return_value('delete_thread', {}) + + def register_delete_comment_response(self, comment_id): + self.set_mock_return_value('delete_comment', {}) + + def register_user_active_threads(self, user_id, response): + self.set_mock_return_value('get_user_active_threads', response) + + def register_get_subscriptions(self, thread_id, response): + self.set_mock_return_value('get_thread_subscriptions', response) + + def register_thread_flag_response(self, thread_id): + """Register a mock response for PUT on the CS thread flag endpoints""" + self.register_flag_response("thread", thread_id) + + def register_comment_flag_response(self, comment_id): + """Register a mock response for PUT on the CS comment flag endpoints""" + self.register_flag_response("comment", comment_id) + + def assert_query_params_equal(self, httpretty_request, expected_params): + """ + Assert that the given mock request had the expected query parameters + """ + actual_params = dict(querystring(httpretty_request)) + actual_params.pop("request_id") # request_id is random + assert actual_params == expected_params + + def assert_last_query_params(self, expected_params): + """ + Assert that the last mock request had the expected query parameters + """ + self.assert_query_params_equal(httpretty.last_request(), expected_params) + + def request_patch(self, request_data): + """ + make a request to PATCH endpoint and return response + """ + return self.client.patch( + self.url, + json.dumps(request_data), + content_type="application/merge-patch+json" + ) + + def expected_thread_data(self, overrides=None): + """ + Returns expected thread data in API response + """ + response_data = { + "anonymous": False, + "anonymous_to_peers": False, + "author": self.user.username, + "author_label": None, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "raw_body": "Test body", + "rendered_body": "

    Test body

    ", + "preview_body": "Test body", + "abuse_flagged": False, + "abuse_flagged_count": None, + "voted": False, + "vote_count": 0, + "editable_fields": [ + "abuse_flagged", + "anonymous", + "copy_link", + "following", + "raw_body", + "read", + "title", + "topic_id", + "type", + ], + "course_id": str(self.course.id), + "topic_id": "test_topic", + "group_id": None, + "group_name": None, + "title": "Test Title", + "pinned": False, + "closed": False, + "can_delete": True, + "following": False, + "comment_count": 1, + "unread_comment_count": 0, + "comment_list_url": "http://testserver/api/discussion/v1/comments/?thread_id=test_thread", + "endorsed_comment_list_url": None, + "non_endorsed_comment_list_url": None, + "read": False, + "has_endorsed": False, + "id": "test_thread", + "type": "discussion", + "response_count": 0, + "last_edit": None, + "edit_by_label": None, + "closed_by": None, + "closed_by_label": None, + "close_reason": None, + "close_reason_code": None, + } + response_data.update(overrides or {}) + return response_data + + def make_minimal_cs_thread(overrides=None): """ Create a dictionary containing all needed thread fields as returned by the @@ -675,13 +968,14 @@ class ThreadMock(object): A mock thread object """ - def __init__(self, thread_id, creator, title, parent_id=None, body=''): + def __init__(self, thread_id, creator, title, parent_id=None, body='', commentable_id=None): self.id = thread_id self.user_id = str(creator.id) self.username = creator.username self.title = title self.parent_id = parent_id self.body = body + self.commentable_id = commentable_id def url_with_id(self, params): return f"http://example.com/{params['id']}" diff --git a/lms/djangoapps/discussion/rest_api/urls.py b/lms/djangoapps/discussion/rest_api/urls.py index f8c5bb3255..f102dc41f2 100644 --- a/lms/djangoapps/discussion/rest_api/urls.py +++ b/lms/djangoapps/discussion/rest_api/urls.py @@ -8,6 +8,7 @@ from django.urls import include, path, re_path from rest_framework.routers import SimpleRouter from lms.djangoapps.discussion.rest_api.views import ( + BulkDeleteUserPosts, CommentViewSet, CourseActivityStatsView, CourseDiscussionRolesAPIView, @@ -87,5 +88,10 @@ urlpatterns = [ CourseTopicsViewV3.as_view(), name="course_topics_v3" ), + re_path( + fr"^v1/bulk_delete_user_posts/{settings.COURSE_ID_PATTERN}", + BulkDeleteUserPosts.as_view(), + name="bulk_delete_user_posts" + ), path('v1/', include(ROUTER.urls)), ] diff --git a/lms/djangoapps/discussion/rest_api/utils.py b/lms/djangoapps/discussion/rest_api/utils.py index e7dca49910..baadf0bc67 100644 --- a/lms/djangoapps/discussion/rest_api/utils.py +++ b/lms/djangoapps/discussion/rest_api/utils.py @@ -1,16 +1,23 @@ """ Utils for discussion API. """ +import logging from datetime import datetime from typing import Dict, List +import requests +from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.paginator import Paginator from django.db.models.functions import Length from pytz import UTC from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole +from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread + +from lms.djangoapps.discussion.config.settings import ENABLE_CAPTCHA_IN_DISCUSSION from lms.djangoapps.discussion.django_comment_client.utils import has_discussion_privileges +from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFY_ALL_LEARNERS from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, PostingRestriction from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_ADMINISTRATOR, @@ -20,6 +27,8 @@ from openedx.core.djangoapps.django_comment_common.models import ( Role ) +log = logging.getLogger(__name__) + class AttributeDict(dict): """ @@ -379,3 +388,63 @@ def is_posting_allowed(posting_restrictions: str, blackout_schedules: List): return not any(schedule["start"] <= now <= schedule["end"] for schedule in blackout_schedules) else: return False + + +def can_user_notify_all_learners(course_key, user_roles, is_course_staff, is_course_admin): + """ + Check if user posting is allowed to notify all learners based on the given restrictions + + Args: + course_key (CourseKey): CourseKey for which user creating any discussion post. + user_roles (Dict): Roles of the posting user + is_course_staff (Boolean): Whether the user has a course staff access. + is_course_admin (Boolean): Whether the user has a course admin access. + + Returns: + bool: True if posting for all learner is allowed to this user, False otherwise. + """ + is_staff_or_instructor = any([ + user_roles.intersection({FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR}), + is_course_staff, + is_course_admin, + ]) + + return is_staff_or_instructor and ENABLE_NOTIFY_ALL_LEARNERS.is_enabled(course_key) + + +def verify_recaptcha_token(token): + """ + Helper function to verify reCAPTCHA token + """ + verify_url = settings.RECAPTCHA_VERIFY_URL + verify_data = { + 'secret': settings.RECAPTCHA_PRIVATE_KEY, + 'response': token, + } + + try: + response = requests.post(verify_url, data=verify_data, timeout=10) + result = response.json() + log.info("reCAPTCHA verification result: %s", result) + return result.get('success', False) + except Exception as e: # pylint: disable=broad-except + log.error("Error verifying reCAPTCHA token: %s", e) + return False + + +def is_captcha_enabled(course_id) -> bool: + """ + Check if reCAPTCHA is enabled for discussion posts in the given course. + """ + return bool(ENABLE_CAPTCHA_IN_DISCUSSION.is_enabled(course_id) and settings.RECAPTCHA_PRIVATE_KEY) + + +def get_course_id_from_thread_id(thread_id: str) -> str: + """ + Get course id from thread id. + """ + thread = Thread(id=thread_id).retrieve(**{ + 'with_responses': False, + 'mark_as_read': False + }) + return thread["course_id"] diff --git a/lms/djangoapps/discussion/rest_api/views.py b/lms/djangoapps/discussion/rest_api/views.py index 2b0bf8c41e..46855d8385 100644 --- a/lms/djangoapps/discussion/rest_api/views.py +++ b/lms/djangoapps/discussion/rest_api/views.py @@ -1,11 +1,11 @@ """ Discussion API views """ - import logging import uuid import edx_api_doc_tools as apidocs + from django.contrib.auth import get_user_model from django.core.exceptions import BadRequest, ValidationError from django.shortcuts import get_object_or_404 @@ -20,11 +20,16 @@ from rest_framework.parsers import JSONParser from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.viewsets import ViewSet + from xmodule.modulestore.django import modulestore +from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.util.file import store_uploaded_file from lms.djangoapps.course_api.blocks.api import get_blocks from lms.djangoapps.course_goals.models import UserActivity +from lms.djangoapps.discussion.rest_api.permissions import IsAllowedToBulkDelete +from lms.djangoapps.discussion.rest_api.tasks import delete_course_post_for_user +from lms.djangoapps.discussion.toggles import ONLY_VERIFIED_USERS_CAN_POST from lms.djangoapps.discussion.django_comment_client import settings as cc_settings from lms.djangoapps.discussion.django_comment_client.utils import get_group_id_for_comments_service from lms.djangoapps.instructor.access import update_forum_role @@ -33,6 +38,8 @@ from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, from openedx.core.djangoapps.discussions.serializers import DiscussionSettingsSerializer from openedx.core.djangoapps.django_comment_common import comment_client from openedx.core.djangoapps.django_comment_common.models import CourseDiscussionSettings, Role +from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment +from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread from openedx.core.djangoapps.user_api.accounts.permissions import CanReplaceUsername, CanRetireUser from openedx.core.djangoapps.user_api.models import UserRetirementStatus from openedx.core.lib.api.authentication import BearerAuthentication, BearerAuthenticationAllowInactiveUser @@ -79,10 +86,9 @@ from ..rest_api.serializers import ( ) from .utils import ( create_blocks_params, - create_topics_v3_structure, + create_topics_v3_structure, is_captcha_enabled, verify_recaptcha_token, get_course_id_from_thread_id, ) - log = logging.getLogger(__name__) User = get_user_model() @@ -664,7 +670,24 @@ class ThreadViewSet(DeveloperErrorViewMixin, ViewSet): Implements the POST method for the list endpoint as described in the class docstring. """ - return Response(create_thread(request, request.data)) + if not request.data.get("course_id"): + raise ValidationError({"course_id": ["This field is required."]}) + course_key_str = request.data.get("course_id") + course_key = CourseKey.from_string(course_key_str) + if is_captcha_enabled(course_key): + captcha_token = request.data.get('captcha_token') + if not captcha_token: + raise ValidationError({'captcha_token': 'This field is required.'}) + + if not verify_recaptcha_token(captcha_token): + return Response({'error': 'CAPTCHA verification failed.'}, status=400) + + if ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key) and not request.user.is_active: + raise ValidationError({"detail": "Only verified users can post in discussions."}) + + data = request.data.copy() + data.pop('captcha_token', None) + return Response(create_thread(request, data)) def partial_update(self, request, thread_id): """ @@ -1019,7 +1042,25 @@ class CommentViewSet(DeveloperErrorViewMixin, ViewSet): Implements the POST method for the list endpoint as described in the class docstring. """ - return Response(create_comment(request, request.data)) + if not request.data.get("thread_id"): + raise ValidationError({"thread_id": ["This field is required."]}) + course_key_str = get_course_id_from_thread_id(request.data["thread_id"]) + course_key = CourseKey.from_string(course_key_str) + + if is_captcha_enabled(course_key): + captcha_token = request.data.get('captcha_token') + if not captcha_token: + raise ValidationError({'captcha_token': 'This field is required.'}) + + if not verify_recaptcha_token(captcha_token): + return Response({'error': 'CAPTCHA verification failed.'}, status=400) + + if ONLY_VERIFIED_USERS_CAN_POST.is_enabled(course_key) and not request.user.is_active: + raise ValidationError({"detail": "Only verified users can post in discussions."}) + + data = request.data.copy() + data.pop('captcha_token', None) + return Response(create_comment(request, data)) def destroy(self, request, comment_id): """ @@ -1492,3 +1533,68 @@ class CourseDiscussionRolesAPIView(DeveloperErrorViewMixin, APIView): context = {'course_discussion_settings': CourseDiscussionSettings.get(course_id)} serializer = DiscussionRolesListSerializer(data, context=context) return Response(serializer.data) + + +class BulkDeleteUserPosts(DeveloperErrorViewMixin, APIView): + """ + **Use Cases** + A privileged user that can delete all posts and comments made by a user. + It returns expected number of comments and threads that will be deleted + + **Example Requests**: + POST /api/discussion/v1/bulk_delete_user_posts/{course_id} + Query Parameters: + username: The username of the user whose posts are to be deleted + course_id: Course id for which posts are to be removed + execute: If True, runs deletion task + course_or_org: If 'course', deletes posts in the course, if 'org', deletes posts in all courses of the org + + **Example Response**: + Empty string + """ + + authentication_classes = ( + JwtAuthentication, BearerAuthentication, SessionAuthentication, + ) + permission_classes = (permissions.IsAuthenticated, IsAllowedToBulkDelete) + + def post(self, request, course_id): + """ + Implements the delete user posts endpoint. + TODO: Add support for MySQLBackend as well + """ + username = request.GET.get("username", None) + execute_task = request.GET.get("execute", "false").lower() == "true" + if (not username) or (not course_id): + raise BadRequest("username and course_id are required.") + course_or_org = request.GET.get("course_or_org", "course") + if course_or_org not in ["course", "org"]: + raise BadRequest("course_or_org must be either 'course' or 'org'.") + + user = get_object_or_404(User, username=username) + course_ids = [course_id] + if course_or_org == "org": + org_id = CourseKey.from_string(course_id).org + course_ids = [ + str(c_id) + for c_id in CourseEnrollment.objects.filter(user=request.user).values_list('course_id', flat=True) + if c_id.org == org_id + ] + + comment_count = Comment.get_user_comment_count(user.id, course_ids) + thread_count = Thread.get_user_threads_count(user.id, course_ids) + + if execute_task: + event_data = { + "triggered_by": request.user.username, + "username": username, + "course_or_org": course_or_org, + "course_key": course_id, + } + delete_course_post_for_user.apply_async( + args=(user.id, username, course_ids, event_data), + ) + return Response( + {"comment_count": comment_count, "thread_count": thread_count}, + status=status.HTTP_202_ACCEPTED + ) diff --git a/lms/djangoapps/discussion/signals/handlers.py b/lms/djangoapps/discussion/signals/handlers.py index 73c19d2785..ec14cd8281 100644 --- a/lms/djangoapps/discussion/signals/handlers.py +++ b/lms/djangoapps/discussion/signals/handlers.py @@ -166,7 +166,10 @@ def create_thread_created_notification(*args, **kwargs): """ user = kwargs['user'] post = kwargs['post'] - send_thread_created_notification.apply_async(args=[post.id, post.attributes['course_id'], user.id]) + notify_all_learners = kwargs.get('notify_all_learners', False) + send_thread_created_notification.apply_async( + args=[post.id, post.attributes['course_id'], user.id, notify_all_learners] + ) @receiver(signals.comment_created) diff --git a/lms/djangoapps/discussion/tasks.py b/lms/djangoapps/discussion/tasks.py index 3fef4f5f7c..d483388f54 100644 --- a/lms/djangoapps/discussion/tasks.py +++ b/lms/djangoapps/discussion/tasks.py @@ -120,8 +120,6 @@ def send_ace_message(context): # lint-amnesty, pylint: disable=missing-function log.info('Sending forum comment notification with context %s', message_context) ace.send(message, limit_to_channels=[ChannelType.PUSH]) _track_notification_sent(message, context) - else: - return @shared_task(base=LoggedTask) diff --git a/lms/djangoapps/discussion/tests/test_tasks.py b/lms/djangoapps/discussion/tests/test_tasks.py index 92dadac9d9..952a6c567a 100644 --- a/lms/djangoapps/discussion/tests/test_tasks.py +++ b/lms/djangoapps/discussion/tests/test_tasks.py @@ -232,6 +232,22 @@ class TaskTestCase(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missin thread_permalink = '/courses/discussion/dummy_discussion_id' self.permalink_patcher = mock.patch('lms.djangoapps.discussion.tasks.permalink', return_value=thread_permalink) self.mock_permalink = self.permalink_patcher.start() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def tearDown(self): super().tearDown() diff --git a/lms/djangoapps/discussion/tests/test_views.py b/lms/djangoapps/discussion/tests/test_views.py index e0d3b869da..8bb0b45500 100644 --- a/lms/djangoapps/discussion/tests/test_views.py +++ b/lms/djangoapps/discussion/tests/test_views.py @@ -4,6 +4,7 @@ Tests the forum notification views. import json import logging from datetime import datetime +from unittest import mock from unittest.mock import ANY, Mock, call, patch import ddt @@ -16,8 +17,6 @@ from django.urls import reverse from django.utils import translation from edx_django_utils.cache import RequestCache from edx_toggles.toggles.testutils import override_waffle_flag -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import ( TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase, @@ -26,7 +25,6 @@ from xmodule.modulestore.tests.django_utils import ( from xmodule.modulestore.tests.factories import ( CourseFactory, BlockFactory, - check_mongo_calls ) from common.djangoapps.course_modes.models import CourseMode @@ -41,7 +39,6 @@ from lms.djangoapps.discussion.django_comment_client.permissions import get_team from lms.djangoapps.discussion.django_comment_client.tests.group_id import ( CohortedTopicGroupIdTestMixin, GroupIdAssertionMixin, - NonCohortedTopicGroupIdTestMixin ) from lms.djangoapps.discussion.django_comment_client.tests.unicode import UnicodeTestMixin from lms.djangoapps.discussion.django_comment_client.tests.utils import ( @@ -54,7 +51,6 @@ from lms.djangoapps.discussion.django_comment_client.utils import strip_none from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE from lms.djangoapps.discussion.views import _get_discussion_default_topic_id, course_discussions_settings_handler from lms.djangoapps.teams.tests.factories import CourseTeamFactory, CourseTeamMembershipFactory -from openedx.core.djangoapps.course_groups.models import CourseUserGroup from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts from openedx.core.djangoapps.course_groups.tests.test_views import CohortViewsTestCase from openedx.core.djangoapps.django_comment_common.comment_client.utils import CommentClientPaginatedResult @@ -67,8 +63,6 @@ from openedx.core.djangoapps.django_comment_common.utils import ThreadContext, s from openedx.core.djangoapps.util.testing import ContentGroupTestCase from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES from openedx.core.lib.teams_config import TeamsConfig -from openedx.features.content_type_gating.models import ContentTypeGatingConfig -from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired log = logging.getLogger(__name__) @@ -109,9 +103,20 @@ class ViewsExceptionTestCase(UrlResetMixin, ModuleStoreTestCase): # lint-amnest config = ForumsConfig.current() config.enabled = True config.save() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) @patch('common.djangoapps.student.models.user.cc.User.from_django_user') - @patch('common.djangoapps.student.models.user.cc.User.active_threads') + @patch('openedx.core.djangoapps.django_comment_common.comment_client.user.User.active_threads') def test_user_profile_exception(self, mock_threads, mock_from_django_user): # Mock the code that makes the HTTP requests to the cs_comment_service app @@ -323,6 +328,17 @@ class SingleThreadTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amne def setUp(self): super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': 'dummy_discussion_id'}}) self.student = UserFactory.create() @@ -506,82 +522,23 @@ class AllowPlusOrMinusOneInt(int): return f"({self.value} +/- 1)" -@ddt.ddt -@patch('requests.request', autospec=True) -class SingleThreadQueryCountTestCase(ForumsEnableMixin, ModuleStoreTestCase): - """ - Ensures the number of modulestore queries and number of sql queries are - independent of the number of responses retrieved for a given discussion thread. - """ - @ddt.data( - # split mongo: 3 queries, regardless of thread response size. - (False, 1, 2, 2, 21, 8), - (False, 50, 2, 2, 21, 8), - - # Enabling Enterprise integration should have no effect on the number of mongo queries made. - # split mongo: 3 queries, regardless of thread response size. - (True, 1, 2, 2, 21, 8), - (True, 50, 2, 2, 21, 8), - ) - @ddt.unpack - def test_number_of_mongo_queries( - self, - enterprise_enabled, - num_thread_responses, - num_uncached_mongo_calls, - num_cached_mongo_calls, - num_uncached_sql_queries, - num_cached_sql_queries, - mock_request - ): - ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) - with modulestore().default_store(ModuleStoreEnum.Type.split): - course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': 'dummy_discussion_id'}}) - - student = UserFactory.create() - CourseEnrollmentFactory.create(user=student, course_id=course.id) - - test_thread_id = "test_thread_id" - mock_request.side_effect = make_mock_request_impl( - course=course, text="dummy content", thread_id=test_thread_id, num_thread_responses=num_thread_responses - ) - request = RequestFactory().get( - "dummy_url", - HTTP_X_REQUESTED_WITH="XMLHttpRequest" - ) - request.user = student - - def call_single_thread(): - """ - Call single_thread and assert that it returns what we expect. - """ - with patch.dict("django.conf.settings.FEATURES", dict(ENABLE_ENTERPRISE_INTEGRATION=enterprise_enabled)): - response = views.single_thread( - request, - str(course.id), - "dummy_discussion_id", - test_thread_id - ) - assert response.status_code == 200 - assert len(json.loads(response.content.decode('utf-8'))['content']['children']) == num_thread_responses - - # Test uncached first, then cached now that the cache is warm. - cached_calls = [ - [num_uncached_mongo_calls, num_uncached_sql_queries], - # Sometimes there will be one more or fewer sql call than expected, because the call to - # CourseMode.modes_for_course sometimes does / doesn't get cached and does / doesn't hit the DB. - # EDUCATOR-5167 - [num_cached_mongo_calls, AllowPlusOrMinusOneInt(num_cached_sql_queries)], - ] - for expected_mongo_calls, expected_sql_queries in cached_calls: - with self.assertNumQueries(expected_sql_queries, table_ignorelist=QUERY_COUNT_TABLE_IGNORELIST): - with check_mongo_calls(expected_mongo_calls): - call_single_thread() - - @patch('requests.request', autospec=True) class SingleCohortedThreadTestCase(CohortedTestCase): # lint-amnesty, pylint: disable=missing-class-docstring + def setUp(self): + super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + def _create_mock_cohorted_thread(self, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring mock_text = "dummy content" mock_thread_id = "test_thread_id" @@ -644,6 +601,20 @@ class SingleCohortedThreadTestCase(CohortedTestCase): # lint-amnesty, pylint: d @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) class SingleThreadAccessTestCase(CohortedTestCase): # lint-amnesty, pylint: disable=missing-class-docstring + def setUp(self): + super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + def call_view(self, mock_request, commentable_id, user, group_id, thread_group_id=None, pass_group_id=True): # lint-amnesty, pylint: disable=missing-function-docstring thread_id = "test_thread_id" mock_request.side_effect = make_mock_request_impl( @@ -746,6 +717,20 @@ class SingleThreadAccessTestCase(CohortedTestCase): # lint-amnesty, pylint: dis class SingleThreadGroupIdTestCase(CohortedTestCase, GroupIdAssertionMixin): # lint-amnesty, pylint: disable=missing-class-docstring cs_endpoint = "/threads/dummy_thread_id" + def setUp(self): + super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True, is_ajax=False): # lint-amnesty, pylint: disable=missing-function-docstring mock_request.side_effect = make_mock_request_impl( course=self.course, text="dummy context", group_id=self.student_cohort.id @@ -789,98 +774,28 @@ class SingleThreadGroupIdTestCase(CohortedTestCase, GroupIdAssertionMixin): # l ) -@patch('requests.request', autospec=True) -class ForumFormDiscussionContentGroupTestCase(ForumsEnableMixin, ContentGroupTestCase): - """ - Tests `forum_form_discussion api` works with different content groups. - Discussion blocks are setup in ContentGroupTestCase class i.e - alpha_block => alpha_group_discussion => alpha_cohort => alpha_user/community_ta - beta_block => beta_group_discussion => beta_cohort => beta_user - """ - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - self.thread_list = [ - {"thread_id": "test_general_thread_id"}, - {"thread_id": "test_global_group_thread_id", "commentable_id": self.global_block.discussion_id}, - {"thread_id": "test_alpha_group_thread_id", "group_id": self.alpha_block.group_access[0][0], - "commentable_id": self.alpha_block.discussion_id}, - {"thread_id": "test_beta_group_thread_id", "group_id": self.beta_block.group_access[0][0], - "commentable_id": self.beta_block.discussion_id} - ] - - def assert_has_access(self, response, expected_discussion_threads): - """ - Verify that a users have access to the threads in their assigned - cohorts and non-cohorted blocks. - """ - discussion_data = json.loads(response.content.decode('utf-8'))['discussion_data'] - assert len(discussion_data) == expected_discussion_threads - - def call_view(self, mock_request, user): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl( - course=self.course, - text="dummy content", - thread_list=self.thread_list - ) - self.client.login(username=user.username, password=self.TEST_PASSWORD) - return self.client.get( - reverse("forum_form_discussion", args=[str(self.course.id)]), - HTTP_X_REQUESTED_WITH="XMLHttpRequest" - ) - - def test_community_ta_user(self, mock_request): - """ - Verify that community_ta user has access to all threads regardless - of cohort. - """ - response = self.call_view( - mock_request, - self.community_ta - ) - self.assert_has_access(response, 4) - - def test_alpha_cohort_user(self, mock_request): - """ - Verify that alpha_user has access to alpha_cohort and non-cohorted - threads. - """ - response = self.call_view( - mock_request, - self.alpha_user - ) - self.assert_has_access(response, 3) - - def test_beta_cohort_user(self, mock_request): - """ - Verify that beta_user has access to beta_cohort and non-cohorted - threads. - """ - response = self.call_view( - mock_request, - self.beta_user - ) - self.assert_has_access(response, 3) - - def test_global_staff_user(self, mock_request): - """ - Verify that global staff user has access to all threads regardless - of cohort. - """ - response = self.call_view( - mock_request, - self.staff_user - ) - self.assert_has_access(response, 4) - - @patch('requests.request', autospec=True) class SingleThreadContentGroupTestCase(ForumsEnableMixin, UrlResetMixin, ContentGroupTestCase): # lint-amnesty, pylint: disable=missing-class-docstring @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) def assert_can_access(self, user, discussion_id, thread_id, should_have_access): """ @@ -986,325 +901,28 @@ class SingleThreadContentGroupTestCase(ForumsEnableMixin, UrlResetMixin, Content @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -class InlineDiscussionContextTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - def setUp(self): - super().setUp() - self.course = CourseFactory.create() - CourseEnrollmentFactory(user=self.user, course_id=self.course.id) - self.discussion_topic_id = "dummy_topic" - self.team = CourseTeamFactory( - name="A team", - course_id=self.course.id, - topic_id='topic_id', - discussion_topic_id=self.discussion_topic_id - ) - - self.team.add_user(self.user) - self.user_not_in_team = UserFactory.create() - - def test_context_can_be_standalone(self, mock_request): - mock_request.side_effect = make_mock_request_impl( - course=self.course, - text="dummy text", - commentable_id=self.discussion_topic_id - ) - - request = RequestFactory().get("dummy_url") - request.user = self.user - - response = views.inline_discussion( - request, - str(self.course.id), - self.discussion_topic_id, - ) - - json_response = json.loads(response.content.decode('utf-8')) - assert json_response['discussion_data'][0]['context'] == ThreadContext.STANDALONE - - def test_private_team_discussion(self, mock_request): - # First set the team discussion to be private - CourseEnrollmentFactory(user=self.user_not_in_team, course_id=self.course.id) - request = RequestFactory().get("dummy_url") - request.user = self.user_not_in_team - - mock_request.side_effect = make_mock_request_impl( - course=self.course, - text="dummy text", - commentable_id=self.discussion_topic_id - ) - - with patch('lms.djangoapps.teams.api.is_team_discussion_private', autospec=True) as mocked: - mocked.return_value = True - response = views.inline_discussion( - request, - str(self.course.id), - self.discussion_topic_id, - ) - assert response.status_code == 403 - assert response.content.decode('utf-8') == views.TEAM_PERMISSION_MESSAGE - - -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -class InlineDiscussionGroupIdTestCase( # lint-amnesty, pylint: disable=missing-class-docstring - CohortedTestCase, - CohortedTopicGroupIdTestMixin, - NonCohortedTopicGroupIdTestMixin -): - cs_endpoint = "/threads" - - def setUp(self): - super().setUp() - self.cohorted_commentable_id = 'cohorted_topic' - - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True): - kwargs = {'commentable_id': self.cohorted_commentable_id} - if group_id: - # avoid causing a server error when the LMS chokes attempting - # to find a group name for the group_id, when we're testing with - # an invalid one. - try: - CourseUserGroup.objects.get(id=group_id) - kwargs['group_id'] = group_id - except CourseUserGroup.DoesNotExist: - pass - mock_request.side_effect = make_mock_request_impl(self.course, "dummy content", **kwargs) - - request_data = {} - if pass_group_id: - request_data["group_id"] = group_id - request = RequestFactory().get( - "dummy_url", - data=request_data - ) - request.user = user - return views.inline_discussion( - request, - str(self.course.id), - commentable_id - ) - - def test_group_info_in_ajax_response(self, mock_request): - response = self.call_view( - mock_request, - self.cohorted_commentable_id, - self.student, - self.student_cohort.id - ) - self._assert_json_response_contains_group_info( - response, lambda d: d['discussion_data'][0] - ) - - -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -class ForumFormDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring - cs_endpoint = "/threads" - - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True, is_ajax=False): # pylint: disable=arguments-differ - kwargs = {} - if group_id: - kwargs['group_id'] = group_id - mock_request.side_effect = make_mock_request_impl(self.course, "dummy content", **kwargs) - - request_data = {} - if pass_group_id: - request_data["group_id"] = group_id - headers = {} - if is_ajax: - headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest" - - self.client.login(username=user.username, password=self.TEST_PASSWORD) - return self.client.get( - reverse("forum_form_discussion", args=[str(self.course.id)]), - data=request_data, - **headers - ) - - def test_group_info_in_html_response(self, mock_request): - response = self.call_view( - mock_request, - "cohorted_topic", - self.student, - self.student_cohort.id - ) - self._assert_html_response_contains_group_info(response) - - def test_group_info_in_ajax_response(self, mock_request): - response = self.call_view( - mock_request, - "cohorted_topic", - self.student, - self.student_cohort.id, - is_ajax=True - ) - self._assert_json_response_contains_group_info( - response, lambda d: d['discussion_data'][0] - ) - - -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -class UserProfileDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring - cs_endpoint = "/active_threads" - - def call_view_for_profiled_user( - self, mock_request, requesting_user, profiled_user, group_id, pass_group_id, is_ajax=False - ): - """ - Calls "user_profile" view method on behalf of "requesting_user" to get information about - the user "profiled_user". - """ - kwargs = {} - if group_id: - kwargs['group_id'] = group_id - mock_request.side_effect = make_mock_request_impl(self.course, "dummy content", **kwargs) - - request_data = {} - if pass_group_id: - request_data["group_id"] = group_id - headers = {} - if is_ajax: - headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest" - - self.client.login(username=requesting_user.username, password=self.TEST_PASSWORD) - return self.client.get( - reverse('user_profile', args=[str(self.course.id), profiled_user.id]), - data=request_data, - **headers - ) - - def call_view(self, mock_request, _commentable_id, user, group_id, pass_group_id=True, is_ajax=False): # pylint: disable=arguments-differ - return self.call_view_for_profiled_user( - mock_request, user, user, group_id, pass_group_id=pass_group_id, is_ajax=is_ajax - ) - - def test_group_info_in_html_response(self, mock_request): - response = self.call_view( - mock_request, - "cohorted_topic", - self.student, - self.student_cohort.id, - is_ajax=False - ) - self._assert_html_response_contains_group_info(response) - - def test_group_info_in_ajax_response(self, mock_request): - response = self.call_view( - mock_request, - "cohorted_topic", - self.student, - self.student_cohort.id, - is_ajax=True - ) - self._assert_json_response_contains_group_info( - response, lambda d: d['discussion_data'][0] - ) - - def _test_group_id_passed_to_user_profile( - self, mock_request, expect_group_id_in_request, requesting_user, profiled_user, group_id, pass_group_id - ): - """ - Helper method for testing whether or not group_id was passed to the user_profile request. - """ - - def get_params_from_user_info_call(for_specific_course): - """ - Returns the request parameters for the user info call with either course_id specified or not, - depending on value of 'for_specific_course'. - """ - # There will be 3 calls from user_profile. One has the cs_endpoint "active_threads", and it is already - # tested. The other 2 calls are for user info; one of those calls is for general information about the user, - # and it does not specify a course_id. The other call does specify a course_id, and if the caller did not - # have discussion moderator privileges, it should also contain a group_id. - for r_call in mock_request.call_args_list: - if not r_call[0][1].endswith(self.cs_endpoint): - params = r_call[1]["params"] - has_course_id = "course_id" in params - if (for_specific_course and has_course_id) or (not for_specific_course and not has_course_id): - return params - pytest.fail("Did not find appropriate user_profile call for 'for_specific_course'=" + for_specific_course) - - mock_request.reset_mock() - self.call_view_for_profiled_user( - mock_request, - requesting_user, - profiled_user, - group_id, - pass_group_id=pass_group_id, - is_ajax=False - ) - # Should never have a group_id if course_id was not included in the request. - params_without_course_id = get_params_from_user_info_call(False) - assert 'group_id' not in params_without_course_id - - params_with_course_id = get_params_from_user_info_call(True) - if expect_group_id_in_request: - assert 'group_id' in params_with_course_id - assert group_id == params_with_course_id['group_id'] - else: - assert 'group_id' not in params_with_course_id - - def test_group_id_passed_to_user_profile_student(self, mock_request): - """ - Test that the group id is always included when requesting user profile information for a particular - course if the requester does not have discussion moderation privileges. - """ - def verify_group_id_always_present(profiled_user, pass_group_id): - """ - Helper method to verify that group_id is always present for student in course - (non-privileged user). - """ - self._test_group_id_passed_to_user_profile( - mock_request, True, self.student, profiled_user, self.student_cohort.id, pass_group_id - ) - - # In all these test cases, the requesting_user is the student (non-privileged user). - # The profile returned on behalf of the student is for the profiled_user. - verify_group_id_always_present(profiled_user=self.student, pass_group_id=True) - verify_group_id_always_present(profiled_user=self.student, pass_group_id=False) - verify_group_id_always_present(profiled_user=self.moderator, pass_group_id=True) - verify_group_id_always_present(profiled_user=self.moderator, pass_group_id=False) - - def test_group_id_user_profile_moderator(self, mock_request): - """ - Test that the group id is only included when a privileged user requests user profile information for a - particular course and user if the group_id is explicitly passed in. - """ - def verify_group_id_present(profiled_user, pass_group_id, requested_cohort=self.moderator_cohort): - """ - Helper method to verify that group_id is present. - """ - self._test_group_id_passed_to_user_profile( - mock_request, True, self.moderator, profiled_user, requested_cohort.id, pass_group_id - ) - - def verify_group_id_not_present(profiled_user, pass_group_id, requested_cohort=self.moderator_cohort): - """ - Helper method to verify that group_id is not present. - """ - self._test_group_id_passed_to_user_profile( - mock_request, False, self.moderator, profiled_user, requested_cohort.id, pass_group_id - ) - - # In all these test cases, the requesting_user is the moderator (privileged user). - - # If the group_id is explicitly passed, it will be present in the request. - verify_group_id_present(profiled_user=self.student, pass_group_id=True) - verify_group_id_present(profiled_user=self.moderator, pass_group_id=True) - verify_group_id_present( - profiled_user=self.student, pass_group_id=True, requested_cohort=self.student_cohort - ) - - # If the group_id is not explicitly passed, it will not be present because the requesting_user - # has discussion moderator privileges. - verify_group_id_not_present(profiled_user=self.student, pass_group_id=False) - verify_group_id_not_present(profiled_user=self.moderator, pass_group_id=False) - - -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) +@patch('openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', autospec=True) class FollowedThreadsDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring cs_endpoint = "/subscribed_threads" - def call_view(self, mock_request, commentable_id, user, group_id, pass_group_id=True): + def setUp(self): + super().setUp() + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + + def call_view( + self, + mock_is_forum_v2_enabled, + mock_request, + commentable_id, + user, + group_id, + pass_group_id=True + ): # pylint: disable=arguments-differ + mock_is_forum_v2_enabled.return_value = False kwargs = {} if group_id: kwargs['group_id'] = group_id @@ -1325,8 +943,9 @@ class FollowedThreadsDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGr user.id ) - def test_group_info_in_ajax_response(self, mock_request): + def test_group_info_in_ajax_response(self, mock_is_forum_v2_enabled, mock_request): response = self.call_view( + mock_is_forum_v2_enabled, mock_request, "cohorted_topic", self.student, @@ -1337,189 +956,6 @@ class FollowedThreadsDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGr ) -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -class InlineDiscussionTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - def setUp(self): - super().setUp() - - self.course = CourseFactory.create( - org="TestX", - number="101", - display_name="Test Course", - teams_configuration=TeamsConfig({ - 'topics': [{ - 'id': 'topic_id', - 'name': 'A topic', - 'description': 'A topic', - }] - }) - ) - self.student = UserFactory.create() - CourseEnrollmentFactory(user=self.student, course_id=self.course.id) - self.discussion1 = BlockFactory.create( - parent_location=self.course.location, - category="discussion", - discussion_id="discussion1", - display_name='Discussion1', - discussion_category="Chapter", - discussion_target="Discussion1" - ) - - def send_request(self, mock_request, params=None): - """ - Creates and returns a request with params set, and configures - mock_request to return appropriate values. - """ - request = RequestFactory().get("dummy_url", params if params else {}) - request.user = self.student - mock_request.side_effect = make_mock_request_impl( - course=self.course, text="dummy content", commentable_id=self.discussion1.discussion_id - ) - return views.inline_discussion( - request, str(self.course.id), self.discussion1.discussion_id - ) - - def test_context(self, mock_request): - team = CourseTeamFactory( - name='Team Name', - topic_id='topic_id', - course_id=self.course.id, - discussion_topic_id=self.discussion1.discussion_id - ) - - team.add_user(self.student) - - self.send_request(mock_request) - assert mock_request.call_args[1]['params']['context'] == ThreadContext.STANDALONE - - -@patch('requests.request', autospec=True) -class UserProfileTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - TEST_THREAD_TEXT = 'userprofile-test-text' - TEST_THREAD_ID = 'userprofile-test-thread-id' - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - - self.course = CourseFactory.create() - self.student = UserFactory.create() - self.profiled_user = UserFactory.create() - CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - CourseEnrollmentFactory.create(user=self.profiled_user, course_id=self.course.id) - - def get_response(self, mock_request, params, **headers): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl( - course=self.course, text=self.TEST_THREAD_TEXT, thread_id=self.TEST_THREAD_ID - ) - self.client.login(username=self.student.username, password=self.TEST_PASSWORD) - - response = self.client.get( - reverse('user_profile', kwargs={ - 'course_id': str(self.course.id), - 'user_id': self.profiled_user.id, - }), - data=params, - **headers - ) - mock_request.assert_any_call( - "get", - StringEndsWithMatcher(f'/users/{self.profiled_user.id}/active_threads'), - data=None, - params=PartialDictMatcher({ - "course_id": str(self.course.id), - "page": params.get("page", 1), - "per_page": views.THREADS_PER_PAGE - }), - headers=ANY, - timeout=ANY - ) - return response - - def check_html(self, mock_request, **params): # lint-amnesty, pylint: disable=missing-function-docstring - response = self.get_response(mock_request, params) - assert response.status_code == 200 - assert response['Content-Type'] == 'text/html; charset=utf-8' - html = response.content.decode('utf-8') - self.assertRegex(html, r'data-page="1"') - self.assertRegex(html, r'data-num-pages="1"') - self.assertRegex(html, r'1 discussion started') - self.assertRegex(html, r'2 comments') - self.assertRegex(html, f''id': '{self.TEST_THREAD_ID}'') - self.assertRegex(html, f''title': '{self.TEST_THREAD_TEXT}'') - self.assertRegex(html, f''body': '{self.TEST_THREAD_TEXT}'') - self.assertRegex(html, f''username': '{self.student.username}'') - - def check_ajax(self, mock_request, **params): # lint-amnesty, pylint: disable=missing-function-docstring - response = self.get_response(mock_request, params, HTTP_X_REQUESTED_WITH="XMLHttpRequest") - assert response.status_code == 200 - assert response['Content-Type'] == 'application/json; charset=utf-8' - response_data = json.loads(response.content.decode('utf-8')) - assert sorted(response_data.keys()) == ['annotated_content_info', 'discussion_data', 'num_pages', 'page'] - assert len(response_data['discussion_data']) == 1 - assert response_data['page'] == 1 - assert response_data['num_pages'] == 1 - assert response_data['discussion_data'][0]['id'] == self.TEST_THREAD_ID - assert response_data['discussion_data'][0]['title'] == self.TEST_THREAD_TEXT - assert response_data['discussion_data'][0]['body'] == self.TEST_THREAD_TEXT - - def test_html(self, mock_request): - self.check_html(mock_request) - - def test_ajax(self, mock_request): - self.check_ajax(mock_request) - - def test_404_non_enrolled_user(self, __): - """ - Test that when student try to visit un-enrolled students' discussion profile, - the system raises Http404. - """ - unenrolled_user = UserFactory.create() - request = RequestFactory().get("dummy_url") - request.user = self.student - with pytest.raises(Http404): - views.user_profile( - request, - str(self.course.id), - unenrolled_user.id - ) - - def test_404_profiled_user(self, _mock_request): - request = RequestFactory().get("dummy_url") - request.user = self.student - with pytest.raises(Http404): - views.user_profile( - request, - str(self.course.id), - -999 - ) - - def test_404_course(self, _mock_request): - request = RequestFactory().get("dummy_url") - request.user = self.student - with pytest.raises(Http404): - views.user_profile( - request, - "non/existent/course", - self.profiled_user.id - ) - - def test_post(self, mock_request): - mock_request.side_effect = make_mock_request_impl( - course=self.course, text=self.TEST_THREAD_TEXT, thread_id=self.TEST_THREAD_ID - ) - request = RequestFactory().post("dummy_url") - request.user = self.student - response = views.user_profile( - request, - str(self.course.id), - self.profiled_user.id - ) - assert response.status_code == 405 - - @patch('requests.request', autospec=True) class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring @@ -1528,6 +964,22 @@ class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, Mo @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) def setUp(self): super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.models.forum_api.get_course_id_by_comment" + ) + self.mock_get_course_id_by_comment = patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) username = "foo" password = "bar" @@ -1585,155 +1037,6 @@ class CommentsServiceRequestHeadersTestCase(ForumsEnableMixin, UrlResetMixin, Mo self.assert_all_calls_have_header(mock_request, "X-Edx-Api-Key", "test_api_key") -class InlineDiscussionUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring - - @classmethod - def setUpClass(cls): - # pylint: disable=super-method-not-called - with super().setUpClassAndTestData(): - cls.course = CourseFactory.create() - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - cls.student = UserFactory.create() - CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) - - @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl(course=self.course, text=text) - request = RequestFactory().get("dummy_url") - request.user = self.student - - response = views.inline_discussion( - request, str(self.course.id), self.course.discussion_topics['General']['id'] - ) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data['discussion_data'][0]['title'] == text - assert response_data['discussion_data'][0]['body'] == text - - -class ForumFormDiscussionUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring - - @classmethod - def setUpClass(cls): - # pylint: disable=super-method-not-called - with super().setUpClassAndTestData(): - cls.course = CourseFactory.create() - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - cls.student = UserFactory.create() - CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) - - @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl(course=self.course, text=text) - request = RequestFactory().get("dummy_url") - request.user = self.student - # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True - request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" - - response = views.forum_form_discussion(request, str(self.course.id)) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data['discussion_data'][0]['title'] == text - assert response_data['discussion_data'][0]['body'] == text - - -@ddt.ddt -@patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) -class ForumDiscussionXSSTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - super().setUp() - - username = "foo" - password = "bar" - - self.course = CourseFactory.create() - self.student = UserFactory.create(username=username, password=password) - CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - assert self.client.login(username=username, password=password) - - @ddt.data('">', '', '') - @patch('common.djangoapps.student.models.user.cc.User.from_django_user') - def test_forum_discussion_xss_prevent(self, malicious_code, mock_user, mock_req): - """ - Test that XSS attack is prevented - """ - mock_user.return_value.to_dict.return_value = {} - mock_req.return_value.status_code = 200 - reverse_url = "{}{}".format(reverse( - "forum_form_discussion", - kwargs={"course_id": str(self.course.id)}), '/forum_form_discussion') - # Test that malicious code does not appear in html - url = "{}?{}={}".format(reverse_url, 'sort_key', malicious_code) - resp = self.client.get(url) - self.assertNotContains(resp, malicious_code) - - @ddt.data('">', '', '') - @patch('common.djangoapps.student.models.user.cc.User.from_django_user') - @patch('common.djangoapps.student.models.user.cc.User.active_threads') - def test_forum_user_profile_xss_prevent(self, malicious_code, mock_threads, mock_from_django_user, mock_request): - """ - Test that XSS attack is prevented - """ - mock_threads.return_value = [], 1, 1 - mock_from_django_user.return_value.to_dict.return_value = { - 'upvoted_ids': [], - 'downvoted_ids': [], - 'subscribed_thread_ids': [] - } - mock_request.side_effect = make_mock_request_impl(course=self.course, text='dummy') - - url = reverse('user_profile', - kwargs={'course_id': str(self.course.id), 'user_id': str(self.student.id)}) - # Test that malicious code does not appear in html - url_string = "{}?{}={}".format(url, 'page', malicious_code) - resp = self.client.get(url_string) - self.assertNotContains(resp, malicious_code) - - -class ForumDiscussionSearchUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring - - @classmethod - def setUpClass(cls): - # pylint: disable=super-method-not-called - with super().setUpClassAndTestData(): - cls.course = CourseFactory.create() - - @classmethod - def setUpTestData(cls): - super().setUpTestData() - - cls.student = UserFactory.create() - CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) - - @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) - def _test_unicode_data(self, text, mock_request): # lint-amnesty, pylint: disable=missing-function-docstring - mock_request.side_effect = make_mock_request_impl(course=self.course, text=text) - data = { - "ajax": 1, - "text": text, - } - request = RequestFactory().get("dummy_url", data) - request.user = self.student - # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True - request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" - - response = views.forum_form_discussion(request, str(self.course.id)) - assert response.status_code == 200 - response_data = json.loads(response.content.decode('utf-8')) - assert response_data['discussion_data'][0]['title'] == text - assert response_data['discussion_data'][0]['body'] == text - - class SingleThreadUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin): # lint-amnesty, pylint: disable=missing-class-docstring @classmethod @@ -1742,6 +1045,20 @@ class SingleThreadUnicodeTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, with super().setUpClassAndTestData(): cls.course = CourseFactory.create(discussion_topics={'dummy_discussion_id': {'id': 'dummy_discussion_id'}}) + def setUp(self): + super().setUp() + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) + @classmethod def setUpTestData(cls): super().setUpTestData() @@ -1847,50 +1164,6 @@ class EnrollmentTestCase(ForumsEnableMixin, ModuleStoreTestCase): views.forum_form_discussion(request, course_id=str(self.course.id)) # pylint: disable=no-value-for-parameter, unexpected-keyword-arg -@patch('requests.request', autospec=True) -class EnterpriseConsentTestCase(EnterpriseTestConsentRequired, ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase): - """ - Ensure that the Enterprise Data Consent redirects are in place only when consent is required. - """ - CREATE_USER = False - - @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) - def setUp(self): - # Invoke UrlResetMixin setUp - super().setUp() - - username = "foo" - password = "bar" - - self.discussion_id = 'dummy_discussion_id' - self.course = CourseFactory.create(discussion_topics={'dummy discussion': {'id': self.discussion_id}}) - self.student = UserFactory.create(username=username, password=password) - CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - assert self.client.login(username=username, password=password) - - self.addCleanup(translation.deactivate) - - @patch('openedx.features.enterprise_support.api.enterprise_customer_for_request') - def test_consent_required(self, mock_enterprise_customer_for_request, mock_request): - """ - Test that enterprise data sharing consent is required when enabled for the various discussion views. - """ - # ENT-924: Temporary solution to replace sensitive SSO usernames. - mock_enterprise_customer_for_request.return_value = None - - thread_id = 'dummy' - course_id = str(self.course.id) - mock_request.side_effect = make_mock_request_impl(course=self.course, text='dummy', thread_id=thread_id) - - for url in ( - reverse('forum_form_discussion', - kwargs=dict(course_id=course_id)), - reverse('single_thread', - kwargs=dict(course_id=course_id, discussion_id=self.discussion_id, thread_id=thread_id)), - ): - self.verify_consent_required(self.client, url) # pylint: disable=no-value-for-parameter - - class DividedDiscussionsTestCase(CohortViewsTestCase): # lint-amnesty, pylint: disable=missing-class-docstring def create_divided_discussions(self): @@ -2195,6 +1468,17 @@ class ThreadViewedEventTestCase(EventTestMixin, ForumsEnableMixin, UrlResetMixin def setUp(self): # pylint: disable=arguments-differ super().setUp('lms.djangoapps.discussion.django_comment_client.base.views.tracker') + patcher = mock.patch( + 'openedx.core.djangoapps.discussions.config.waffle.ENABLE_FORUM_V2.is_enabled', + return_value=False + ) + patcher.start() + self.addCleanup(patcher.stop) + patcher = mock.patch( + "openedx.core.djangoapps.django_comment_common.comment_client.thread.forum_api.get_course_id_by_thread" + ) + self.mock_get_course_id_by_thread = patcher.start() + self.addCleanup(patcher.stop) self.course = CourseFactory.create( teams_configuration=TeamsConfig({ 'topics': [{ diff --git a/lms/djangoapps/discussion/tests/test_views_v2.py b/lms/djangoapps/discussion/tests/test_views_v2.py new file mode 100644 index 0000000000..3ac375ed03 --- /dev/null +++ b/lms/djangoapps/discussion/tests/test_views_v2.py @@ -0,0 +1,1264 @@ +# pylint: disable=unused-import +""" +Tests the forum notification views. +""" + +import json +import logging +from datetime import datetime +from unittest import mock +from unittest.mock import ANY, Mock, call, patch + +import ddt +import pytest +from django.conf import settings +from django.http import Http404 +from django.test.client import Client, RequestFactory +from django.test.utils import override_settings +from django.urls import reverse +from django.utils import translation +from edx_django_utils.cache import RequestCache +from edx_toggles.toggles.testutils import override_waffle_flag +from lms.djangoapps.discussion.django_comment_client.tests.mixins import ( + MockForumApiMixin, +) +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ( + TEST_DATA_SPLIT_MODULESTORE, + ModuleStoreTestCase, + SharedModuleStoreTestCase, +) +from xmodule.modulestore.tests.factories import ( + CourseFactory, + BlockFactory, + check_mongo_calls, +) + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.course_modes.tests.factories import CourseModeFactory +from common.djangoapps.student.roles import CourseStaffRole, UserBasedRole +from common.djangoapps.student.tests.factories import ( + AdminFactory, + CourseEnrollmentFactory, + UserFactory, +) +from common.djangoapps.util.testing import EventTestMixin, UrlResetMixin +from lms.djangoapps.courseware.exceptions import CourseAccessRedirect +from lms.djangoapps.discussion import views +from lms.djangoapps.discussion.django_comment_client.constants import ( + TYPE_ENTRY, + TYPE_SUBCATEGORY, +) +from lms.djangoapps.discussion.django_comment_client.permissions import get_team +from lms.djangoapps.discussion.django_comment_client.tests.group_id import ( + CohortedTopicGroupIdTestMixinV2, + GroupIdAssertionMixinV2, + NonCohortedTopicGroupIdTestMixinV2, +) +from lms.djangoapps.discussion.django_comment_client.tests.unicode import ( + UnicodeTestMixin, +) +from lms.djangoapps.discussion.django_comment_client.tests.utils import ( + CohortedTestCase, + ForumsEnableMixin, + config_course_discussions, + topic_name_to_id, +) +from lms.djangoapps.discussion.django_comment_client.utils import strip_none +from lms.djangoapps.discussion.toggles import ENABLE_DISCUSSIONS_MFE +from lms.djangoapps.discussion.views import ( + _get_discussion_default_topic_id, + course_discussions_settings_handler, +) +from lms.djangoapps.teams.tests.factories import ( + CourseTeamFactory, + CourseTeamMembershipFactory, +) +from openedx.core.djangoapps.course_groups.models import CourseUserGroup +from openedx.core.djangoapps.course_groups.tests.helpers import config_course_cohorts +from openedx.core.djangoapps.course_groups.tests.test_views import CohortViewsTestCase +from openedx.core.djangoapps.django_comment_common.comment_client.utils import ( + CommentClientPaginatedResult, +) +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_STUDENT, + CourseDiscussionSettings, + ForumsConfig, +) +from openedx.core.djangoapps.django_comment_common.utils import ( + ThreadContext, + seed_permissions_roles, +) +from openedx.core.djangoapps.util.testing import ContentGroupTestCase +from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES +from openedx.core.lib.teams_config import TeamsConfig +from openedx.features.content_type_gating.models import ContentTypeGatingConfig +from openedx.features.enterprise_support.tests.mixins.enterprise import ( + EnterpriseTestConsentRequired, +) + +log = logging.getLogger(__name__) + +QUERY_COUNT_TABLE_IGNORELIST = WAFFLE_TABLES + + +def make_mock_thread_data( + course, + text, + thread_id, + num_children, + group_id=None, + group_name=None, + commentable_id=None, + is_commentable_divided=None, + anonymous=False, + anonymous_to_peers=False, +): + """ + Creates mock thread data for testing purposes. + """ + data_commentable_id = ( + commentable_id + or course.discussion_topics.get("General", {}).get("id") + or "dummy_commentable_id" + ) + thread_data = { + "id": thread_id, + "type": "thread", + "title": text, + "body": text, + "commentable_id": data_commentable_id, + "resp_total": 42, + "resp_skip": 25, + "resp_limit": 5, + "group_id": group_id, + "anonymous": anonymous, + "anonymous_to_peers": anonymous_to_peers, + "context": ( + ThreadContext.COURSE + if get_team(data_commentable_id) is None + else ThreadContext.STANDALONE + ), + } + if group_id is not None: + thread_data["group_name"] = group_name + if is_commentable_divided is not None: + thread_data["is_commentable_divided"] = is_commentable_divided + if num_children is not None: + thread_data["children"] = [ + { + "id": f"dummy_comment_id_{i}", + "type": "comment", + "body": text, + } + for i in range(num_children) + ] + return thread_data + + +def make_mock_collection_data( + course, + text, + thread_id, + num_children=None, + group_id=None, + commentable_id=None, + thread_list=None, +): + """ + Creates mock collection data for testing purposes. + """ + if thread_list: + return [ + make_mock_thread_data( + course=course, text=text, num_children=num_children, **thread + ) + for thread in thread_list + ] + else: + return [ + make_mock_thread_data( + course=course, + text=text, + thread_id=thread_id, + num_children=num_children, + group_id=group_id, + commentable_id=commentable_id, + ) + ] + + +def make_collection_callback( + course, + text, + thread_id="dummy_thread_id", + group_id=None, + commentable_id=None, + thread_list=None, +): + """ + Creates a callback function for simulating collection data. + """ + + def callback(*args, **kwargs): + # Simulate default user thread response + return { + "collection": make_mock_collection_data( + course, text, thread_id, None, group_id, commentable_id, thread_list + ) + } + + return callback + + +def make_thread_callback( + course, + text, + thread_id="dummy_thread_id", + group_id=None, + commentable_id=None, + num_thread_responses=1, + anonymous=False, + anonymous_to_peers=False, +): + """ + Creates a callback function for simulating thread data. + """ + + def callback(*args, **kwargs): + # Simulate default user thread response + return make_mock_thread_data( + course=course, + text=text, + thread_id=thread_id, + num_children=num_thread_responses, + group_id=group_id, + commentable_id=commentable_id, + anonymous=anonymous, + anonymous_to_peers=anonymous_to_peers, + ) + + return callback + + +def make_user_callback(): + """ + Creates a callback function for simulating user data. + """ + + def callback(*args, **kwargs): + res = { + "default_sort_key": "date", + "upvoted_ids": [], + "downvoted_ids": [], + "subscribed_thread_ids": [], + } + # comments service adds these attributes when course_id param is present + if kwargs.get("course_id"): + res.update({"threads_count": 1, "comments_count": 2}) + return res + + return callback + + +class ForumViewsUtilsMixin(MockForumApiMixin): + """ + Utils for the Forum Views. + """ + + def _configure_mock_responses( + self, + course, + text, + thread_id="dummy_thread_id", + group_id=None, + commentable_id=None, + num_thread_responses=1, + thread_list=None, + anonymous=False, + anonymous_to_peers=False, + ): + """ + Configure mock responses for the Forum Views. + """ + for func_name in [ + "search_threads", + "get_user_active_threads", + "get_user_threads", + ]: + self.set_mock_side_effect( + func_name, + make_collection_callback( + course, + text, + thread_id, + group_id, + commentable_id, + thread_list, + ), + ) + + self.set_mock_side_effect( + "get_thread", + make_thread_callback( + course, + text, + thread_id, + group_id, + commentable_id, + num_thread_responses, + anonymous, + anonymous_to_peers, + ), + ) + + self.set_mock_side_effect("get_user", make_user_callback()) + + +class ForumFormDiscussionContentGroupTestCase( + ForumsEnableMixin, ContentGroupTestCase, ForumViewsUtilsMixin +): + """ + Tests `forum_form_discussion api` works with different content groups. + Discussion blocks are setup in ContentGroupTestCase class i.e + alpha_block => alpha_group_discussion => alpha_cohort => alpha_user/community_ta + beta_block => beta_group_discussion => beta_cohort => beta_user + """ + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + self.thread_list = [ + {"thread_id": "test_general_thread_id"}, + { + "thread_id": "test_global_group_thread_id", + "commentable_id": self.global_block.discussion_id, + }, + { + "thread_id": "test_alpha_group_thread_id", + "group_id": self.alpha_block.group_access[0][0], + "commentable_id": self.alpha_block.discussion_id, + }, + { + "thread_id": "test_beta_group_thread_id", + "group_id": self.beta_block.group_access[0][0], + "commentable_id": self.beta_block.discussion_id, + }, + ] + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def assert_has_access(self, response, expected_discussion_threads): + """ + Verify that a users have access to the threads in their assigned + cohorts and non-cohorted blocks. + """ + discussion_data = json.loads(response.content.decode("utf-8"))[ + "discussion_data" + ] + assert len(discussion_data) == expected_discussion_threads + + def call_view( + self, user + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses( + course=self.course, text="dummy content", thread_list=self.thread_list + ) + self.client.login(username=user.username, password=self.TEST_PASSWORD) + return self.client.get( + reverse("forum_form_discussion", args=[str(self.course.id)]), + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + + def test_community_ta_user(self): + """ + Verify that community_ta user has access to all threads regardless + of cohort. + """ + response = self.call_view(self.community_ta) + self.assert_has_access(response, 4) + + def test_alpha_cohort_user(self): + """ + Verify that alpha_user has access to alpha_cohort and non-cohorted + threads. + """ + response = self.call_view(self.alpha_user) + self.assert_has_access(response, 3) + + def test_beta_cohort_user(self): + """ + Verify that beta_user has access to beta_cohort and non-cohorted + threads. + """ + response = self.call_view(self.beta_user) + self.assert_has_access(response, 3) + + def test_global_staff_user(self): + """ + Verify that global staff user has access to all threads regardless + of cohort. + """ + response = self.call_view(self.staff_user) + self.assert_has_access(response, 4) + + +class ForumFormDiscussionUnicodeTestCase( + ForumsEnableMixin, + SharedModuleStoreTestCase, + UnicodeTestMixin, + ForumViewsUtilsMixin, +): + """ + Discussiin Unicode Tests. + """ + + @classmethod + def setUpClass(cls): # pylint: disable=super-method-not-called + super().setUpClassAndForumMock() + + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.student = UserFactory.create() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + def _test_unicode_data( + self, text + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses(course=self.course, text=text) + request = RequestFactory().get("dummy_url") + request.user = self.student + # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True + request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + + response = views.forum_form_discussion(request, str(self.course.id)) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["discussion_data"][0]["title"] == text + assert response_data["discussion_data"][0]["body"] == text + + +class EnterpriseConsentTestCase( + EnterpriseTestConsentRequired, + ForumsEnableMixin, + UrlResetMixin, + ModuleStoreTestCase, + ForumViewsUtilsMixin, +): + """ + Ensure that the Enterprise Data Consent redirects are in place only when consent is required. + """ + + CREATE_USER = False + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + # Invoke UrlResetMixin setUp + super().setUp() + username = "foo" + password = "bar" + + self.discussion_id = "dummy_discussion_id" + self.course = CourseFactory.create( + discussion_topics={"dummy discussion": {"id": self.discussion_id}} + ) + self.student = UserFactory.create(username=username, password=password) + CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + assert self.client.login(username=username, password=password) + + self.addCleanup(translation.deactivate) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @patch("openedx.features.enterprise_support.api.enterprise_customer_for_request") + def test_consent_required(self, mock_enterprise_customer_for_request): + """ + Test that enterprise data sharing consent is required when enabled for the various discussion views. + """ + # ENT-924: Temporary solution to replace sensitive SSO usernames. + mock_enterprise_customer_for_request.return_value = None + + thread_id = "dummy" + course_id = str(self.course.id) + self._configure_mock_responses( + course=self.course, text="dummy", thread_id=thread_id + ) + + for url in ( + reverse("forum_form_discussion", kwargs=dict(course_id=course_id)), + reverse( + "single_thread", + kwargs=dict( + course_id=course_id, + discussion_id=self.discussion_id, + thread_id=thread_id, + ), + ), + ): + self.verify_consent_required( # pylint: disable=no-value-for-parameter + self.client, url + ) + + +class InlineDiscussionGroupIdTestCase( # lint-amnesty, pylint: disable=missing-class-docstring + CohortedTestCase, + CohortedTopicGroupIdTestMixinV2, + NonCohortedTopicGroupIdTestMixinV2, + ForumViewsUtilsMixin, +): + function_name = "get_user_threads" + + def setUp(self): + super().setUp() + self.cohorted_commentable_id = "cohorted_topic" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def call_view( + self, commentable_id, user, group_id, pass_group_id=True + ): # pylint: disable=arguments-differ + kwargs = {"commentable_id": self.cohorted_commentable_id} + if group_id: + # avoid causing a server error when the LMS chokes attempting + # to find a group name for the group_id, when we're testing with + # an invalid one. + try: + CourseUserGroup.objects.get(id=group_id) + kwargs["group_id"] = group_id + except CourseUserGroup.DoesNotExist: + pass + self._configure_mock_responses(self.course, "dummy content", **kwargs) + + request_data = {} + if pass_group_id: + request_data["group_id"] = group_id + request = RequestFactory().get("dummy_url", data=request_data) + request.user = user + return views.inline_discussion(request, str(self.course.id), commentable_id) + + def test_group_info_in_ajax_response(self): + response = self.call_view( + self.cohorted_commentable_id, self.student, self.student_cohort.id + ) + self._assert_json_response_contains_group_info( + response, lambda d: d["discussion_data"][0] + ) + + +class InlineDiscussionContextTestCase( + ForumsEnableMixin, ModuleStoreTestCase, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + CourseEnrollmentFactory(user=self.user, course_id=self.course.id) + self.discussion_topic_id = "dummy_topic" + self.team = CourseTeamFactory( + name="A team", + course_id=self.course.id, + topic_id="topic_id", + discussion_topic_id=self.discussion_topic_id, + ) + + self.team.add_user(self.user) + self.user_not_in_team = UserFactory.create() + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def test_context_can_be_standalone(self): + self._configure_mock_responses( + course=self.course, + text="dummy text", + commentable_id=self.discussion_topic_id, + ) + + request = RequestFactory().get("dummy_url") + request.user = self.user + + response = views.inline_discussion( + request, + str(self.course.id), + self.discussion_topic_id, + ) + + json_response = json.loads(response.content.decode("utf-8")) + assert ( + json_response["discussion_data"][0]["context"] == ThreadContext.STANDALONE + ) + + def test_private_team_discussion(self): + # First set the team discussion to be private + CourseEnrollmentFactory(user=self.user_not_in_team, course_id=self.course.id) + request = RequestFactory().get("dummy_url") + request.user = self.user_not_in_team + + self._configure_mock_responses( + course=self.course, + text="dummy text", + commentable_id=self.discussion_topic_id, + ) + + with patch( + "lms.djangoapps.teams.api.is_team_discussion_private", autospec=True + ) as mocked: + mocked.return_value = True + response = views.inline_discussion( + request, + str(self.course.id), + self.discussion_topic_id, + ) + assert response.status_code == 403 + assert response.content.decode("utf-8") == views.TEAM_PERMISSION_MESSAGE + + +class UserProfileDiscussionGroupIdTestCase( + CohortedTestCase, CohortedTopicGroupIdTestMixinV2, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + function_name = "get_user_active_threads" + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def call_view_for_profiled_user( + self, requesting_user, profiled_user, group_id, pass_group_id, is_ajax=False + ): + """ + Calls "user_profile" view method on behalf of "requesting_user" to get information about + the user "profiled_user". + """ + kwargs = {} + if group_id: + kwargs["group_id"] = group_id + self._configure_mock_responses(self.course, "dummy content", **kwargs) + + request_data = {} + if pass_group_id: + request_data["group_id"] = group_id + headers = {} + if is_ajax: + headers["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + + self.client.login( + username=requesting_user.username, password=self.TEST_PASSWORD + ) + return self.client.get( + reverse("user_profile", args=[str(self.course.id), profiled_user.id]), + data=request_data, + **headers, + ) + + def call_view( + self, _commentable_id, user, group_id, pass_group_id=True, is_ajax=False + ): # pylint: disable=arguments-differ + return self.call_view_for_profiled_user( + user, user, group_id, pass_group_id=pass_group_id, is_ajax=is_ajax + ) + + def test_group_info_in_html_response(self): + response = self.call_view( + "cohorted_topic", self.student, self.student_cohort.id, is_ajax=False + ) + self._assert_html_response_contains_group_info(response) + + def test_group_info_in_ajax_response(self): + response = self.call_view( + "cohorted_topic", self.student, self.student_cohort.id, is_ajax=True + ) + self._assert_json_response_contains_group_info( + response, lambda d: d["discussion_data"][0] + ) + + def _test_group_id_passed_to_user_profile( + self, + expect_group_id_in_request, + requesting_user, + profiled_user, + group_id, + pass_group_id, + ): + """ + Helper method for testing whether or not group_id was passed to the user_profile request. + """ + + def get_params_from_user_info_call(for_specific_course): + """ + Returns the request parameters for the user info call with either course_id specified or not, + depending on value of 'for_specific_course'. + """ + # There will be 3 calls from user_profile. One has the cs_endpoint "active_threads", and it is already + # tested. The other 2 calls are for user info; one of those calls is for general information about the user, + # and it does not specify a course_id. The other call does specify a course_id, and if the caller did not + # have discussion moderator privileges, it should also contain a group_id. + user_func_calls = self.get_mock_func_calls("get_user") + for r_call in user_func_calls: + has_course_id = "course_id" in r_call[1] + if (for_specific_course and has_course_id) or ( + not for_specific_course and not has_course_id + ): + return r_call[1] + pytest.fail( + f"Did not find appropriate user_profile call for 'for_specific_course'={for_specific_course}" + ) + + self.call_view_for_profiled_user( + requesting_user, + profiled_user, + group_id, + pass_group_id=pass_group_id, + is_ajax=False, + ) + # Should never have a group_id if course_id was not included in the request. + params_without_course_id = get_params_from_user_info_call(False) + assert "group_ids" not in params_without_course_id + + params_with_course_id = get_params_from_user_info_call(True) + if expect_group_id_in_request: + assert "group_ids" in params_with_course_id + assert [group_id] == params_with_course_id["group_ids"] + else: + assert "group_ids" not in params_with_course_id + + def test_group_id_passed_to_user_profile_student(self): + """ + Test that the group id is always included when requesting user profile information for a particular + course if the requester does not have discussion moderation privileges. + """ + + def verify_group_id_always_present(profiled_user, pass_group_id): + """ + Helper method to verify that group_id is always present for student in course + (non-privileged user). + """ + self._test_group_id_passed_to_user_profile( + True, self.student, profiled_user, self.student_cohort.id, pass_group_id + ) + + # In all these test cases, the requesting_user is the student (non-privileged user). + # The profile returned on behalf of the student is for the profiled_user. + verify_group_id_always_present(profiled_user=self.student, pass_group_id=True) + verify_group_id_always_present(profiled_user=self.student, pass_group_id=False) + verify_group_id_always_present(profiled_user=self.moderator, pass_group_id=True) + verify_group_id_always_present( + profiled_user=self.moderator, pass_group_id=False + ) + + def test_group_id_user_profile_moderator(self): + """ + Test that the group id is only included when a privileged user requests user profile information for a + particular course and user if the group_id is explicitly passed in. + """ + + def verify_group_id_present( + profiled_user, pass_group_id, requested_cohort=self.moderator_cohort + ): + """ + Helper method to verify that group_id is present. + """ + self._test_group_id_passed_to_user_profile( + True, self.moderator, profiled_user, requested_cohort.id, pass_group_id + ) + + def verify_group_id_not_present( + profiled_user, pass_group_id, requested_cohort=self.moderator_cohort + ): + """ + Helper method to verify that group_id is not present. + """ + self._test_group_id_passed_to_user_profile( + False, self.moderator, profiled_user, requested_cohort.id, pass_group_id + ) + + # In all these test cases, the requesting_user is the moderator (privileged user). + + # If the group_id is explicitly passed, it will be present in the request. + verify_group_id_present(profiled_user=self.student, pass_group_id=True) + verify_group_id_present(profiled_user=self.moderator, pass_group_id=True) + verify_group_id_present( + profiled_user=self.student, + pass_group_id=True, + requested_cohort=self.student_cohort, + ) + + # If the group_id is not explicitly passed, it will not be present because the requesting_user + # has discussion moderator privileges. + verify_group_id_not_present(profiled_user=self.student, pass_group_id=False) + verify_group_id_not_present(profiled_user=self.moderator, pass_group_id=False) + + +@ddt.ddt +class ForumDiscussionXSSTestCase( + ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + + username = "foo" + password = "bar" + + self.course = CourseFactory.create() + self.student = UserFactory.create(username=username, password=password) + CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + assert self.client.login(username=username, password=password) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @ddt.data( + '">', + "", + "", + ) + @patch("common.djangoapps.student.models.user.cc.User.from_django_user") + def test_forum_discussion_xss_prevent(self, malicious_code, mock_user): + """ + Test that XSS attack is prevented + """ + self.set_mock_return_value("get_user", {}) + self.set_mock_return_value("get_user_threads", {}) + self.set_mock_return_value("get_user_active_threads", {}) + mock_user.return_value.to_dict.return_value = {} + reverse_url = "{}{}".format( + reverse("forum_form_discussion", kwargs={"course_id": str(self.course.id)}), + "/forum_form_discussion", + ) + # Test that malicious code does not appear in html + url = "{}?{}={}".format(reverse_url, "sort_key", malicious_code) + resp = self.client.get(url) + self.assertNotContains(resp, malicious_code) + + @ddt.data( + '">', + "", + "", + ) + @patch("common.djangoapps.student.models.user.cc.User.from_django_user") + @patch("common.djangoapps.student.models.user.cc.User.active_threads") + def test_forum_user_profile_xss_prevent( + self, malicious_code, mock_threads, mock_from_django_user + ): + """ + Test that XSS attack is prevented + """ + mock_threads.return_value = [], 1, 1 + mock_from_django_user.return_value.to_dict.return_value = { + "upvoted_ids": [], + "downvoted_ids": [], + "subscribed_thread_ids": [], + } + self._configure_mock_responses(course=self.course, text="dummy") + + url = reverse( + "user_profile", + kwargs={"course_id": str(self.course.id), "user_id": str(self.student.id)}, + ) + # Test that malicious code does not appear in html + url_string = "{}?{}={}".format(url, "page", malicious_code) + resp = self.client.get(url_string) + self.assertNotContains(resp, malicious_code) + + +class InlineDiscussionTestCase( + ForumsEnableMixin, ModuleStoreTestCase, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + def setUp(self): + super().setUp() + + self.course = CourseFactory.create( + org="TestX", + number="101", + display_name="Test Course", + teams_configuration=TeamsConfig( + { + "topics": [ + { + "id": "topic_id", + "name": "A topic", + "description": "A topic", + } + ] + } + ), + ) + self.student = UserFactory.create() + CourseEnrollmentFactory(user=self.student, course_id=self.course.id) + self.discussion1 = BlockFactory.create( + parent_location=self.course.location, + category="discussion", + discussion_id="discussion1", + display_name="Discussion1", + discussion_category="Chapter", + discussion_target="Discussion1", + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def send_request(self, params=None): + """ + Creates and returns a request with params set, and configures + mock_request to return appropriate values. + """ + request = RequestFactory().get("dummy_url", params if params else {}) + request.user = self.student + self._configure_mock_responses( + course=self.course, + text="dummy content", + commentable_id=self.discussion1.discussion_id, + ) + return views.inline_discussion( + request, str(self.course.id), self.discussion1.discussion_id + ) + + def test_context(self): + team = CourseTeamFactory( + name="Team Name", + topic_id="topic_id", + course_id=self.course.id, + discussion_topic_id=self.discussion1.discussion_id, + ) + + team.add_user(self.student) + + self.send_request() + last_call = self.get_mock_func_calls("get_user_threads")[-1][1] + assert last_call["context"] == ThreadContext.STANDALONE + + +class ForumDiscussionSearchUnicodeTestCase( + ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + @classmethod + def setUpClass(cls): # pylint: disable=super-method-not-called + super().setUpClassAndForumMock() + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.student = UserFactory.create() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + def _test_unicode_data( + self, text + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses(course=self.course, text=text) + data = { + "ajax": 1, + "text": text, + } + request = RequestFactory().get("dummy_url", data) + request.user = self.student + # so (request.headers.get('x-requested-with') == 'XMLHttpRequest') == True + request.META["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + + response = views.forum_form_discussion(request, str(self.course.id)) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["discussion_data"][0]["title"] == text + assert response_data["discussion_data"][0]["body"] == text + + +class InlineDiscussionUnicodeTestCase( + ForumsEnableMixin, SharedModuleStoreTestCase, UnicodeTestMixin, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + @classmethod + def setUpClass(cls): # pylint: disable=super-method-not-called + super().setUpClassAndForumMock() + + with super().setUpClassAndTestData(): + cls.course = CourseFactory.create() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + @classmethod + def setUpTestData(cls): + super().setUpTestData() + + cls.student = UserFactory.create() + CourseEnrollmentFactory(user=cls.student, course_id=cls.course.id) + + def _test_unicode_data( + self, text + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses(course=self.course, text=text) + request = RequestFactory().get("dummy_url") + request.user = self.student + + response = views.inline_discussion( + request, str(self.course.id), self.course.discussion_topics["General"]["id"] + ) + assert response.status_code == 200 + response_data = json.loads(response.content.decode("utf-8")) + assert response_data["discussion_data"][0]["title"] == text + assert response_data["discussion_data"][0]["body"] == text + + +class ForumFormDiscussionGroupIdTestCase( + CohortedTestCase, CohortedTopicGroupIdTestMixinV2, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + function_name = "get_user_threads" + + def call_view( + self, commentable_id, user, group_id, pass_group_id=True, is_ajax=False + ): # pylint: disable=arguments-differ + kwargs = {} + if group_id: + kwargs["group_id"] = group_id + self._configure_mock_responses(self.course, "dummy content", **kwargs) + + request_data = {} + if pass_group_id: + request_data["group_id"] = group_id + headers = {} + if is_ajax: + headers["HTTP_X_REQUESTED_WITH"] = "XMLHttpRequest" + + self.client.login(username=user.username, password=self.TEST_PASSWORD) + return self.client.get( + reverse("forum_form_discussion", args=[str(self.course.id)]), + data=request_data, + **headers, + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def test_group_info_in_html_response(self): + response = self.call_view( + "cohorted_topic", self.student, self.student_cohort.id + ) + self._assert_html_response_contains_group_info(response) + + def test_group_info_in_ajax_response(self): + response = self.call_view( + "cohorted_topic", self.student, self.student_cohort.id, is_ajax=True + ) + self._assert_json_response_contains_group_info( + response, lambda d: d["discussion_data"][0] + ) + + +class UserProfileTestCase( + ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase, ForumViewsUtilsMixin +): # lint-amnesty, pylint: disable=missing-class-docstring + + TEST_THREAD_TEXT = "userprofile-test-text" + TEST_THREAD_ID = "userprofile-test-thread-id" + + @patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) + def setUp(self): + super().setUp() + + self.course = CourseFactory.create() + self.student = UserFactory.create() + self.profiled_user = UserFactory.create() + CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + CourseEnrollmentFactory.create( + user=self.profiled_user, course_id=self.course.id + ) + + @classmethod + def setUpClass(cls): + super().setUpClass() + super().setUpClassAndForumMock() + + @classmethod + def tearDownClass(cls): + super().tearDownClass() + super().disposeForumMocks() + + def get_response( + self, params, **headers + ): # lint-amnesty, pylint: disable=missing-function-docstring + self._configure_mock_responses( + course=self.course, + text=self.TEST_THREAD_TEXT, + thread_id=self.TEST_THREAD_ID, + ) + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) + + response = self.client.get( + reverse( + "user_profile", + kwargs={ + "course_id": str(self.course.id), + "user_id": self.profiled_user.id, + }, + ), + data=params, + **headers, + ) + params = { + "course_id": str(self.course.id), + "page": params.get("page", 1), + "per_page": views.THREADS_PER_PAGE, + } + self.check_mock_called_with("get_user_active_threads", -1, **params) + return response + + def check_html( + self, **params + ): # lint-amnesty, pylint: disable=missing-function-docstring + response = self.get_response(params) + assert response.status_code == 200 + assert response["Content-Type"] == "text/html; charset=utf-8" + html = response.content.decode("utf-8") + self.assertRegex(html, r'data-page="1"') + self.assertRegex(html, r'data-num-pages="1"') + self.assertRegex( + html, r'1 discussion started' + ) + self.assertRegex(html, r'2 comments') + self.assertRegex(html, f"'id': '{self.TEST_THREAD_ID}'") + self.assertRegex(html, f"'title': '{self.TEST_THREAD_TEXT}'") + self.assertRegex(html, f"'body': '{self.TEST_THREAD_TEXT}'") + self.assertRegex(html, f"'username': '{self.student.username}'") + + def check_ajax( + self, **params + ): # lint-amnesty, pylint: disable=missing-function-docstring + response = self.get_response(params, HTTP_X_REQUESTED_WITH="XMLHttpRequest") + assert response.status_code == 200 + assert response["Content-Type"] == "application/json; charset=utf-8" + response_data = json.loads(response.content.decode("utf-8")) + assert sorted(response_data.keys()) == [ + "annotated_content_info", + "discussion_data", + "num_pages", + "page", + ] + assert len(response_data["discussion_data"]) == 1 + assert response_data["page"] == 1 + assert response_data["num_pages"] == 1 + assert response_data["discussion_data"][0]["id"] == self.TEST_THREAD_ID + assert response_data["discussion_data"][0]["title"] == self.TEST_THREAD_TEXT + assert response_data["discussion_data"][0]["body"] == self.TEST_THREAD_TEXT + + def test_html(self): + self.check_html() + + def test_ajax(self): + self.check_ajax() + + def test_404_non_enrolled_user(self): + """ + Test that when student try to visit un-enrolled students' discussion profile, + the system raises Http404. + """ + unenrolled_user = UserFactory.create() + request = RequestFactory().get("dummy_url") + request.user = self.student + with pytest.raises(Http404): + views.user_profile(request, str(self.course.id), unenrolled_user.id) + + def test_404_profiled_user(self): + request = RequestFactory().get("dummy_url") + request.user = self.student + with pytest.raises(Http404): + views.user_profile(request, str(self.course.id), -999) + + def test_404_course(self): + request = RequestFactory().get("dummy_url") + request.user = self.student + with pytest.raises(Http404): + views.user_profile(request, "non/existent/course", self.profiled_user.id) + + def test_post(self): + self._configure_mock_responses( + course=self.course, + text=self.TEST_THREAD_TEXT, + thread_id=self.TEST_THREAD_ID, + ) + request = RequestFactory().post("dummy_url") + request.user = self.student + response = views.user_profile( + request, str(self.course.id), self.profiled_user.id + ) + assert response.status_code == 405 diff --git a/lms/djangoapps/discussion/tests/utils.py b/lms/djangoapps/discussion/tests/utils.py new file mode 100644 index 0000000000..822034fb39 --- /dev/null +++ b/lms/djangoapps/discussion/tests/utils.py @@ -0,0 +1,70 @@ +""" +Utils for the discussion app. +""" + + +def make_minimal_cs_thread(overrides=None): + """ + Create a dictionary containing all needed thread fields as returned by the + comments service with dummy data and optional overrides + """ + ret = { + "type": "thread", + "id": "dummy", + "course_id": "course-v1:dummy+dummy+dummy", + "commentable_id": "dummy", + "group_id": None, + "user_id": "0", + "username": "dummy", + "anonymous": False, + "anonymous_to_peers": False, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "last_activity_at": "1970-01-01T00:00:00Z", + "thread_type": "discussion", + "title": "dummy", + "body": "dummy", + "pinned": False, + "closed": False, + "abuse_flaggers": [], + "abuse_flagged_count": None, + "votes": {"up_count": 0}, + "comments_count": 0, + "unread_comments_count": 0, + "children": [], + "read": False, + "endorsed": False, + "resp_total": 0, + "closed_by": None, + "close_reason_code": None, + } + ret.update(overrides or {}) + return ret + + +def make_minimal_cs_comment(overrides=None): + """ + Create a dictionary containing all needed comment fields as returned by the + comments service with dummy data and optional overrides + """ + ret = { + "type": "comment", + "id": "dummy", + "commentable_id": "dummy", + "thread_id": "dummy", + "parent_id": None, + "user_id": "0", + "username": "dummy", + "anonymous": False, + "anonymous_to_peers": False, + "created_at": "1970-01-01T00:00:00Z", + "updated_at": "1970-01-01T00:00:00Z", + "body": "dummy", + "abuse_flaggers": [], + "votes": {"up_count": 0}, + "endorsed": False, + "child_count": 0, + "children": [], + } + ret.update(overrides or {}) + return ret diff --git a/lms/djangoapps/discussion/toggles.py b/lms/djangoapps/discussion/toggles.py index a1c292a473..6965f462f9 100644 --- a/lms/djangoapps/discussion/toggles.py +++ b/lms/djangoapps/discussion/toggles.py @@ -1,6 +1,7 @@ """ Discussions feature toggles """ + from openedx.core.djangoapps.discussions.config.waffle import WAFFLE_FLAG_NAMESPACE from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag @@ -11,4 +12,18 @@ from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag # .. toggle_use_cases: temporary, open_edx # .. toggle_creation_date: 2021-11-05 # .. toggle_target_removal_date: 2022-12-05 -ENABLE_DISCUSSIONS_MFE = CourseWaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.enable_discussions_mfe', __name__) +ENABLE_DISCUSSIONS_MFE = CourseWaffleFlag( + f"{WAFFLE_FLAG_NAMESPACE}.enable_discussions_mfe", __name__ +) + + +# .. toggle_name: discussions.only_verified_users_can_post +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to allow only verified users to post in discussions +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2025-22-07 +# .. toggle_target_removal_date: 2026-04-01 +ONLY_VERIFIED_USERS_CAN_POST = CourseWaffleFlag( + f"{WAFFLE_FLAG_NAMESPACE}.only_verified_users_can_post", __name__ +) diff --git a/lms/djangoapps/discussion/views.py b/lms/djangoapps/discussion/views.py index bfa511a575..c01fb24b07 100644 --- a/lms/djangoapps/discussion/views.py +++ b/lms/djangoapps/discussion/views.py @@ -4,6 +4,7 @@ Views handling read (GET) requests for the Discussion tab and inline discussions import logging from functools import wraps +from urllib.parse import urljoin from django.conf import settings from django.contrib.auth import get_user_model @@ -146,7 +147,7 @@ def get_threads(request, course, user_info, discussion_id=None, per_page=THREADS # If the user clicked a sort key, update their default sort key cc_user = cc.User.from_django_user(request.user) cc_user.default_sort_key = request.GET.get('sort_key') - cc_user.save() + cc_user.save(params={"course_id": str(course.id)}) #there are 2 dimensions to consider when executing a search with respect to group id #is user a moderator @@ -218,7 +219,7 @@ def inline_discussion(request, course_key, discussion_id): with function_trace('get_course_and_user_info'): course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=True) cc_user = cc.User.from_django_user(request.user) - user_info = cc_user.to_dict() + user_info = cc_user.to_dict(course_key=str(course_key)) try: with function_trace('get_threads'): @@ -356,7 +357,7 @@ def single_thread(request, course_key, discussion_id, thread_id): if request.headers.get('x-requested-with') == 'XMLHttpRequest': cc_user = cc.User.from_django_user(request.user) - user_info = cc_user.to_dict() + user_info = cc_user.to_dict(course_key=str(course_key)) is_staff = has_permission(request.user, 'openclose_thread', course.id) try: @@ -471,7 +472,7 @@ def _create_base_discussion_view_context(request, course_key): """ user = request.user cc_user = cc.User.from_django_user(user) - user_info = cc_user.to_dict() + user_info = cc_user.to_dict(course_key=str(course_key)) course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True) course_settings = make_course_settings(course, user) return { @@ -629,7 +630,7 @@ def create_user_profile_context(request, course_key, user_id): 'page': query_params['page'], 'num_pages': query_params['num_pages'], 'sort_preference': user.default_sort_key, - 'learner_profile_page_url': reverse('learner_profile', kwargs={'username': django_user.username}), + 'learner_profile_page_url': urljoin(settings.PROFILE_MICROFRONTEND_URL, f'/u/{django_user.username}'), }) return context diff --git a/lms/djangoapps/edxnotes/helpers.py b/lms/djangoapps/edxnotes/helpers.py index 17705f835e..f81947ce1e 100644 --- a/lms/djangoapps/edxnotes/helpers.py +++ b/lms/djangoapps/edxnotes/helpers.py @@ -29,6 +29,7 @@ from lms.djangoapps.edxnotes.plugins import EdxNotesTab from lms.lib.utils import get_parent_unit from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user from openedx.core.djangolib.markup import Text +from openedx.features.course_experience.url_helpers import get_courseware_url from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order @@ -256,16 +257,8 @@ def get_block_context(course, block): course = block.get_parent() block_dict['index'] = get_index(block_dict['location'], course.children) elif block.category == 'vertical': - section = block.get_parent() - chapter = section.get_parent() - # Position starts from 1, that's why we add 1. - position = get_index(str(block.location), section.children) + 1 - block_dict['url'] = reverse('courseware_position', kwargs={ - 'course_id': str(course.id), - 'chapter': chapter.url_name, - 'section': section.url_name, - 'position': position, - }) + # Use the MFE-aware URL generator instead of always using the legacy URL format + block_dict['url'] = get_courseware_url(block.location) if block.category in ('chapter', 'sequential'): block_dict['children'] = [str(child) for child in block.children] diff --git a/lms/djangoapps/email_marketing/README.rst b/lms/djangoapps/email_marketing/README.rst deleted file mode 100644 index 5cd66a3816..0000000000 --- a/lms/djangoapps/email_marketing/README.rst +++ /dev/null @@ -1,9 +0,0 @@ -Email Marketing -=============== - -If you are reading this and the 'Maple' Open edX release is out, you can safely delete -this whole djangoapp. It only exists to hold old model migrations and any post-Maple -installation will no longer have any model in the database for this app. - -But for some minor historical context, this djangoapp used to hold some integration -with sailthru that we no longer needed. diff --git a/lms/djangoapps/email_marketing/apps.py b/lms/djangoapps/email_marketing/apps.py deleted file mode 100644 index bf78636d49..0000000000 --- a/lms/djangoapps/email_marketing/apps.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -Configuration for the email_marketing Django application. -""" - -from django.apps import AppConfig - - -class EmailMarketingConfig(AppConfig): - """ - Configuration class for the email_marketing Django application. - """ - name = 'lms.djangoapps.email_marketing' - verbose_name = "Email Marketing" diff --git a/lms/djangoapps/email_marketing/migrations/0001_initial.py b/lms/djangoapps/email_marketing/migrations/0001_initial.py deleted file mode 100644 index 0264296e47..0000000000 --- a/lms/djangoapps/email_marketing/migrations/0001_initial.py +++ /dev/null @@ -1,28 +0,0 @@ -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='EmailMarketingConfiguration', - fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('change_date', models.DateTimeField(auto_now_add=True, verbose_name='Change date')), - ('enabled', models.BooleanField(default=False, verbose_name='Enabled')), - ('sailthru_key', models.CharField(help_text='API key for accessing Sailthru. ', max_length=32)), - ('sailthru_secret', models.CharField(help_text='API secret for accessing Sailthru. ', max_length=32)), - ('sailthru_new_user_list', models.CharField(help_text='Sailthru list name to add new users to. ', max_length=48)), - ('sailthru_retry_interval', models.IntegerField(default=3600, help_text='Sailthru connection retry interval (secs).')), - ('sailthru_max_retries', models.IntegerField(default=24, help_text='Sailthru maximum retries.')), - ('sailthru_activation_template', models.CharField(help_text='Sailthru template to use on activation send. ', max_length=20)), - ('changed_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, editable=False, to=settings.AUTH_USER_MODEL, null=True, verbose_name='Changed by')), - ], - ), - ] diff --git a/lms/djangoapps/email_marketing/migrations/0002_auto_20160623_1656.py b/lms/djangoapps/email_marketing/migrations/0002_auto_20160623_1656.py deleted file mode 100644 index 049b6b3a29..0000000000 --- a/lms/djangoapps/email_marketing/migrations/0002_auto_20160623_1656.py +++ /dev/null @@ -1,56 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('email_marketing', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='emailmarketingconfiguration', - name='sailthru_abandoned_cart_delay', - field=models.IntegerField(default=60, help_text='Sailthru minutes to wait before sending abandoned cart message.'), - ), - migrations.AddField( - model_name='emailmarketingconfiguration', - name='sailthru_abandoned_cart_template', - field=models.CharField(help_text='Sailthru template to use on abandoned cart reminder. ', max_length=20, blank=True), - ), - migrations.AddField( - model_name='emailmarketingconfiguration', - name='sailthru_content_cache_age', - field=models.IntegerField(default=3600, help_text='Number of seconds to cache course content retrieved from Sailthru.'), - ), - migrations.AddField( - model_name='emailmarketingconfiguration', - name='sailthru_enroll_cost', - field=models.IntegerField(default=100, help_text='Cost in cents to report to Sailthru for enrolls.'), - ), - migrations.AddField( - model_name='emailmarketingconfiguration', - name='sailthru_enroll_template', - field=models.CharField(help_text='Sailthru send template to use on enrolling for audit. ', max_length=20, blank=True), - ), - migrations.AddField( - model_name='emailmarketingconfiguration', - name='sailthru_get_tags_from_sailthru', - field=models.BooleanField(default=True, help_text='Use the Sailthru content API to fetch course tags.'), - ), - migrations.AddField( - model_name='emailmarketingconfiguration', - name='sailthru_purchase_template', - field=models.CharField(help_text='Sailthru send template to use on purchasing a course seat. ', max_length=20, blank=True), - ), - migrations.AddField( - model_name='emailmarketingconfiguration', - name='sailthru_upgrade_template', - field=models.CharField(help_text='Sailthru send template to use on upgrading a course. ', max_length=20, blank=True), - ), - migrations.AlterField( - model_name='emailmarketingconfiguration', - name='sailthru_activation_template', - field=models.CharField(help_text='Sailthru template to use on activation send. ', max_length=20, blank=True), - ), - ] diff --git a/lms/djangoapps/email_marketing/migrations/0003_auto_20160715_1145.py b/lms/djangoapps/email_marketing/migrations/0003_auto_20160715_1145.py deleted file mode 100644 index f0840a4d4c..0000000000 --- a/lms/djangoapps/email_marketing/migrations/0003_auto_20160715_1145.py +++ /dev/null @@ -1,36 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('email_marketing', '0002_auto_20160623_1656'), - ] - - operations = [ - migrations.AddField( - model_name='emailmarketingconfiguration', - name='sailthru_lms_url_override', - field=models.CharField(help_text='Optional lms url scheme + host used to construct urls for content library, e.g. https://courses.edx.org.', max_length=80, blank=True), - ), - migrations.AlterField( - model_name='emailmarketingconfiguration', - name='sailthru_abandoned_cart_delay', - field=models.IntegerField(default=60, help_text='Sailthru minutes to wait before sending abandoned cart message. Deprecated.'), - ), - migrations.AlterField( - model_name='emailmarketingconfiguration', - name='sailthru_abandoned_cart_template', - field=models.CharField(help_text='Sailthru template to use on abandoned cart reminder. Deprecated.', max_length=20, blank=True), - ), - migrations.AlterField( - model_name='emailmarketingconfiguration', - name='sailthru_purchase_template', - field=models.CharField(help_text='Sailthru send template to use on purchasing a course seat. Deprecated ', max_length=20, blank=True), - ), - migrations.AlterField( - model_name='emailmarketingconfiguration', - name='sailthru_upgrade_template', - field=models.CharField(help_text='Sailthru send template to use on upgrading a course. Deprecated ', max_length=20, blank=True), - ), - ] diff --git a/lms/djangoapps/email_marketing/migrations/0004_emailmarketingconfiguration_welcome_email_send_delay.py b/lms/djangoapps/email_marketing/migrations/0004_emailmarketingconfiguration_welcome_email_send_delay.py deleted file mode 100644 index d6cc8334fc..0000000000 --- a/lms/djangoapps/email_marketing/migrations/0004_emailmarketingconfiguration_welcome_email_send_delay.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('email_marketing', '0003_auto_20160715_1145'), - ] - - operations = [ - migrations.AddField( - model_name='emailmarketingconfiguration', - name='welcome_email_send_delay', - field=models.IntegerField(default=600, help_text='Number of seconds to delay the sending of User Welcome email after user has been activated'), - ), - ] diff --git a/lms/djangoapps/email_marketing/migrations/0005_emailmarketingconfiguration_user_registration_cookie_timeout_delay.py b/lms/djangoapps/email_marketing/migrations/0005_emailmarketingconfiguration_user_registration_cookie_timeout_delay.py deleted file mode 100644 index bd71d3a3e5..0000000000 --- a/lms/djangoapps/email_marketing/migrations/0005_emailmarketingconfiguration_user_registration_cookie_timeout_delay.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('email_marketing', '0004_emailmarketingconfiguration_welcome_email_send_delay'), - ] - - operations = [ - migrations.AddField( - model_name='emailmarketingconfiguration', - name='user_registration_cookie_timeout_delay', - field=models.FloatField(default=1.5, help_text='The number of seconds to delay/timeout wait to get cookie values from sailthru.'), - ), - ] diff --git a/lms/djangoapps/email_marketing/migrations/0006_auto_20170711_0615.py b/lms/djangoapps/email_marketing/migrations/0006_auto_20170711_0615.py deleted file mode 100644 index 56c4a06d43..0000000000 --- a/lms/djangoapps/email_marketing/migrations/0006_auto_20170711_0615.py +++ /dev/null @@ -1,16 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('email_marketing', '0005_emailmarketingconfiguration_user_registration_cookie_timeout_delay'), - ] - - operations = [ - migrations.AlterField( - model_name='emailmarketingconfiguration', - name='user_registration_cookie_timeout_delay', - field=models.FloatField(default=3.0, help_text='The number of seconds to delay/timeout wait to get cookie values from sailthru.'), - ), - ] diff --git a/lms/djangoapps/email_marketing/migrations/0007_auto_20170809_0653.py b/lms/djangoapps/email_marketing/migrations/0007_auto_20170809_0653.py deleted file mode 100644 index f0e08c0576..0000000000 --- a/lms/djangoapps/email_marketing/migrations/0007_auto_20170809_0653.py +++ /dev/null @@ -1,26 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('email_marketing', '0006_auto_20170711_0615'), - ] - - operations = [ - migrations.AddField( - model_name='emailmarketingconfiguration', - name='sailthru_welcome_template', - field=models.CharField(help_text='Sailthru template to use on welcome send.', max_length=20, blank=True), - ), - migrations.AlterField( - model_name='emailmarketingconfiguration', - name='sailthru_activation_template', - field=models.CharField(help_text='DEPRECATED: use sailthru_welcome_template instead.', max_length=20, blank=True), - ), - migrations.AlterField( - model_name='emailmarketingconfiguration', - name='welcome_email_send_delay', - field=models.IntegerField(default=600, help_text='Number of seconds to delay the sending of User Welcome email after user has been created'), - ), - ] diff --git a/lms/djangoapps/email_marketing/migrations/0008_auto_20170809_0539.py b/lms/djangoapps/email_marketing/migrations/0008_auto_20170809_0539.py deleted file mode 100644 index cf30a0db28..0000000000 --- a/lms/djangoapps/email_marketing/migrations/0008_auto_20170809_0539.py +++ /dev/null @@ -1,24 +0,0 @@ -from django.db import migrations, models - - -def migrate_data_forwards(apps, schema_editor): - EmailMarketingConfiguration = apps.get_model('email_marketing', 'EmailMarketingConfiguration') - EmailMarketingConfiguration.objects.all().update( - sailthru_welcome_template=models.F('sailthru_activation_template') - ) - - -def migrate_data_backwards(apps, schema_editor): - # Just copying old field's value to new one in forward migration, so nothing needed here. - pass - - -class Migration(migrations.Migration): - - dependencies = [ - ('email_marketing', '0007_auto_20170809_0653'), - ] - - operations = [ - migrations.RunPython(migrate_data_forwards, migrate_data_backwards) - ] diff --git a/lms/djangoapps/email_marketing/migrations/0009_remove_emailmarketingconfiguration_sailthru_activation_template.py b/lms/djangoapps/email_marketing/migrations/0009_remove_emailmarketingconfiguration_sailthru_activation_template.py deleted file mode 100644 index 9408cf4c17..0000000000 --- a/lms/djangoapps/email_marketing/migrations/0009_remove_emailmarketingconfiguration_sailthru_activation_template.py +++ /dev/null @@ -1,15 +0,0 @@ -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('email_marketing', '0008_auto_20170809_0539'), - ] - - operations = [ - migrations.RemoveField( - model_name='emailmarketingconfiguration', - name='sailthru_activation_template', - ), - ] diff --git a/lms/djangoapps/email_marketing/migrations/0010_auto_20180425_0800.py b/lms/djangoapps/email_marketing/migrations/0010_auto_20180425_0800.py deleted file mode 100644 index 64dca9977e..0000000000 --- a/lms/djangoapps/email_marketing/migrations/0010_auto_20180425_0800.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 1.11.12 on 2018-04-25 12:00 - - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('email_marketing', '0009_remove_emailmarketingconfiguration_sailthru_activation_template'), - ] - - operations = [ - migrations.AddField( - model_name='emailmarketingconfiguration', - name='sailthru_verification_failed_template', - field=models.CharField(blank=True, help_text='Sailthru send template to use on failed ID verification.', max_length=20), - ), - migrations.AddField( - model_name='emailmarketingconfiguration', - name='sailthru_verification_passed_template', - field=models.CharField(blank=True, help_text='Sailthru send template to use on passed ID verification.', max_length=20), - ), - ] diff --git a/lms/djangoapps/email_marketing/migrations/0011_delete_emailmarketingconfiguration.py b/lms/djangoapps/email_marketing/migrations/0011_delete_emailmarketingconfiguration.py deleted file mode 100644 index f892feff09..0000000000 --- a/lms/djangoapps/email_marketing/migrations/0011_delete_emailmarketingconfiguration.py +++ /dev/null @@ -1,16 +0,0 @@ -# Generated by Django 2.2.20 on 2021-05-03 20:17 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('email_marketing', '0010_auto_20180425_0800'), - ] - - operations = [ - migrations.DeleteModel( - name='EmailMarketingConfiguration', - ), - ] diff --git a/lms/djangoapps/experiments/flags.py b/lms/djangoapps/experiments/flags.py index b83a7751fa..3b45bcee5b 100644 --- a/lms/djangoapps/experiments/flags.py +++ b/lms/djangoapps/experiments/flags.py @@ -245,7 +245,7 @@ class ExperimentWaffleFlag(CourseWaffleFlag): if ( track and hasattr(request, 'session') and session_key not in request.session and - not masquerading_as_specific_student and not anonymous + not masquerading_as_specific_student and not anonymous # pylint: disable=used-before-assignment ): segment.track( user_id=user.id, diff --git a/lms/djangoapps/experiments/migrations/0006_rename_experimentdata_user_experiment_id_user_experiment_id_idx.py b/lms/djangoapps/experiments/migrations/0006_rename_experimentdata_user_experiment_id_user_experiment_id_idx.py new file mode 100644 index 0000000000..7b8e9c0720 --- /dev/null +++ b/lms/djangoapps/experiments/migrations/0006_rename_experimentdata_user_experiment_id_user_experiment_id_idx.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.20 on 2025-05-13 10:12 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('experiments', '0005_alter_historicalexperimentkeyvalue_options'), + ] + + operations = [ + migrations.RenameIndex( + model_name='experimentdata', + new_name='user_experiment_id_idx', + old_fields=('user', 'experiment_id'), + ), + ] diff --git a/lms/djangoapps/experiments/models.py b/lms/djangoapps/experiments/models.py index 049cd91082..13a048b021 100644 --- a/lms/djangoapps/experiments/models.py +++ b/lms/djangoapps/experiments/models.py @@ -23,9 +23,12 @@ class ExperimentData(TimeStampedModel): value = models.TextField() class Meta: - index_together = ( - ('user', 'experiment_id'), - ) + indexes = [ + models.Index( + fields=['user', 'experiment_id'], + name="user_experiment_id_idx", + ), + ] verbose_name = 'Experiment Data' verbose_name_plural = 'Experiment Data' unique_together = ( diff --git a/lms/djangoapps/grades/docs/background.rst b/lms/djangoapps/grades/docs/background.rst index ef10145538..9b3b31eda3 100644 --- a/lms/djangoapps/grades/docs/background.rst +++ b/lms/djangoapps/grades/docs/background.rst @@ -15,7 +15,7 @@ Terminology +-------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | Term | Definition | +-------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| Raw Score | Unweighted (earned, possible) points tuple. For a CapaBlock (our most common problem type), each response entry is a point. So a single CapaBlock problem with two multiple choice responses has a possible raw score of 2. | +| Raw Score | Unweighted (earned, possible) points tuple. For a CapaBlock (our most common problem type), each response entry is a point. So a single CapaBlock problem with two multiple choice responses has a possible raw score of 2. | +-------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | Weighted Score | The result of applying the weight settings-scoped XBlock attribute to the raw score. The weight is an indication of how much the total problem should be worth, so the weighted score tuple looks like ((earned / possible) * weight, weight). So if someone has a raw score of 1/2 and the problem weight is 10, then 5/10 is what will show up on the progress page as the weighted score. Problem weights are attributes that are placed on the XBlock/XModuleDescriptor and can be manipulated via Studio. | +-------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ @@ -25,20 +25,20 @@ Terminology +-------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | Assignment-Type Grade | Aggregated grade for an assignment category, like Homework or Final. Grading can be configured to drop the lowest n assignments when calculating an assignment-type grade - otherwise all assignments count equally (i.e. there is no weighting of assignments within an assignment category). The course grading policy specifies the minimum number of assignments expected for each category in advance. | +-------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| Overall Percentage | Float value between 0 and 1 that marks the student's percentage for the course. This is calculated by weighing each assignment-type according to the rules specified in CourseDescriptor.grader. The grader is an `extensible interface `_, but the only rules currently used in practice are simple weight by assignment-type (e.g. 30% Final, 40% HW, etc.). The grader will return the actual percentage as a value between 0 and 1. The overall grade calculation process will then take this number and do a small bit of rounding up: round(actual_percent * 100 + 0.05) / 100. This is so that someone who has been scoring an 89.5% and has been seeing their average rounded to 90% on the progress page is not suddenly surprised at the end of the course. The denominator for this is based on the total possible at the end of the course, and does not adjust for unreleased assignments – getting a perfect score on your first homework may only give you a 4% overall percentage. | +| Overall Percentage | Float value between 0 and 1 that marks the student's percentage for the course. This is calculated by weighing each assignment-type according to the rules specified in CourseDescriptor.grader. The grader is an `extensible interface `_, but the only rules currently used in practice are simple weight by assignment-type (e.g. 30% Final, 40% HW, etc.). The grader will return the actual percentage as a value between 0 and 1. The overall grade calculation process will then take this number and do a small bit of rounding up: round(actual_percent * 100 + 0.05) / 100. This is so that someone who has been scoring an 89.5% and has been seeing their average rounded to 90% on the progress page is not suddenly surprised at the end of the course. The denominator for this is based on the total possible at the end of the course, and does not adjust for unreleased assignments – getting a perfect score on your first homework may only give you a 4% overall percentage. | +-------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | Overall Grade | Letter grade based on CourseDescriptor.grade_cutoffs and the Overall Percentage (so after rounding up). | +-------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | XBlock | The fundamental building block of edX course content. Everything that shows up in the courseware tab is some subclass of XBlock: a chapter, a video, a problem, etc. The entire course is a tree of XBlocks, which act like mini web-applications that can cooperatively build a web page together. They define their own views and can define state in multiple scopes like content (e.g. problem definition) and student state (e.g. a student's answer). XBlocks in edx-platform store their student state as a JSON text field in the StudentModule model. | +-------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| Courseware | The Django model that XBlocks use to store their student state. Raw scores are also stored here. There are many legacy artifacts in this model, so please read the `documentation `_ if you're going to work with it. The vast majority of scores in the system are stored here. | +| Courseware | The Django model that XBlocks use to store their student state. Raw scores are also stored here. There are many legacy artifacts in this model, so please read the `documentation `_ if you're going to work with it. The vast majority of scores in the system are stored here. | | StudentModule | | +-------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| `Submissions API `_ | A newer API created to store submissions and scores separately from XBlock state. The long term goals were to make a more auditable, performant, and flexible scoring system. Scores and submissions are immutable (you never edit old entries, just create new ones). Because it is not derived from XBlock state, it is also possible to later record scores for more abstract things like "class participation", though that functionality is not presently used. This API is relatively new and few things use it. | +| `Submissions API `_ | A newer API created to store submissions and scores separately from XBlock state. The long term goals were to make a more auditable, performant, and flexible scoring system. Scores and submissions are immutable (you never edit old entries, just create new ones). Because it is not derived from XBlock state, it is also possible to later record scores for more abstract things like "class participation", though that functionality is not presently used. This API is relatively new and few things use it. | +-------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ | Modulestore | The interface that we use to instantiate XBlocks in edx-platform. Content is loaded from MongoDB as part of this process. | +-------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ -| Block Structures | An extensible framework for caching xBlock data from the modulestore and storing it in a denormalized read-optimized form. See the `BlockStructure Readme `_ for more information. | +| Block Structures | An extensible framework for caching xBlock data from the modulestore and storing it in a denormalized read-optimized form. See the `BlockStructure Readme `_ for more information. | +-------------------------------------------------------------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+ Course Structure in context of Grading @@ -79,13 +79,13 @@ Overall Course Grade - A learner's overall numerical grade in the course can range anywhere between 0% to 100%. - - Course teams set the `grade range `_ and specify the Pass / Fail threshold (for example, a minimum of 50/100 is required to Pass). + - Course teams set the `grade range `_ and specify the Pass / Fail threshold (for example, a minimum of 50/100 is required to Pass). - The Passing grade range can be further divided into letter grades, such as A, B, etc. Assignment Weights -* Course teams set the `assignment types `_ used in the course, along with their weights and the number of allowed drops (number of assignments with the lowest grades that can be discarded in the final grade computation). +* Course teams set the `assignment types `_ used in the course, along with their weights and the number of allowed drops (number of assignments with the lowest grades that can be discarded in the final grade computation). Computation @@ -118,9 +118,9 @@ Problem Scores - **automatically scored, synchronously** at the time of submission, such as for most Capa-based problems - - **automatically scored, asynchronously** via an `external grader service `_ + - **automatically scored, asynchronously** via an `external grader service `_ - - **manually scored**, such as for `Open Response Assessments `_, where the calculation requires human input from either + - **manually scored**, such as for `Open Response Assessments `_, where the calculation requires human input from either - a single course staff (staff assessment) @@ -153,19 +153,19 @@ As described above in the Grade Computation section, the grading policy is distr - A problem's external grader configuration - - A problem's individual grading policy - as currently supported by `ORA's assessment configuration `_ + - A problem's individual grading policy - as currently supported by `ORA's assessment configuration `_ Grade Overrides/Exceptions -------------------------- -Today, we support the following features to `adjust grades `_, but don't have a general feature to override a grade for any xBlock: +Today, we support the following features to `adjust grades `_, but don't have a general feature to override a grade for any xBlock: -* In `ORA Studio settings `_: +* In `ORA Studio settings `_: - override a learner's grade for an ORA2 block -* In LMS Instructor Dashboard or `Staff Debug Info `_: +* In LMS Instructor Dashboard or `Staff Debug Info `_: - reset the number of attempts a learner has made for a problem back to 0 @@ -173,7 +173,7 @@ Today, we support the following features to `adjust grades `_: +* In `Gradebook `_: - override a subsection grade for a learner - override subsection grades in bulk (master's track only) diff --git a/lms/djangoapps/grades/events.py b/lms/djangoapps/grades/events.py index 51d1b13702..be189a7022 100644 --- a/lms/djangoapps/grades/events.py +++ b/lms/djangoapps/grades/events.py @@ -279,6 +279,8 @@ def _emit_course_passing_status_update(user, course_id, is_passing): The status of event is determined by is_passing parameter. """ if hasattr(course_id, 'ccx'): + # .. event_implemented_name: CCX_COURSE_PASSING_STATUS_UPDATED + # .. event_type: org.openedx.learning.ccx.course.passing.status.updated.v1 CCX_COURSE_PASSING_STATUS_UPDATED.send_event( course_passing_status=CcxCoursePassingStatusData( is_passing=is_passing, @@ -298,6 +300,8 @@ def _emit_course_passing_status_update(user, course_id, is_passing): ) ) else: + # .. event_implemented_name: COURSE_PASSING_STATUS_UPDATED + # .. event_type: org.openedx.learning.course.passing.status.updated.v1 COURSE_PASSING_STATUS_UPDATED.send_event( course_passing_status=CoursePassingStatusData( is_passing=is_passing, diff --git a/lms/djangoapps/grades/migrations/0021_rename_persistentcoursegrade_passed_timestamp_course_id_passed_timestamp_course_id_idx_and_more.py b/lms/djangoapps/grades/migrations/0021_rename_persistentcoursegrade_passed_timestamp_course_id_passed_timestamp_course_id_idx_and_more.py new file mode 100644 index 0000000000..c75dffc063 --- /dev/null +++ b/lms/djangoapps/grades/migrations/0021_rename_persistentcoursegrade_passed_timestamp_course_id_passed_timestamp_course_id_idx_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.20 on 2025-05-12 13:39 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('grades', '0020_alter_historicalpersistentsubsectiongradeoverride_options'), + ] + + operations = [ + migrations.RenameIndex( + model_name='persistentcoursegrade', + new_name='passed_timestamp_course_id_idx', + old_fields=('passed_timestamp', 'course_id'), + ), + migrations.RenameIndex( + model_name='persistentcoursegrade', + new_name='modified_course_id_idx', + old_fields=('modified', 'course_id'), + ), + ] diff --git a/lms/djangoapps/grades/migrations/0022_rename_persistentsubsectiongrade_first_attempted_course_id_user_id_first_course_id_user_id_idx_and_m.py b/lms/djangoapps/grades/migrations/0022_rename_persistentsubsectiongrade_first_attempted_course_id_user_id_first_course_id_user_id_idx_and_m.py new file mode 100644 index 0000000000..58fdc4dcf9 --- /dev/null +++ b/lms/djangoapps/grades/migrations/0022_rename_persistentsubsectiongrade_first_attempted_course_id_user_id_first_course_id_user_id_idx_and_m.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.20 on 2025-05-14 05:53 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('grades', '0021_rename_persistentcoursegrade_passed_timestamp_course_id_passed_timestamp_course_id_idx_and_more'), + ] + + operations = [ + migrations.RenameIndex( + model_name='persistentsubsectiongrade', + new_name='first_course_id_user_id_idx', + old_fields=('first_attempted', 'course_id', 'user_id'), + ), + migrations.RenameIndex( + model_name='persistentsubsectiongrade', + new_name='course_id_usage_key_idx', + old_fields=('modified', 'course_id', 'usage_key'), + ), + ] diff --git a/lms/djangoapps/grades/models.py b/lms/djangoapps/grades/models.py index a5608eb39a..38cfed502c 100644 --- a/lms/djangoapps/grades/models.py +++ b/lms/djangoapps/grades/models.py @@ -315,9 +315,15 @@ class PersistentSubsectionGrade(TimeStampedModel): # in a course # (first_attempted, course_id, user_id): find all attempted subsections in a course for a user # (first_attempted, course_id): find all attempted subsections in a course for all users - index_together = [ - ('modified', 'course_id', 'usage_key'), - ('first_attempted', 'course_id', 'user_id') + indexes = [ + models.Index( + fields=['modified', 'course_id', 'usage_key'], + name="course_id_usage_key_idx" + ), + models.Index( + fields=['first_attempted', 'course_id', 'user_id'], + name="first_course_id_user_id_idx" + ), ] # primary key will need to be large for this table @@ -467,12 +473,6 @@ class PersistentSubsectionGrade(TimeStampedModel): defaults=params, ) - # TODO: Remove as part of EDUCATOR-4602. - if str(usage_key.course_key) == 'course-v1:UQx+BUSLEAD5x+2T2019': - log.info('Created/updated grade ***{}*** for user ***{}*** in course ***{}***' - 'for subsection ***{}*** with default params ***{}***' - .format(grade, user_id, usage_key.course_key, usage_key, params)) - grade.override = PersistentSubsectionGradeOverride.get_override(user_id, usage_key) if first_attempted is not None and grade.first_attempted is None: grade.first_attempted = first_attempted @@ -575,9 +575,9 @@ class PersistentCourseGrade(TimeStampedModel): unique_together = [ ('course_id', 'user_id'), ] - index_together = [ - ('passed_timestamp', 'course_id'), - ('modified', 'course_id') + indexes = [ + models.Index(fields=['passed_timestamp', 'course_id'], name="passed_timestamp_course_id_idx"), + models.Index(fields=['modified', 'course_id'], name="modified_course_id_idx") ] # primary key will need to be large for this table @@ -722,6 +722,7 @@ class PersistentCourseGrade(TimeStampedModel): When called emits an event when a persistent grade is created or updated. """ # .. event_implemented_name: PERSISTENT_GRADE_SUMMARY_CHANGED + # .. event_type: org.openedx.learning.course.persistent_grade_summary.changed.v1 PERSISTENT_GRADE_SUMMARY_CHANGED.send_event( grade=PersistentCourseGradeData( user_id=user_id, @@ -822,11 +823,6 @@ class PersistentSubsectionGradeOverride(models.Model): grade_defaults['override_reason'] = override_data['comment'] if 'comment' in override_data else None grade_defaults['system'] = override_data['system'] if 'system' in override_data else None - # TODO: Remove as part of EDUCATOR-4602. - if str(subsection_grade_model.course_id) == 'course-v1:UQx+BUSLEAD5x+2T2019': - log.info('Creating override for user ***{}*** for PersistentSubsectionGrade' - '***{}*** with override data ***{}*** and derived grade_defaults ***{}***.' - .format(requesting_user, subsection_grade_model, override_data, grade_defaults)) try: override = PersistentSubsectionGradeOverride.objects.get(grade=subsection_grade_model) for key, value in grade_defaults.items(): diff --git a/lms/djangoapps/grades/rest_api/v1/gradebook_views.py b/lms/djangoapps/grades/rest_api/v1/gradebook_views.py index 26df7ce258..c295563da5 100644 --- a/lms/djangoapps/grades/rest_api/v1/gradebook_views.py +++ b/lms/djangoapps/grades/rest_api/v1/gradebook_views.py @@ -859,11 +859,6 @@ class GradebookBulkUpdateView(GradeViewMixin, PaginatedAPIView): subsection = course.get_child(usage_key) if subsection: subsection_grade_model = self._create_subsection_grade(user, course, subsection) - # TODO: Remove as part of EDUCATOR-4602. - if str(course_key) == 'course-v1:UQx+BUSLEAD5x+2T2019': - log.info('PersistentSubsectionGrade ***{}*** created for' - ' subsection ***{}*** in course ***{}*** for user ***{}***.' - .format(subsection_grade_model, subsection.location, course, user.id)) else: self._log_update_result(request.user, requested_user_id, requested_usage_id, success=False) result.append(GradebookUpdateResponseItem( diff --git a/lms/djangoapps/grades/scores.py b/lms/djangoapps/grades/scores.py index 38dd0dc189..7a89a88c79 100644 --- a/lms/djangoapps/grades/scores.py +++ b/lms/djangoapps/grades/scores.py @@ -102,10 +102,6 @@ def get_score(submissions_scores, csm_scores, persisted_block, block): weight, graded - retrieved from the latest block content """ weight = _get_weight_from_block(persisted_block, block) - # TODO: Remove as part of EDUCATOR-4602. - if str(block.location.course_key) == 'course-v1:UQx+BUSLEAD5x+2T2019': - log.info('Weight for block: ***{}*** is {}' - .format(str(block.location), weight)) # Priority order for retrieving the scores: # submissions API -> CSM -> grades persisted block -> latest block content @@ -115,13 +111,6 @@ def get_score(submissions_scores, csm_scores, persisted_block, block): _get_score_from_persisted_or_latest_block(persisted_block, block, weight) ) - # TODO: Remove as part of EDUCATOR-4602. - if str(block.location.course_key) == 'course-v1:UQx+BUSLEAD5x+2T2019': - log.info('Calculated raw-earned: {}, raw_possible: {}, weighted_earned: ' - '{}, weighted_possible: {}, first_attempted: {} for block: ***{}***.' - .format(raw_earned, raw_possible, weighted_earned, - weighted_possible, first_attempted, str(block.location))) - if weighted_possible is None or weighted_earned is None: return None @@ -219,11 +208,6 @@ def _get_score_from_persisted_or_latest_block(persisted_block, block, weight): Uses the raw_possible value from the persisted_block if found, else from the latest block content. """ - # TODO: Remove as part of EDUCATOR-4602. - if str(block.location.course_key) == 'course-v1:UQx+BUSLEAD5x+2T2019': - log.info('Using _get_score_from_persisted_or_latest_block to calculate score for block: ***{}***.'.format( - str(block.location) - )) raw_earned = 0.0 first_attempted = None @@ -231,10 +215,6 @@ def _get_score_from_persisted_or_latest_block(persisted_block, block, weight): raw_possible = persisted_block.raw_possible else: raw_possible = block.transformer_data[GradesTransformer].max_score - # TODO: Remove as part of EDUCATOR-4602. - if str(block.location.course_key) == 'course-v1:UQx+BUSLEAD5x+2T2019': - log.info('Using latest block content to calculate score for block: ***{}***.') - log.info(f'weight for block: ***{str(block.location)}*** is {raw_possible}.') # TODO TNL-5982 remove defensive code for scorables without max_score if raw_possible is None: diff --git a/lms/djangoapps/grades/subsection_grade.py b/lms/djangoapps/grades/subsection_grade.py index ba098a92a4..4ce0a1f3a4 100644 --- a/lms/djangoapps/grades/subsection_grade.py +++ b/lms/djangoapps/grades/subsection_grade.py @@ -170,39 +170,21 @@ class NonZeroSubsectionGrade(SubsectionGradeBase, metaclass=ABCMeta): csm_scores, persisted_block=None, ): - # TODO: Remove as part of EDUCATOR-4602. - if str(block_key.course_key) == 'course-v1:UQx+BUSLEAD5x+2T2019': - log.info('Computing block score for block: ***{}*** in course: ***{}***.'.format( - str(block_key), - str(block_key.course_key), - )) try: block = course_structure[block_key] except KeyError: - # TODO: Remove as part of EDUCATOR-4602. - if str(block_key.course_key) == 'course-v1:UQx+BUSLEAD5x+2T2019': - log.info('User\'s access to block: ***{}*** in course: ***{}*** has changed. ' - 'No block score calculated.'.format(str(block_key), str(block_key.course_key))) # It's possible that the user's access to that # block has changed since the subsection grade # was last persisted. + pass else: if getattr(block, 'has_score', False): - # TODO: Remove as part of EDUCATOR-4602. - if str(block_key.course_key) == 'course-v1:UQx+BUSLEAD5x+2T2019': - log.info('Block: ***{}*** in course: ***{}*** HAS has_score attribute. Continuing.' - .format(str(block_key), str(block_key.course_key))) return get_score( submissions_scores, csm_scores, persisted_block, block, ) - # TODO: Remove as part of EDUCATOR-4602. - if str(block_key.course_key) == 'course-v1:UQx+BUSLEAD5x+2T2019': - log.info('Block: ***{}*** in course: ***{}*** DOES NOT HAVE has_score attribute. ' - 'No block score calculated.' - .format(str(block_key), str(block_key.course_key))) @staticmethod def _aggregated_score_from_model(grade_model, is_graded): @@ -283,23 +265,11 @@ class CreateSubsectionGrade(NonZeroSubsectionGrade): start_node=subsection.location, ): problem_score = self._compute_block_score(block_key, course_structure, submissions_scores, csm_scores) - - # TODO: Remove as part of EDUCATOR-4602. - if str(block_key.course_key) == 'course-v1:UQx+BUSLEAD5x+2T2019': - log.info('Calculated problem score ***{}*** for block ***{!s}***' - ' in subsection ***{}***.' - .format(problem_score, block_key, subsection.location)) if problem_score: self.problem_scores[block_key] = problem_score all_total, graded_total = graders.aggregate_scores(list(self.problem_scores.values())) - # TODO: Remove as part of EDUCATOR-4602. - if str(subsection.location.course_key) == 'course-v1:UQx+BUSLEAD5x+2T2019': - log.info('Calculated aggregate all_total ***{}***' - ' and grade_total ***{}*** for subsection ***{}***' - .format(all_total, graded_total, subsection.location)) - super().__init__(subsection, all_total, graded_total) def update_or_create_model(self, student, score_deleted=False, force_update_subsections=False): @@ -307,11 +277,6 @@ class CreateSubsectionGrade(NonZeroSubsectionGrade): Saves or updates the subsection grade in a persisted model. """ if self._should_persist_per_attempted(score_deleted, force_update_subsections): - # TODO: Remove as part of EDUCATOR-4602. - if str(self.location.course_key) == 'course-v1:UQx+BUSLEAD5x+2T2019': - log.info('Updating PersistentSubsectionGrade for student ***{}*** in' - ' subsection ***{}*** with params ***{}***.' - .format(student.id, self.location, self._persisted_model_params(student))) model = PersistentSubsectionGrade.update_or_create_grade(**self._persisted_model_params(student)) if hasattr(model, 'override'): diff --git a/lms/djangoapps/grades/tests/test_course_grade_factory.py b/lms/djangoapps/grades/tests/test_course_grade_factory.py index c47e80a3da..606c935e40 100644 --- a/lms/djangoapps/grades/tests/test_course_grade_factory.py +++ b/lms/djangoapps/grades/tests/test_course_grade_factory.py @@ -68,35 +68,35 @@ class TestCourseGradeFactory(GradeTestBase): self.sequence2.display_name ] - with self.assertNumQueries(5), mock_get_score(1, 2): + with self.assertNumQueries(3), mock_get_score(1, 2): _assert_read(expected_pass=False, expected_percent=0) # start off with grade of 0 - num_queries = 43 + num_queries = 42 with self.assertNumQueries(num_queries), mock_get_score(1, 2): grade_factory.update(self.request.user, self.course, force_update_subsections=True) - with self.assertNumQueries(4): + with self.assertNumQueries(3): _assert_read(expected_pass=True, expected_percent=0.5) # updated to grade of .5 - num_queries = 7 + num_queries = 6 with self.assertNumQueries(num_queries), mock_get_score(1, 4): grade_factory.update(self.request.user, self.course, force_update_subsections=False) - with self.assertNumQueries(4): + with self.assertNumQueries(3): _assert_read(expected_pass=True, expected_percent=0.5) # NOT updated to grade of .25 - num_queries = 19 + num_queries = 18 with self.assertNumQueries(num_queries), mock_get_score(2, 2): grade_factory.update(self.request.user, self.course, force_update_subsections=True) - with self.assertNumQueries(4): + with self.assertNumQueries(3): _assert_read(expected_pass=True, expected_percent=1.0) # updated to grade of 1.0 - num_queries = 29 + num_queries = 28 with self.assertNumQueries(num_queries), mock_get_score(0, 0): # the subsection now is worth zero grade_factory.update(self.request.user, self.course, force_update_subsections=True) - with self.assertNumQueries(4): + with self.assertNumQueries(3): _assert_read(expected_pass=False, expected_percent=0.0) # updated to grade of 0.0 @ddt.data((True, False)) diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py index ed344876eb..23d0ce3d3f 100644 --- a/lms/djangoapps/instructor/enrollment.py +++ b/lms/djangoapps/instructor/enrollment.py @@ -4,7 +4,6 @@ Enrollment operations for use by instructor APIs. Does not include any access control, be sure to check access before calling. """ - import json import logging from contextlib import ExitStack, contextmanager @@ -60,6 +59,7 @@ log = logging.getLogger(__name__) class EmailEnrollmentState: """ Store the complete enrollment state of an email in a class """ + def __init__(self, course_id, email): # N.B. retired users are not a concern here because they should be # handled at a higher level (i.e. in enroll_email). Besides, this @@ -433,10 +433,10 @@ def _reset_module_attempts(studentmodule): def _fire_score_changed_for_block( - course_id, - student, - block, - module_state_key, + course_id, + student, + block, + module_state_key, ): """ Fires a PROBLEM_RAW_SCORE_CHANGED event for the given module. @@ -566,9 +566,10 @@ def send_mail_to_student(student, param_dict, language=None): # Extract an LMS user ID for the student, if possible. # ACE needs the user ID to be able to send email via Braze. - lms_user_id = 0 - if 'user_id' in param_dict and param_dict['user_id'] is not None and param_dict['user_id'] > 0: - lms_user_id = param_dict['user_id'] + try: + lms_user_id = User.objects.get(email=student).id + except User.DoesNotExist: + lms_user_id = 0 # see if there is an activation email template definition available as configuration, # if so, then render that @@ -590,7 +591,6 @@ def send_mail_to_student(student, param_dict, language=None): language=language, user_context=param_dict, ) - ace.send(message) diff --git a/lms/djangoapps/instructor/permissions.py b/lms/djangoapps/instructor/permissions.py index 24e0079fcc..254d1457b5 100644 --- a/lms/djangoapps/instructor/permissions.py +++ b/lms/djangoapps/instructor/permissions.py @@ -4,9 +4,13 @@ Permissions for the instructor dashboard and associated actions from bridgekeeper import perms from bridgekeeper.rules import is_staff from opaque_keys.edx.keys import CourseKey +from rest_framework.exceptions import PermissionDenied from rest_framework.permissions import BasePermission +from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.rules import HasAccessRule, HasRolesRule +from lms.djangoapps.discussion.django_comment_client.utils import has_forum_access +from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_ADMINISTRATOR from openedx.core.lib.courses import get_course_by_id ALLOW_STUDENT_TO_BYPASS_ENTRANCE_EXAM = 'instructor.allow_student_to_bypass_entrance_exam' @@ -82,3 +86,44 @@ class InstructorPermission(BasePermission): course = get_course_by_id(CourseKey.from_string(view.kwargs.get('course_id'))) permission = getattr(view, 'permission_name', None) return request.user.has_perm(permission, course) + + +class ForumAdminRequiresInstructorAccess(BasePermission): + """ + default roles require either (staff & forum admin) or (instructor) + User should be forum-admin and staff to access this endpoint. + + But if request rolename is FORUM_ROLE_ADMINISTRATOR, then user must also have + instructor-level access to proceed. + """ + def has_permission(self, request, view): + """ + Permission class for forum endpoints. + + Only allow if: + - User is an instructor, OR + - User is staff AND forum admin. + + Special case: + - If the action relates to forum admin (FORUM_ROLE_ADMINISTRATOR), user must be instructor. + """ + rolename = request.data.get('rolename') + course_id = view.kwargs.get('course_id') + course = get_course_by_id(CourseKey.from_string(course_id)) + + has_instructor_access = has_access(request.user, 'instructor', course) + has_forum_admin = has_forum_access( + request.user, course_id, FORUM_ROLE_ADMINISTRATOR + ) + + # Special case first: if role is FORUM_ROLE_ADMINISTRATOR + if rolename == FORUM_ROLE_ADMINISTRATOR: + if has_instructor_access: + return True + raise PermissionDenied("Operation requires instructor access.") + + # default roles require either (staff & forum admin) or (instructor) + if has_instructor_access or has_forum_admin: + return True + + raise PermissionDenied("Operation requires staff & forum admin or instructor access") diff --git a/lms/djangoapps/instructor/static/instructor/.eslintrc.js b/lms/djangoapps/instructor/static/instructor/.eslintrc.js deleted file mode 100644 index 24039825bd..0000000000 --- a/lms/djangoapps/instructor/static/instructor/.eslintrc.js +++ /dev/null @@ -1,19 +0,0 @@ -module.exports = { - extends: '@edx/eslint-config', - root: true, - settings: { - 'import/resolver': { - webpack: { - config: 'webpack.dev.config.js', - }, - }, - }, - rules: { - 'import/prefer-default-export': 'off', - indent: ['error', 4], - 'react/jsx-indent': ['error', 4], - 'react/jsx-indent-props': ['error', 4], - 'import/extensions': 'off', - 'import/no-unresolved': 'off', - }, -}; diff --git a/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/Main.test.jsx b/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/Main.test.jsx index fc44829b90..758de36082 100644 --- a/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/Main.test.jsx +++ b/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/Main.test.jsx @@ -1,96 +1,91 @@ -// eslint-disable-next-line no-redeclare -/* global jest,test,describe,expect */ -import { Button } from '@edx/paragon'; -import BlockBrowserContainer from 'BlockBrowser/components/BlockBrowser/BlockBrowserContainer'; import { Provider } from 'react-redux'; -import { shallow } from 'enzyme'; import React from 'react'; -import renderer from 'react-test-renderer'; +import { + render, + screen, + waitFor, +} from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import store from '../../data/store'; - import Main from './Main'; +jest.mock('BlockBrowser/components/BlockBrowser/BlockBrowserContainer', () => { + function MockedBlockBrowserContainer() { + return ( +
    + Mocked BlockBrowserContainer +
    + ); + } + return MockedBlockBrowserContainer; +}); + describe('ProblemBrowser Main component', () => { const courseId = 'testcourse'; const problemResponsesEndpoint = '/api/problem_responses/'; const taskStatusEndpoint = '/api/task_status/'; const excludedBlockTypes = []; + const reportDownloadEndpoint = '/api/report_download'; + let fetchCourseBlocksMock; + let createProblemResponsesReportTaskMock; + let onSelectBlockMock; - test('render with basic parameters', () => { - const component = renderer.create( - -
    - , - ); - const tree = component.toJSON(); - expect(tree).toMatchSnapshot(); + beforeEach(() => { + fetchCourseBlocksMock = jest.fn(); + createProblemResponsesReportTaskMock = jest.fn(); + onSelectBlockMock = jest.fn(); }); - test('render with selected block', () => { - const component = renderer.create( + const renderMainComponent = (props = {}) => ( + render(
    - , - ); - const tree = component.toJSON(); - expect(tree).toMatchSnapshot(); - }); - - test('fetch course block on toggling dropdown', () => { - const fetchCourseBlocksMock = jest.fn(); - const component = renderer.create( - -
    , - ); - // eslint-disable-next-line prefer-destructuring - const instance = component.root.children[0].instance; - instance.handleToggleDropdown(); - expect(fetchCourseBlocksMock.mock.calls.length).toBe(1); + ) + ); + + describe('Initial rendering', () => { + test('should match snapshot with basic parameters', () => { + const { container } = renderMainComponent(); + expect(container).toMatchSnapshot(); + }); + test('should match snapshot with selected block', () => { + const { container } = renderMainComponent({ selectedBlock: 'some-selected-block' }); + expect(container).toMatchSnapshot(); + }); }); - test('display dropdown on toggling dropdown', () => { - const component = shallow( -
    , - ); - expect(component.find(BlockBrowserContainer).length).toBeFalsy(); - component.find(Button).find({ label: 'Select a section or problem' }).simulate('click'); - expect(component.find(BlockBrowserContainer).length).toBeTruthy(); + describe('Dropdown interactions', () => { + test('should fetch course blocks when dropdown is toggled', async () => { + renderMainComponent(); + await userEvent.click(screen.getByText('Select a section or problem')); + await waitFor(() => { + expect(fetchCourseBlocksMock).toHaveBeenCalledTimes(1); + expect(fetchCourseBlocksMock).toHaveBeenCalledWith(courseId, excludedBlockTypes); + }); + }); + + test('should display dropdown when toggled', async () => { + renderMainComponent(); + expect(screen.queryByTestId('mocked-block-browser-container')).toBeNull(); + await userEvent.click(screen.getByText('Select a section or problem')); + await waitFor(() => expect( + screen.getByTestId('mocked-block-browser-container'), + ).toHaveClass('block-browser')); + await userEvent.click(screen.getByText('Select a section or problem')); + await waitFor(() => expect(screen.queryByTestId('mocked-block-browser-container')).toBeNull()); + }); }); }); diff --git a/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/__snapshots__/Main.test.jsx.snap b/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/__snapshots__/Main.test.jsx.snap index 2ee3170da3..cebf59aa18 100644 --- a/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/__snapshots__/Main.test.jsx.snap +++ b/lms/djangoapps/instructor/static/instructor/ProblemBrowser/components/Main/__snapshots__/Main.test.jsx.snap @@ -1,83 +1,73 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ProblemBrowser Main component render with basic parameters 1`] = ` -
    +exports[`ProblemBrowser Main component Initial rendering should match snapshot with basic parameters 1`] = ` +
    - - -
    + class="problem-browser" + > + + + +
    +
    `; -exports[`ProblemBrowser Main component render with selected block 1`] = ` -
    +exports[`ProblemBrowser Main component Initial rendering should match snapshot with selected block 1`] = ` +
    - - -
    + class="problem-browser" + > + + + +
    +
    `; diff --git a/lms/djangoapps/instructor/static/instructor/ProblemBrowser/data/api/client.js b/lms/djangoapps/instructor/static/instructor/ProblemBrowser/data/api/client.js index 1a15d44cc2..53f910c7bf 100644 --- a/lms/djangoapps/instructor/static/instructor/ProblemBrowser/data/api/client.js +++ b/lms/djangoapps/instructor/static/instructor/ProblemBrowser/data/api/client.js @@ -1,4 +1,3 @@ -import 'whatwg-fetch'; import Cookies from 'js-cookie'; const HEADERS = { diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 4d3e413f5a..af8408ba8f 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -22,20 +22,15 @@ from django.core.files.uploadedfile import SimpleUploadedFile from django.http import HttpRequest, HttpResponse from django.test import RequestFactory, TestCase from django.test.client import MULTIPART_CONTENT +from django.test.utils import override_settings from django.urls import reverse as django_reverse from django.utils.translation import gettext as _ -from edx_when.api import get_dates_for_course, get_overrides_for_user, set_date_for_block from edx_toggles.toggles.testutils import override_waffle_flag +from edx_when.api import get_dates_for_course, get_overrides_for_user, set_date_for_block from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import UsageKey from pytz import UTC from testfixtures import LogCapture -from xmodule.fields import Date -from xmodule.modulestore import ModuleStoreEnum -from xmodule.modulestore.tests.django_utils import ( - TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase, SharedModuleStoreTestCase, -) -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory @@ -59,21 +54,21 @@ from common.djangoapps.student.roles import ( CourseBetaTesterRole, CourseDataResearcherRole, CourseFinanceAdminRole, - CourseInstructorRole, + CourseInstructorRole ) from common.djangoapps.student.tests.factories import ( BetaTesterFactory, + CourseAccessRoleFactory, CourseEnrollmentFactory, GlobalStaffFactory, InstructorFactory, StaffFactory, UserFactory ) + from lms.djangoapps.bulk_email.models import BulkEmailFlag, CourseEmail, CourseEmailTemplate from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.tests.factories import ( - GeneratedCertificateFactory -) +from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase from lms.djangoapps.instructor.tests.utils import FakeContentTask, FakeEmail, FakeEmailInfo @@ -95,17 +90,26 @@ from lms.djangoapps.instructor_task.models import InstructorTask, InstructorTask from lms.djangoapps.program_enrollments.tests.factories import ProgramEnrollmentFactory from openedx.core.djangoapps.course_date_signals.handlers import extract_dates from openedx.core.djangoapps.course_groups.cohorts import set_course_cohorted -from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_COMMUNITY_TA +from openedx.core.djangoapps.django_comment_common.models import FORUM_ROLE_COMMUNITY_TA, Role from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles from openedx.core.djangoapps.oauth_dispatch import jwt as jwt_api from openedx.core.djangoapps.oauth_dispatch.adapters import DOTAdapter from openedx.core.djangoapps.oauth_dispatch.tests.factories import AccessTokenFactory, ApplicationFactory from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin +from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context from openedx.core.djangoapps.user_api.preferences.api import delete_user_preference from openedx.core.lib.teams_config import TeamsConfig from openedx.core.lib.xblock_utils import grade_histogram from openedx.features.course_experience import RELATIVE_DATES_FLAG +from xmodule.fields import Date +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import ( + TEST_DATA_SPLIT_MODULESTORE, + ModuleStoreTestCase, + SharedModuleStoreTestCase +) +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory from .test_tools import msk_from_problem_urlname @@ -1990,6 +1994,15 @@ class TestInstructorAPIBulkBetaEnrollment(SharedModuleStoreTestCase, LoginEnroll self.add_notenrolled(response, self.notenrolled_student.username) assert CourseEnrollment.is_enrolled(self.notenrolled_student, self.course.id) + def test_add_notenrolled_username_autoenroll_with_multiple_users(self): + url = reverse('bulk_beta_modify_access', kwargs={'course_id': str(self.course.id)}) + identifiers = (f"Lorem@ipsum.dolor, " + f"sit@amet.consectetur\nadipiscing@elit.Aenean\r convallis@at.lacus\r, ut@lacinia.Sed, " + f"{self.notenrolled_student.username}" + ) + response = self.client.post(url, {'identifiers': identifiers, 'action': 'add', 'email_students': False, 'auto_enroll': True}) # lint-amnesty, pylint: disable=line-too-long + assert 6, len(json.loads(response.content.decode())['results']) + @ddt.data('http', 'https') def test_add_notenrolled_with_email(self, protocol): url = reverse('bulk_beta_modify_access', kwargs={'course_id': str(self.course.id)}) @@ -2250,6 +2263,7 @@ class TestInstructorAPILevelsAccess(SharedModuleStoreTestCase, LoginEnrollmentTe self.other_instructor = InstructorFactory(course_key=self.course.id) self.other_staff = StaffFactory(course_key=self.course.id) self.other_user = UserFactory() + self.list_forum_members_url = reverse('list_forum_members', kwargs={'course_id': str(self.course.id)}) def test_modify_access_noparams(self): """ Test missing all query parameters. """ @@ -2446,8 +2460,11 @@ class TestInstructorAPILevelsAccess(SharedModuleStoreTestCase, LoginEnrollmentTe def assert_update_forum_role_membership(self, current_user, identifier, rolename, action): """ - Test update forum role membership. - Get unique_student_identifier, rolename and action and update forum role. + default roles require either (staff & forum admin) or (instructor) + User should be forum-admin and staff to access this endpoint. + + But if request rolename is FORUM_ROLE_ADMINISTRATOR, then user must also have + instructor-level access to proceed. """ url = reverse('update_forum_role_membership', kwargs={'course_id': str(self.course.id)}) response = self.client.post( @@ -2481,6 +2498,113 @@ class TestInstructorAPILevelsAccess(SharedModuleStoreTestCase, LoginEnrollmentTe self.assert_update_forum_role_membership(user, user.email, rolename, "revoke") CourseEnrollment.unenroll(user, self.course.id) + def create_forum_roles(self, role_name, user): + """ + DRY Utility method for adding roles. + """ + role, __ = Role.objects.get_or_create( + course_id=self.course.id, + name=role_name + ) + role.users.add(user) + + def access_list_forum(self, user): + """ + Utility method for adding forums rules and hitting the url. + """ + for role_name in ["Group Moderator", "Moderator", "Community TA", "Administrator"]: + self.create_forum_roles(role_name, user) + return self.client.post(self.list_forum_members_url, {'rolename': role_name}) + + def test_staff_without_forum_admin_access(self): + """ + Test to ensure that an error is raised if the given rolename lacks the appropriate permissions. + Allowed Role is either (staff & forum admin) or (instructor). + + In this test case user has staff permissions but his forum admin role is missing. + """ + self.client.logout() + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) + role_name = "staff" + CourseAccessRoleFactory( + course_id=self.course.id, + user=self.other_user, + role=role_name, + org=self.course.id.org + ) + + response = self.access_list_forum(self.other_user) + assert response.status_code == 403 + + if role_name in ["Administrator"]: + # if the rolename is `Administrator` then user must need `Administrator` access. + assert (response.__dict__['data'].get('detail') == + "Operation requires instructor access.") + else: + assert (response.__dict__['data'].get('detail') == + "Operation requires staff & forum admin or instructor access") + + def test_staff_with_forum_admin_access(self): + """ + Test to ensure that an error is raised if the given rolename lacks the appropriate permissions. + Allowed Role is either (staff & forum admin) or (instructor) + + In this test case user has staff permissions and forum admin role also. + """ + self.client.logout() + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) + + CourseAccessRoleFactory( + course_id=self.course.id, + user=self.other_user, + role="staff", + org=self.course.id.org + ) + + # make user staff and administrator + self.create_forum_roles('Administrator', self.other_user) + response = self.access_list_forum(self.other_user) + assert response.status_code == 200 + data = json.loads(response.content.decode('utf-8')) + assert data['course_id'] == str(self.course.id) + + def test_staff_with_forum_admin_access_with_oauth(self): + """ + Verify the endpoint using JWT authentication. + """ + self.client.logout() + dot_application = ApplicationFactory(user=self.other_user, authorization_grant_type='password') + access_token = AccessTokenFactory(user=self.other_user, application=dot_application) + oauth_adapter = DOTAdapter() + token_dict = { + 'access_token': access_token, + 'scope': 'email profile', + } + jwt_token = jwt_api.create_jwt_from_token(token_dict, oauth_adapter, use_asymmetric_key=True) + headers = { + 'HTTP_AUTHORIZATION': 'JWT ' + jwt_token + } + response = self.client.post( + self.list_forum_members_url, + data={'rolename': 'Moderator'}, + **headers + ) + # JWT authentication works but it has no permissions. + assert response.status_code == 403 + + # add user as course staff. + CourseAccessRoleFactory( + course_id=self.course.id, + user=self.other_user, + role="staff", + org=self.course.id.org + ) + + # add user as forum admin. + self.create_forum_roles('Administrator', self.other_user) + response = self.client.post(self.list_forum_members_url, data={'rolename': 'Moderator'}, **headers) + assert response.status_code == 200 + @ddt.ddt class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollmentTestCase): @@ -2617,6 +2741,63 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment assert student_json['city'] == student.profile.city assert student_json['country'] == '' + def test_get_students_features_private_fields(self): + """ + Test that the get_students_features does not return the private fields + if they are by default in the `PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS` setting. + """ + url = reverse("get_students_features", kwargs={"course_id": str(self.course.id)}) + response = self.client.post(url, {}) + res_json = json.loads(response.content.decode("utf-8")) + + assert "students" in res_json + for student in res_json["students"]: + for field in settings.PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS: + assert field not in student + + def test_get_students_features_private_fields_with_custom_config(self): + """ + Test that the get_students_features does not return the private custom + fields set in the `PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS` setting. + """ + private_fields = ["email", "location", "gender"] + + with override_settings(PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS=private_fields): + url = reverse("get_students_features", kwargs={"course_id": str(self.course.id)}) + response = self.client.post(url, {}) + res_json = json.loads(response.content.decode("utf-8")) + + assert "students" in res_json + for student in res_json["students"]: + for field in private_fields: + assert field not in student + + @override_settings(PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS=[]) + def test_get_students_features_private_fields_empty(self): + """ + Test that the get_students_features returns all the fields if the + `PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS` setting is empty. + """ + custom_config = { + "student_profile_download_fields": [ + "id", + "username", + "email", + "language", + "year_of_birth", + ] + } + + with with_site_configuration_context(configuration=custom_config): + url = reverse("get_students_features", kwargs={"course_id": str(self.course.id)}) + response = self.client.post(url, {}) + res_json = json.loads(response.content.decode("utf-8")) + + assert "students" in res_json + for student in res_json["students"]: + for field in custom_config["student_profile_download_fields"]: + assert field in student + @ddt.data(True, False) def test_get_students_features_cohorted(self, is_cohorted): """ @@ -2678,6 +2859,16 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment else: assert student_json['external_user_key'] == '' + def test_get_students_features_without_permissions(self): + """ Test that get_students_features returns 403 without credentials. """ + + # removed both roles from courses for instructor + CourseDataResearcherRole(self.course.id).remove_users(self.instructor) + CourseInstructorRole(self.course.id).remove_users(self.instructor) + url = reverse('get_students_features', kwargs={'course_id': str(self.course.id)}) + response = self.client.post(url, {}) + assert response.status_code == 403 + def test_get_students_who_may_enroll(self): """ Test whether get_students_who_may_enroll returns an appropriate @@ -4214,6 +4405,36 @@ class TestDueDateExtensions(SharedModuleStoreTestCase, LoginEnrollmentTestCase): assert response.status_code == 400, response.content assert get_extended_due(self.course, self.week3, self.user1) is None + def test_change_to_invalid_username(self): + url = reverse('change_due_date', kwargs={'course_id': str(self.course.id)}) + response = self.client.post(url, { + 'student': 'invalid_username', + 'url': str(self.week1.location), + 'due_datetime': '12/30/2026 02:00' + }) + assert response.status_code == 404, response.content + assert get_extended_due(self.course, self.week1, self.user1) is None + + def test_change_to_invalid_due_date_format(self): + url = reverse('change_due_date', kwargs={'course_id': str(self.course.id)}) + response = self.client.post(url, { + 'student': self.user1.username, + 'url': str(self.week1.location), + 'due_datetime': '12/30/2kkk 00:00:00' + }) + assert response.status_code == 400, response.content + assert get_extended_due(self.course, self.week1, self.user1) is None + + def test_change_with_blank_fields(self): + url = reverse('change_due_date', kwargs={'course_id': str(self.course.id)}) + response = self.client.post(url, { + 'student': '', + 'url': '', + 'due_datetime': '' + }) + assert response.status_code == 400, response.content + assert get_extended_due(self.course, self.week1, self.user1) is None + @override_waffle_flag(RELATIVE_DATES_FLAG, active=True) def test_reset_date(self): self.test_change_due_date() diff --git a/lms/djangoapps/instructor/tests/test_certificates.py b/lms/djangoapps/instructor/tests/test_certificates.py index b24ef618c7..3b6a9a2235 100644 --- a/lms/djangoapps/instructor/tests/test_certificates.py +++ b/lms/djangoapps/instructor/tests/test_certificates.py @@ -367,7 +367,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): # Assert Error Message assert res_json['message'] ==\ - 'Please select one or more certificate statuses that require certificate regeneration.' + 'Please select certificate statuses from the list only.' # Access the url passing 'certificate_statuses' that are not present in db url = reverse('start_certificate_regeneration', kwargs={'course_id': str(self.course.id)}) @@ -378,7 +378,8 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): res_json = json.loads(response.content.decode('utf-8')) # Assert Error Message - assert res_json['message'] == 'Please select certificate statuses from the list only.' + assert (res_json['message'] == + 'Please select certificate statuses from the list only.') @override_settings(CERT_QUEUE='certificates') @@ -488,9 +489,7 @@ class CertificateExceptionViewInstructorApiTest(SharedModuleStoreTestCase): assert not res_json['success'] # Assert Error Message - assert res_json['message'] ==\ - 'Student username/email field is required and can not be empty.' \ - ' Kindly fill in username/email and then press "Add to Exception List" button.' + assert res_json['message'] == {'user': ['This field may not be blank.']} def test_certificate_exception_duplicate_user_error(self): """ @@ -604,6 +603,34 @@ class CertificateExceptionViewInstructorApiTest(SharedModuleStoreTestCase): # Verify that certificate exception does not exist assert not certs_api.is_on_allowlist(self.user2, self.course.id) + def test_certificate_exception_removed_successfully_form_url(self): + """ + In case of deletion front-end is sending content-type x-www-form-urlencoded. + Just to handle that some logic added in api and this test is for that part. + Test certificates exception removal api endpoint returns success status + when called with valid course key and certificate exception id + """ + GeneratedCertificateFactory.create( + user=self.user2, + course_id=self.course.id, + status=CertificateStatuses.downloadable, + grade='1.0' + ) + # Verify that certificate exception exists + assert certs_api.is_on_allowlist(self.user2, self.course.id) + + response = self.client.post( + self.url, + data=json.dumps(self.certificate_exception_in_db), + content_type='application/x-www-form-urlencoded', + REQUEST_METHOD='DELETE' + ) + # Assert successful request processing + assert response.status_code == 204 + + # Verify that certificate exception does not exist + assert not certs_api.is_on_allowlist(self.user2, self.course.id) + def test_remove_certificate_exception_invalid_request_error(self): """ Test certificates exception removal api endpoint returns error diff --git a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py index 400e9b5562..e48098b9ef 100644 --- a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py +++ b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py @@ -103,7 +103,8 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT Returns expected dashboard demographic message with link to Insights. """ return 'For analytics about your course, go to Example.'.format(str(self.course.id)) + 'rel="noopener" target="_blank">ExampleOpens in a new tab' \ + '.'.format(str(self.course.id)) def test_instructor_tab(self): """ diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index f27ffc32db..f5e6d007e8 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -22,7 +22,7 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imp from django.core.exceptions import MultipleObjectsReturned, ObjectDoesNotExist, PermissionDenied, ValidationError from django.core.validators import validate_email from django.db import IntegrityError, transaction -from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound +from django.http import QueryDict, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotFound from django.shortcuts import redirect from django.urls import reverse from django.utils.decorators import method_decorator @@ -30,7 +30,7 @@ from django.utils.html import strip_tags from django.utils.translation import gettext as _ from django.views.decorators.cache import cache_control from django.views.decorators.csrf import ensure_csrf_cookie -from django.views.decorators.http import require_POST, require_http_methods +from django.views.decorators.http import require_POST from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from edx_when.api import get_date_for_block @@ -77,18 +77,10 @@ from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadReq from common.djangoapps.util.views import require_global_staff # pylint: disable=unused-import from lms.djangoapps.bulk_email.api import is_bulk_email_feature_enabled, create_course_email from lms.djangoapps.certificates import api as certs_api -from lms.djangoapps.certificates.models import ( - CertificateStatuses -) from lms.djangoapps.course_home_api.toggles import course_home_mfe_progress_tab_is_active from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.courses import get_course_with_access from lms.djangoapps.courseware.models import StudentModule -from lms.djangoapps.discussion.django_comment_client.utils import ( - get_group_id_for_user, - get_group_name, - has_forum_access, -) from lms.djangoapps.instructor import enrollment from lms.djangoapps.instructor.access import ROLES, allow_access, list_with_level, revoke_access, update_forum_role from lms.djangoapps.instructor.constants import INVOICE_KEY @@ -110,23 +102,26 @@ from lms.djangoapps.instructor.views.serializer import ( AccessSerializer, BlockDueDateSerializer, CertificateSerializer, + CertificateStatusesSerializer, + ForumRoleNameSerializer, ListInstructorTaskInputSerializer, + ModifyAccessSerializer, RoleNameSerializer, SendEmailSerializer, + ShowUnitExtensionsSerializer, ShowStudentExtensionSerializer, StudentAttemptsSerializer, UserSerializer, - UniqueStudentIdentifierSerializer + UniqueStudentIdentifierSerializer, + ProblemResetSerializer, + UpdateForumRoleMembershipSerializer, + RescoreEntranceExamSerializer ) from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, is_course_cohorted from openedx.core.djangoapps.course_groups.models import CourseUserGroup from openedx.core.djangoapps.django_comment_common.models import ( CourseDiscussionSettings, - FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_COMMUNITY_TA, - FORUM_ROLE_GROUP_MODERATOR, - FORUM_ROLE_MODERATOR, Role, ) from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers @@ -138,11 +133,11 @@ from openedx.core.lib.courses import get_course_by_id from openedx.core.lib.api.serializers import CourseKeyField from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url from .tools import ( + DashboardError, dump_block_extensions, dump_student_extensions, find_unit, get_student_from_identifier, - handle_dashboard_error, keep_field_private, parse_datetime, set_due_date_extension, @@ -838,7 +833,7 @@ def students_update_enrollment(request, course_id): # lint-amnesty, pylint: dis validate_email(email) # Raises ValidationError if invalid if action == 'enroll': before, after, enrollment_obj = enroll_email( - course_id, email, auto_enroll, email_students, email_params, language=language + course_id, email, auto_enroll, email_students, {**email_params}, language=language ) before_enrollment = before.to_dict()['enrollment'] before_user_registered = before.to_dict()['user'] @@ -861,7 +856,7 @@ def students_update_enrollment(request, course_id): # lint-amnesty, pylint: dis elif action == 'unenroll': before, after = unenroll_email( - course_id, email, email_students, email_params, language=language + course_id, email, email_students, {**email_params}, language=language ) before_enrollment = before.to_dict()['enrollment'] before_allowed = before.to_dict()['allowed'] @@ -916,88 +911,91 @@ def students_update_enrollment(request, course_id): # lint-amnesty, pylint: dis return JsonResponse(response_payload) -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_BETATEST) -@common_exceptions_400 -@require_post_params( - identifiers="stringified list of emails and/or usernames", - action="add or remove", -) -def bulk_beta_modify_access(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class BulkBetaModifyAccess(DeveloperErrorViewMixin, APIView): """ Enroll or unenroll users in beta testing program. - - Query parameters: - - identifiers is string containing a list of emails and/or usernames separated by - anything split_input_list can handle. - - action is one of ['add', 'remove'] """ - course_id = CourseKey.from_string(course_id) - action = request.POST.get('action') - identifiers_raw = request.POST.get('identifiers') - identifiers = _split_input_list(identifiers_raw) - email_students = _get_boolean_param(request, 'email_students') - auto_enroll = _get_boolean_param(request, 'auto_enroll') - results = [] - rolename = 'beta' - course = get_course_by_id(course_id) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_BETATEST + serializer_class = ModifyAccessSerializer - email_params = {} - if email_students: - secure = request.is_secure() - email_params = get_email_params(course, auto_enroll=auto_enroll, secure=secure) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Query parameters: + - identifiers is string containing a list of emails and/or usernames separated by + anything split_input_list can handle. + - action is one of ['add', 'remove'] + """ + course_id = CourseKey.from_string(course_id) + serializer = self.serializer_class(data=request.data) + if not serializer.is_valid(): + return JsonResponse({'message': serializer.errors}, status=400) - for identifier in identifiers: - try: - error = False - user_does_not_exist = False - user = get_student_from_identifier(identifier) - user_active = user.is_active + action = serializer.validated_data['action'] + identifiers = serializer.validated_data['identifiers'] + email_students = serializer.validated_data['email_students'] + auto_enroll = serializer.validated_data['auto_enroll'] - if action == 'add': - allow_access(course, user, rolename) - elif action == 'remove': - revoke_access(course, user, rolename) + results = [] + rolename = 'beta' + course = get_course_by_id(course_id) + + email_params = {} + if email_students: + secure = request.is_secure() + email_params = get_email_params(course, auto_enroll=auto_enroll, secure=secure) + + for identifier in identifiers: + try: + error = False + user_does_not_exist = False + user = get_student_from_identifier(identifier) + user_active = user.is_active + + if action == 'add': + allow_access(course, user, rolename) + elif action == 'remove': + revoke_access(course, user, rolename) + else: + return HttpResponseBadRequest(strip_tags( + f"Unrecognized action '{action}'" + )) + except User.DoesNotExist: + error = True + user_does_not_exist = True + user_active = None + # catch and log any unexpected exceptions + # so that one error doesn't cause a 500. + except Exception as exc: # pylint: disable=broad-except + log.exception("Error while #{}ing student") + log.exception(exc) + error = True else: - return HttpResponseBadRequest(strip_tags( - f"Unrecognized action '{action}'" - )) - except User.DoesNotExist: - error = True - user_does_not_exist = True - user_active = None - # catch and log any unexpected exceptions - # so that one error doesn't cause a 500. - except Exception as exc: # pylint: disable=broad-except - log.exception("Error while #{}ing student") - log.exception(exc) - error = True - else: - # If no exception thrown, see if we should send an email - if email_students: - send_beta_role_email(action, user, email_params) - # See if we should autoenroll the student - if auto_enroll: - # Check if student is already enrolled - if not is_user_enrolled_in_course(user, course_id): - CourseEnrollment.enroll(user, course_id) + # If no exception thrown, see if we should send an email + if email_students: + send_beta_role_email(action, user, email_params) + # See if we should autoenroll the student + if auto_enroll: + # Check if student is already enrolled + if not is_user_enrolled_in_course(user, course_id): + CourseEnrollment.enroll(user, course_id) - finally: - # Tabulate the action result of this email address - results.append({ - 'identifier': identifier, - 'error': error, # pylint: disable=used-before-assignment - 'userDoesNotExist': user_does_not_exist, # pylint: disable=used-before-assignment - 'is_active': user_active # pylint: disable=used-before-assignment - }) + finally: + # Tabulate the action result of this email address + results.append({ + 'identifier': identifier, + 'error': error, # pylint: disable=used-before-assignment + 'userDoesNotExist': user_does_not_exist, # pylint: disable=used-before-assignment + 'is_active': user_active # pylint: disable=used-before-assignment + }) - response_payload = { - 'action': action, - 'results': results, - } - return JsonResponse(response_payload) + response_payload = { + 'action': action, + 'results': results, + } + return JsonResponse(response_payload) @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') @@ -1027,7 +1025,6 @@ class ModifyAccess(APIView): course = get_course_with_access( request.user, 'instructor', course_id, depth=None ) - serializer_data = AccessSerializer(data=request.data) if not serializer_data.is_valid(): return HttpResponseBadRequest(reason=serializer_data.errors) @@ -1276,66 +1273,77 @@ class ProblemResponseReportInitiate(DeveloperErrorViewMixin, APIView): ) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@require_course_permission(permissions.CAN_RESEARCH) -def get_problem_responses(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class GetProblemResponses(DeveloperErrorViewMixin, APIView): """ Initiate generation of a CSV file containing all student answers to a given problem. - - **Example requests** - - POST /courses/{course_id}/instructor/api/get_problem_responses { - "problem_location": "{usage_key1},{usage_key2},{usage_key3}"" - } - POST /courses/{course_id}/instructor/api/get_problem_responses { - "problem_location": "{usage_key}", - "problem_types_filter": "problem" - } - - **POST Parameters** - - A POST request can include the following parameters: - - * problem_location: A comma-separated list of usage keys for the blocks - to include in the report. If the location is a block that contains - other blocks, (such as the course, section, subsection, or unit blocks) - then all blocks under that block will be included in the report. - * problem_types_filter: Optional. A comma-separated list of block types - to include in the repot. If set, only blocks of the specified types will - be included in the report. - - To get data on all the poll and survey blocks in a course, you could - POST the usage key of the course for `problem_location`, and - "poll, survey" as the value for `problem_types_filter`. - - - **Example Response:** - If initiation is successful (or generation task is already running): - ```json - { - "status": "The problem responses report is being created. ...", - "task_id": "4e49522f-31d9-431a-9cff-dd2a2bf4c85a" - } - ``` - - Responds with BadRequest if any of the provided problem locations are faulty. """ - # A comma-separated list of problem locations - # The name of the POST parameter is `problem_location` (not pluralised) in - # order to preserve backwards compatibility with existing third-party - # scripts. - problem_locations = request.POST.get('problem_location', '').split(',') - # A comma-separated list of block types - problem_types_filter = request.POST.get('problem_types_filter') - return _get_problem_responses( - request, - course_id=course_id, - problem_locations=problem_locations, - problem_types_filter=problem_types_filter, - ) + + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_RESEARCH + + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id): + """ + Initiate generation of a CSV file containing all student answers + to a given problem. + + **Example requests** + + POST /courses/{course_id}/instructor/api/get_problem_responses { + "problem_location": "{usage_key1},{usage_key2},{usage_key3}"" + } + POST /courses/{course_id}/instructor/api/get_problem_responses { + "problem_location": "{usage_key}", + "problem_types_filter": "problem" + } + + **POST Parameters** + + A POST request can include the following parameters: + + * problem_location: A comma-separated list of usage keys for the blocks + to include in the report. If the location is a block that contains + other blocks, (such as the course, section, subsection, or unit blocks) + then all blocks under that block will be included in the report. + * problem_types_filter: Optional. A comma-separated list of block types + to include in the repot. If set, only blocks of the specified types will + be included in the report. + + To get data on all the poll and survey blocks in a course, you could + POST the usage key of the course for `problem_location`, and + "poll, survey" as the value for `problem_types_filter`. + + + **Example Response:** + If initiation is successful (or generation task is already running): + ```json + { + "status": "The problem responses report is being created. ...", + "task_id": "4e49522f-31d9-431a-9cff-dd2a2bf4c85a" + } + ``` + + Responds with BadRequest if any of the provided problem locations are faulty. + """ + # A comma-separated list of problem locations + # The name of the POST parameter is `problem_location` (not pluralised) in + # order to preserve backwards compatibility with existing third-party + # scripts. + + problem_locations = request.POST.get('problem_location', '').split(',') + # A comma-separated list of block types + problem_types_filter = request.POST.get('problem_types_filter') + + return _get_problem_responses( + request, + course_id=course_id, + problem_locations=problem_locations, + problem_types_filter=problem_types_filter, + ) @cache_control(no_cache=True, no_store=True, must_revalidate=True) @@ -1384,44 +1392,59 @@ class GetGradingConfig(APIView): return JsonResponse(response_payload) -@transaction.non_atomic_requests -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.VIEW_ISSUED_CERTIFICATES) -def get_issued_certificates(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class GetIssuedCertificates(APIView): """ Responds with JSON if CSV is not required. contains a list of issued certificates. - Arguments: - course_id - Returns: - {"certificates": [{course_id: xyz, mode: 'honor'}, ...]} - """ - course_key = CourseKey.from_string(course_id) - csv_required = request.GET.get('csv', 'false') + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.VIEW_ISSUED_CERTIFICATES - query_features = ['course_id', 'mode', 'total_issued_certificate', 'report_run_date'] - query_features_names = [ - ('course_id', _('CourseID')), - ('mode', _('Certificate Type')), - ('total_issued_certificate', _('Total Certificates Issued')), - ('report_run_date', _('Date Report Run')) - ] - certificates_data = instructor_analytics_basic.issued_certificates(course_key, query_features) - if csv_required.lower() == 'true': - __, data_rows = instructor_analytics_csvs.format_dictlist(certificates_data, query_features) - return instructor_analytics_csvs.create_csv_response( - 'issued_certificates.csv', - [col_header for __, col_header in query_features_names], - data_rows - ) - else: - response_payload = { - 'certificates': certificates_data, - 'queried_features': query_features, - 'feature_names': dict(query_features_names) - } - return JsonResponse(response_payload) + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id): + """ + Arguments: course_id + Returns: + {"certificates": [{course_id: xyz, mode: 'honor'}, ...]} + """ + return self.all_issued_certificates(request, course_id) + + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def get(self, request, course_id): + return self.all_issued_certificates(request, course_id) + + def all_issued_certificates(self, request, course_id): + """ + common method for both post and get. This method will return all issued certificates. + """ + course_key = CourseKey.from_string(course_id) + csv_required = request.GET.get('csv', 'false') + + query_features = ['course_id', 'mode', 'total_issued_certificate', 'report_run_date'] + query_features_names = [ + ('course_id', _('CourseID')), + ('mode', _('Certificate Type')), + ('total_issued_certificate', _('Total Certificates Issued')), + ('report_run_date', _('Date Report Run')) + ] + certificates_data = instructor_analytics_basic.issued_certificates(course_key, query_features) + if csv_required.lower() == 'true': + __, data_rows = instructor_analytics_csvs.format_dictlist(certificates_data, query_features) + return instructor_analytics_csvs.create_csv_response( + 'issued_certificates.csv', + [col_header for __, col_header in query_features_names], + data_rows + ) + else: + response_payload = { + 'certificates': certificates_data, + 'queried_features': query_features, + 'feature_names': dict(query_features_names) + } + return JsonResponse(response_payload) @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') @@ -1474,7 +1497,6 @@ class GetStudentsFeatures(DeveloperErrorViewMixin, APIView): 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', 'goals', 'enrollment_mode', 'last_login', 'date_joined', 'external_user_key' ] - keep_field_private(query_features, 'year_of_birth') # protected information # Provide human-friendly and translatable names for these features. These names # will be displayed in the table generated in data_download.js. It is not (yet) @@ -1486,8 +1508,7 @@ class GetStudentsFeatures(DeveloperErrorViewMixin, APIView): 'email': _('Email'), 'language': _('Language'), 'location': _('Location'), - # 'year_of_birth': _('Birth Year'), treated as privileged information as of TNL-10683, - # not to go in reports + 'year_of_birth': _('Birth Year'), 'gender': _('Gender'), 'level_of_education': _('Level of Education'), 'mailing_address': _('Mailing Address'), @@ -1498,6 +1519,10 @@ class GetStudentsFeatures(DeveloperErrorViewMixin, APIView): 'external_user_key': _('External User Key'), } + for field in settings.PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS: + keep_field_private(query_features, field) + query_features_names.pop(field, None) + if is_course_cohorted(course.id): # Translators: 'Cohort' refers to a group of students within a course. query_features.append('cohort') @@ -1573,6 +1598,46 @@ class GetStudentsWhoMayEnroll(DeveloperErrorViewMixin, APIView): raise MethodNotAllowed('GET') +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class GetInactiveEnrolledStudents(DeveloperErrorViewMixin, APIView): + """ + Initiate generation of a CSV file containing information about + students who are enrolled in a course but have inactive account. + """ + + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_RESEARCH + + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id): + """ + Initiate generation of a CSV file containing information about + students who are enrolled in a course but have inactive account. + + Responds with JSON + {"status": "... status message ..."} + """ + course_key = CourseKey.from_string(course_id) + query_features = ["email"] + report_type = _("inactive enrollment") + try: + task_api.submit_calculate_inactive_enrolled_students_csv( + request, course_key, query_features + ) + success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + except Exception as e: + raise self.api_error( + status.HTTP_400_BAD_REQUEST, str(e), "Requested task is already running" + ) + + return JsonResponse({"status": success_status}) + + def get(self, request, *args, **kwargs): + raise MethodNotAllowed("GET") + + def _cohorts_csv_validator(file_storage, file_to_validate): """ Verifies that the expected columns are present in the CSV used to add users to cohorts. @@ -1593,33 +1658,41 @@ def _cohorts_csv_validator(file_storage, file_to_validate): raise FileValidationException(msg) -@transaction.non_atomic_requests -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_POST -@require_course_permission(permissions.ASSIGN_TO_COHORTS) -@common_exceptions_400 -def add_users_to_cohorts(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class AddUsersToCohorts(DeveloperErrorViewMixin, APIView): """ View method that accepts an uploaded file (using key "uploaded-file") containing cohort assignments for users. This method spawns a celery task to do the assignments, and a CSV file with results is provided via data downloads. """ - course_key = CourseKey.from_string(course_id) - try: - __, filename = store_uploaded_file( - request, 'uploaded-file', ['.csv'], - course_and_time_based_filename_generator(course_key, "cohorts"), - max_file_size=2000000, # limit to 2 MB - validator=_cohorts_csv_validator - ) - # The task will assume the default file storage. - task_api.submit_cohort_students(request, course_key, filename) - except (FileValidationException, PermissionDenied) as err: - return JsonResponse({"error": str(err)}, status=400) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.ASSIGN_TO_COHORTS - return JsonResponse() + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id): + """ + This method spawns a celery task to do the assignments, and a CSV file with results + is provided via data downloads. + """ + + course_key = CourseKey.from_string(course_id) + + try: + __, filename = store_uploaded_file( + request, 'uploaded-file', ['.csv'], + course_and_time_based_filename_generator(course_key, "cohorts"), + max_file_size=2000000, # limit to 2 MB + validator=_cohorts_csv_validator + ) + # The task will assume the default file storage. + task_api.submit_cohort_students(request, course_key, filename) + except (FileValidationException, PermissionDenied, ValueError) as err: + return JsonResponse({"error": str(err)}, status=400) + + return JsonResponse() # The non-atomic decorator is required because this view calls a celery @@ -1667,40 +1740,54 @@ class CohortCSV(DeveloperErrorViewMixin, APIView): return Response(status=status.HTTP_204_NO_CONTENT) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.ENROLLMENT_REPORT) -@common_exceptions_400 -def get_course_survey_results(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class GetCourseSurveyResults(DeveloperErrorViewMixin, APIView): """ get the survey results report for the particular course. """ - course_key = CourseKey.from_string(course_id) - report_type = _('survey') - task_api.submit_course_survey_report(request, course_key) - success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.ENROLLMENT_REPORT - return JsonResponse({"status": success_status}) + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id): + """ + method to return survey results report for the particular course. + """ + course_key = CourseKey.from_string(course_id) + report_type = _('survey') + task_api.submit_course_survey_report(request, course_key) + success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + + return JsonResponse({"status": success_status}) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.EXAM_RESULTS) -@common_exceptions_400 -def get_proctored_exam_results(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class GetProctoredExamResults(DeveloperErrorViewMixin, APIView): """ - get the proctored exam resultsreport for the particular course. + get the proctored exam results report for the particular course. """ - course_key = CourseKey.from_string(course_id) - report_type = _('proctored exam results') - task_api.submit_proctored_exam_results_report(request, course_key) - success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) - return JsonResponse({"status": success_status}) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.EXAM_RESULTS + + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id): + """ + get the proctored exam results report for the particular course. + """ + try: + course_key = CourseKey.from_string(course_id) + report_type = _('proctored exam results') + task_api.submit_proctored_exam_results_report(request, course_key) + success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + return JsonResponse({"status": success_status}) + except (AlreadyRunningError, QueueConnectionError, AttributeError) as error: + # Return a 400 status code with the error message + return JsonResponse({"error": str(error)}, status=400) @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') @@ -2014,84 +2101,92 @@ def reset_student_attempts_for_entrance_exam(request, course_id): return JsonResponse(response_payload) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.OVERRIDE_GRADES) -@require_post_params(problem_to_reset="problem urlname to reset") -@common_exceptions_400 -def rescore_problem(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class RescoreProblem(DeveloperErrorViewMixin, APIView): """ Starts a background process a students attempts counter. Optionally deletes student state for a problem. Rescore for all students is limited to instructor access. - - Takes either of the following query parameters - - problem_to_reset is a urlname of a problem - - unique_student_identifier is an email or username - - all_students is a boolean - - all_students and unique_student_identifier cannot both be present. """ - course_id = CourseKey.from_string(course_id) - course = get_course_with_access(request.user, 'staff', course_id) - all_students = _get_boolean_param(request, 'all_students') + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.OVERRIDE_GRADES + serializer_class = ProblemResetSerializer - if all_students and not has_access(request.user, 'instructor', course): - return HttpResponseForbidden("Requires instructor access.") + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id): + """ + Takes either of the following query parameters + - problem_to_reset is a urlname of a problem + - unique_student_identifier is an email or username + - all_students is a boolean - only_if_higher = _get_boolean_param(request, 'only_if_higher') - problem_to_reset = strip_if_string(request.POST.get('problem_to_reset')) - student_identifier = request.POST.get('unique_student_identifier', None) - student = None - if student_identifier is not None: - student = get_student_from_identifier(student_identifier) + all_students and unique_student_identifier cannot both be present. + """ - if not (problem_to_reset and (all_students or student)): - return HttpResponseBadRequest("Missing query parameters.") + course_id = CourseKey.from_string(course_id) + course = get_course_with_access(request.user, 'staff', course_id) - if all_students and student: - return HttpResponseBadRequest( - "Cannot rescore with all_students and unique_student_identifier." - ) + serializer_data = self.serializer_class(data=request.data) - try: - module_state_key = UsageKey.from_string(problem_to_reset).map_into_course(course_id) - except InvalidKeyError: - return HttpResponseBadRequest("Unable to parse problem id") + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) - response_payload = {'problem_to_reset': problem_to_reset} + problem_to_reset = serializer_data.validated_data.get("problem_to_reset") + all_students = serializer_data.validated_data.get("all_students") + only_if_higher = serializer_data.validated_data.get("only_if_higher") - if student: - response_payload['student'] = student_identifier - try: - task_api.submit_rescore_problem_for_student( - request, - module_state_key, - student, - only_if_higher, + student = serializer_data.validated_data.get("unique_student_identifier") + student_identifier = request.data.get("unique_student_identifier") + + if all_students and not has_access(request.user, 'instructor', course): + return HttpResponseForbidden("Requires instructor access.") + + if not (problem_to_reset and (all_students or student)): + return HttpResponseBadRequest("Missing query parameters.") + + if all_students and student: + return HttpResponseBadRequest( + "Cannot rescore with all_students and unique_student_identifier." ) - except NotImplementedError as exc: - return HttpResponseBadRequest(str(exc)) - except ItemNotFoundError as exc: - return HttpResponseBadRequest(f"{module_state_key} not found") - elif all_students: try: - task_api.submit_rescore_problem_for_all_students( - request, - module_state_key, - only_if_higher, - ) - except NotImplementedError as exc: - return HttpResponseBadRequest(str(exc)) - except ItemNotFoundError as exc: - return HttpResponseBadRequest(f"{module_state_key} not found") - else: - return HttpResponseBadRequest() + module_state_key = UsageKey.from_string(problem_to_reset).map_into_course(course_id) + except InvalidKeyError: + return HttpResponseBadRequest("Unable to parse problem id") - response_payload['task'] = TASK_SUBMISSION_OK - return JsonResponse(response_payload) + response_payload = {'problem_to_reset': problem_to_reset} + + if student: + response_payload['student'] = student_identifier + try: + task_api.submit_rescore_problem_for_student( + request, + module_state_key, + student, + only_if_higher, + ) + except NotImplementedError as exc: + return HttpResponseBadRequest(str(exc)) + except ItemNotFoundError as exc: + return HttpResponseBadRequest(f"{module_state_key} not found") + + elif all_students: + try: + task_api.submit_rescore_problem_for_all_students( + request, + module_state_key, + only_if_higher, + ) + except NotImplementedError as exc: + return HttpResponseBadRequest(str(exc)) + except ItemNotFoundError as exc: + return HttpResponseBadRequest(f"{module_state_key} not found") + else: + return HttpResponseBadRequest() + + response_payload['task'] = TASK_SUBMISSION_OK + return JsonResponse(response_payload) @transaction.non_atomic_requests @@ -2154,84 +2249,102 @@ def override_problem_score(request, course_id): # lint-amnesty, pylint: disable return JsonResponse(response_payload) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.RESCORE_EXAMS) -@common_exceptions_400 -def rescore_entrance_exam(request, course_id): +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class RescoreEntranceExamView(DeveloperErrorViewMixin, APIView): """ - Starts a background process a students attempts counter for entrance exam. + Starts a background process for a student's attempts counter for entrance exam. Optionally deletes student state for a problem. Limited to instructor access. - Takes either of the following query parameters - - unique_student_identifier is an email or username - - all_students is a boolean + Takes either of the following parameters: + - unique_student_identifier: an email or username + - all_students: a boolean all_students and unique_student_identifier cannot both be present. """ - course_id = CourseKey.from_string(course_id) - course = get_course_with_access( - request.user, 'staff', course_id, depth=None - ) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.RESCORE_EXAMS + serializer_class = RescoreEntranceExamSerializer - student_identifier = request.POST.get('unique_student_identifier', None) - only_if_higher = request.POST.get('only_if_higher', None) - student = None - if student_identifier is not None: - student = get_student_from_identifier(student_identifier) + @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True)) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Initiates a Celery task to rescore the entrance exam for a student or all students. + """ + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data - all_students = _get_boolean_param(request, 'all_students') - - if not course.entrance_exam_id: - return HttpResponseBadRequest( - _("Course has no entrance exam section.") + course_id = CourseKey.from_string(course_id) + course = get_course_with_access( + request.user, 'staff', course_id, depth=None ) - if all_students and student: - return HttpResponseBadRequest( - _("Cannot rescore with all_students and unique_student_identifier.") + if not course.entrance_exam_id: + return Response( + {"error": _("Course has no entrance exam section.")}, + status=status.HTTP_400_BAD_REQUEST + ) + + student_identifier = data.get('unique_student_identifier') + only_if_higher = data.get('only_if_higher') + all_students = data.get('all_students', False) + student = None + + if student_identifier: + student = get_student_from_identifier(student_identifier) + + if all_students and student: + return Response( + {"error": _("Cannot rescore with all_students and unique_student_identifier.")}, + status=status.HTTP_400_BAD_REQUEST + ) + + try: + entrance_exam_key = UsageKey.from_string(course.entrance_exam_id).map_into_course(course_id) + except InvalidKeyError: + return Response( + {"error": _("Course has no valid entrance exam section.")}, + status=status.HTTP_400_BAD_REQUEST + ) + + response_payload = { + 'student': student_identifier if student else _("All Students"), + 'task': TASK_SUBMISSION_OK + } + + task_api.submit_rescore_entrance_exam_for_student( + request, entrance_exam_key, student, only_if_higher, ) - try: - entrance_exam_key = UsageKey.from_string(course.entrance_exam_id).map_into_course(course_id) - except InvalidKeyError: - return HttpResponseBadRequest(_("Course has no valid entrance exam section.")) - - response_payload = {} - if student: - response_payload['student'] = student_identifier - else: - response_payload['student'] = _("All Students") - - task_api.submit_rescore_entrance_exam_for_student( - request, entrance_exam_key, student, only_if_higher, - ) - response_payload['task'] = TASK_SUBMISSION_OK - return JsonResponse(response_payload) + return Response(response_payload) -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.EMAIL) -def list_background_email_tasks(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ListBackgroundEmailTasks(DeveloperErrorViewMixin, APIView): """ List background email tasks. """ - course_id = CourseKey.from_string(course_id) - task_type = InstructorTaskTypes.BULK_COURSE_EMAIL - # Specifying for the history of a single task type - tasks = task_api.get_instructor_task_history( - course_id, - task_type=task_type - ) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.EMAIL - response_payload = { - 'tasks': list(map(extract_task_features, tasks)), - } - return JsonResponse(response_payload) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + List background email tasks. + """ + course_id = CourseKey.from_string(course_id) + task_type = InstructorTaskTypes.BULK_COURSE_EMAIL + # Specifying for the history of a single task type + tasks = task_api.get_instructor_task_history( + course_id, + task_type=task_type + ) + + response_payload = { + 'tasks': list(map(extract_task_features, tasks)), + } + return JsonResponse(response_payload) @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') @@ -2658,110 +2771,147 @@ def list_financial_report_downloads(_request, course_id): return JsonResponse(response_payload) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_RESEARCH) -@common_exceptions_400 -def export_ora2_data(request, course_id): +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class ExportOra2DataView(DeveloperErrorViewMixin, APIView): """ Pushes a Celery task which will aggregate ora2 responses for a course into a .csv """ - course_key = CourseKey.from_string(course_id) - report_type = _('ORA data') - task_api.submit_export_ora2_data(request, course_key) - success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_RESEARCH - return JsonResponse({"status": success_status}) + @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True)) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Initiates a task to export Open Response Assessment (ORA) data for a course. + + Args: + request: The HTTP request object + course_id: The ID of the course for which to export ORA data + + Returns: + Response: A JSON response containing the status message indicating the task has been initiated + """ + course_key = CourseKey.from_string(course_id) + report_type = _('ORA data') + + try: + task_api.submit_export_ora2_data(request, course_key) + success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + return Response({"status": success_status}) + except (AlreadyRunningError, QueueConnectionError, AttributeError) as err: + return JsonResponse({"error": str(err)}, status=400) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_RESEARCH) -@common_exceptions_400 -def export_ora2_summary(request, course_id): +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class ExportOra2SummaryView(DeveloperErrorViewMixin, APIView): """ - Pushes a Celery task which will aggregate a summary students' progress in ora2 tasks for a course into a .csv + Pushes a Celery task which will aggregate a summary of students' progress in ora2 tasks for a course into a .csv """ - course_key = CourseKey.from_string(course_id) - report_type = _('ORA summary') - task_api.submit_export_ora2_summary(request, course_key) - success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_RESEARCH - return JsonResponse({"status": success_status}) + @method_decorator(ensure_csrf_cookie) + @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True)) + def post(self, request, course_id): + """ + Initiates a Celery task to generate an ORA summary report for the specified course. + + Args: + request: The HTTP request object + course_id: The string representation of the course key + + Returns: + Response: A JSON response with a status message indicating the report generation has started + """ + course_key = CourseKey.from_string(course_id) + report_type = _('ORA summary') + try: + task_api.submit_export_ora2_summary(request, course_key) + success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + return Response({"status": success_status}) + except (AlreadyRunningError, QueueConnectionError, AttributeError) as err: + return JsonResponse({"error": str(err)}, status=400) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_RESEARCH) -@common_exceptions_400 -def export_ora2_submission_files(request, course_id): +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class ExportOra2SubmissionFilesView(DeveloperErrorViewMixin, APIView): """ Pushes a Celery task which will download and compress all submission files (texts, attachments) into a zip archive. """ - course_key = CourseKey.from_string(course_id) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_RESEARCH - task_api.submit_export_ora2_submission_files(request, course_key) - - return JsonResponse({ - "status": _( - "Attachments archive is being created." - ) - }) + @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True)) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Initiates a task to export all ORA2 submission files for a course. + Returns a JSON response indicating the export task has been started. + """ + course_key = CourseKey.from_string(course_id) + try: + task_api.submit_export_ora2_submission_files(request, course_key) + return Response({ + "status": _("Attachments archive is being created.") + }) + except (AlreadyRunningError, QueueConnectionError, AttributeError) as err: + return JsonResponse({"error": str(err)}, status=400) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_RESEARCH) -@common_exceptions_400 -def calculate_grades_csv(request, course_id): +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class CalculateGradesCsvView(DeveloperErrorViewMixin, APIView): """ + Initiates a Celery task to calculate grades CSV. AlreadyRunningError is raised if the course's grades are already being updated. """ - report_type = _('grade') - course_key = CourseKey.from_string(course_id) - task_api.submit_calculate_grades_csv(request, course_key) - success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_RESEARCH - return JsonResponse({"status": success_status}) + @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True)) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Initiates a Celery task to calculate grades CSV. + """ + report_type = _('grade') + course_key = CourseKey.from_string(course_id) + task_api.submit_calculate_grades_csv(request, course_key) + success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + + return Response({"status": success_status}) -@transaction.non_atomic_requests -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_RESEARCH) -@common_exceptions_400 -def problem_grade_report(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class ProblemGradeReport(DeveloperErrorViewMixin, APIView): """ - Request a CSV showing students' grades for all problems in the - course. - - AlreadyRunningError is raised if the course's grades are already being - updated. + Request a CSV showing students' grades for all problems in the course. """ - course_key = CourseKey.from_string(course_id) - report_type = _('problem grade') - task_api.submit_problem_grade_report(request, course_key) - success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_RESEARCH - return JsonResponse({"status": success_status}) + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id): + """ + Request a CSV showing students' grades for all problems in the + course. + + AlreadyRunningError is raised if the course's grades are already being + updated. + """ + course_key = CourseKey.from_string(course_id) + report_type = _('problem grade') + task_api.submit_problem_grade_report(request, course_key) + success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + + return JsonResponse({"status": success_status}) -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.VIEW_FORUM_MEMBERS) -@require_post_params('rolename') -def list_forum_members(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ListForumMembers(APIView): """ Lists forum members of a certain rolename. Limited to staff access. @@ -2770,61 +2920,48 @@ def list_forum_members(request, course_id): Staff forum admins can access all roles EXCEPT for FORUM_ROLE_ADMINISTRATOR which is limited to instructors. - Takes query parameter `rolename`. """ - course_id = CourseKey.from_string(course_id) - course = get_course_by_id(course_id) - has_instructor_access = has_access(request.user, 'instructor', course) - has_forum_admin = has_forum_access( - request.user, course_id, FORUM_ROLE_ADMINISTRATOR + permission_classes = ( + IsAuthenticated, permissions.InstructorPermission, permissions.ForumAdminRequiresInstructorAccess ) + permission_name = permissions.VIEW_FORUM_MEMBERS + serializer_class = ForumRoleNameSerializer - rolename = request.POST.get('rolename') + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Handle the POST request to list forum members with a certain role name for the given course. - # default roles require either (staff & forum admin) or (instructor) - if not (has_forum_admin or has_instructor_access): - return HttpResponseBadRequest( - "Operation requires staff & forum admin or instructor access" + Args: + request (HttpRequest): The request object containing the data sent by the client. + course_id (int): The ID of the course for which the role is being assigned or managed. + + Returns: + Response: The Json constians lists of members. + + Raises: + ValidationError: If the provided `rolename` is not valid according to the serializer. + """ + course_id = CourseKey.from_string(course_id) + course_discussion_settings = CourseDiscussionSettings.get(course_id) + + role_serializer = ForumRoleNameSerializer( + data=request.data, + context={ + 'course_discussion_settings': course_discussion_settings, + 'course_id': course_id + } ) - # EXCEPT FORUM_ROLE_ADMINISTRATOR requires (instructor) - if rolename == FORUM_ROLE_ADMINISTRATOR and not has_instructor_access: - return HttpResponseBadRequest("Operation requires instructor access.") + role_serializer.is_valid(raise_exception=True) + rolename = role_serializer.data['rolename'] - # filter out unsupported for roles - if rolename not in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_GROUP_MODERATOR, - FORUM_ROLE_COMMUNITY_TA]: - return HttpResponseBadRequest(strip_tags( - f"Unrecognized rolename '{rolename}'." - )) - - try: - role = Role.objects.get(name=rolename, course_id=course_id) - users = role.users.all().order_by('username') - except Role.DoesNotExist: - users = [] - - course_discussion_settings = CourseDiscussionSettings.get(course_id) - - def extract_user_info(user): - """ Convert user to dict for json rendering. """ - group_id = get_group_id_for_user(user, course_discussion_settings) - group_name = get_group_name(group_id, course_discussion_settings) - - return { - 'username': user.username, - 'email': user.email, - 'first_name': user.first_name, - 'last_name': user.last_name, - 'group_name': group_name, + response_payload = { + 'course_id': str(course_id), + rolename: role_serializer.data.get('users'), + 'division_scheme': course_discussion_settings.division_scheme, } - - response_payload = { - 'course_id': str(course_id), - rolename: list(map(extract_user_info, users)), - 'division_scheme': course_discussion_settings.division_scheme, - } - return JsonResponse(response_payload) + return JsonResponse(response_payload) @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') @@ -2919,71 +3056,71 @@ class SendEmail(DeveloperErrorViewMixin, APIView): return JsonResponse(response_payload) -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.EDIT_FORUM_ROLES) -@require_post_params( - unique_student_identifier="email or username of user to change access", - rolename="the forum role", - action="'allow' or 'revoke'", -) -@common_exceptions_400 -def update_forum_role_membership(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class UpdateForumRoleMembership(APIView): """ - Modify user's forum role. + Modify a user's forum role in a course. - The requesting user must be at least staff. - Staff forum admins can access all roles EXCEPT for FORUM_ROLE_ADMINISTRATOR - which is limited to instructors. - No one can revoke an instructors FORUM_ROLE_ADMINISTRATOR status. + Permissions: + - Must be authenticated. + - Must be instructor or (staff + forum admin). + - Only instructors can grant FORUM_ROLE_ADMINISTRATOR. - Query parameters: - - `email` is the target users email - - `rolename` is one of [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_GROUP_MODERATOR, - FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA] - - `action` is one of ['allow', 'revoke'] - """ - course_id = CourseKey.from_string(course_id) - course = get_course_by_id(course_id) - has_instructor_access = has_access(request.user, 'instructor', course) - has_forum_admin = has_forum_access( - request.user, course_id, FORUM_ROLE_ADMINISTRATOR - ) - - unique_student_identifier = request.POST.get('unique_student_identifier') - rolename = request.POST.get('rolename') - action = request.POST.get('action') - - # default roles require either (staff & forum admin) or (instructor) - if not (has_forum_admin or has_instructor_access): - return HttpResponseBadRequest( - "Operation requires staff & forum admin or instructor access" - ) - - # EXCEPT FORUM_ROLE_ADMINISTRATOR requires (instructor) - if rolename == FORUM_ROLE_ADMINISTRATOR and not has_instructor_access: - return HttpResponseBadRequest("Operation requires instructor access.") - - if rolename not in [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_GROUP_MODERATOR, - FORUM_ROLE_COMMUNITY_TA]: - return HttpResponseBadRequest(strip_tags( - f"Unrecognized rolename '{rolename}'." - )) - - user = get_student_from_identifier(unique_student_identifier) - if action == 'allow' and not is_user_enrolled_in_course(user, course_id): - CourseEnrollment.enroll(user, course_id) - try: - update_forum_role(course_id, user, rolename, action) - except Role.DoesNotExist: - return HttpResponseBadRequest("Role does not exist.") - - response_payload = { - 'course_id': str(course_id), - 'action': action, + Request (POST body): + { + "unique_student_identifier": "user@example.com", + "rolename": "FORUM_ROLE_MODERATOR", + "action": "allow" or "revoke" } - return JsonResponse(response_payload) + + """ + permission_classes = ( + IsAuthenticated, + permissions.InstructorPermission, + permissions.ForumAdminRequiresInstructorAccess + ) + permission_name = permissions.EDIT_FORUM_ROLES + serializer_class = UpdateForumRoleMembershipSerializer + + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Handles role modification requests for a forum user. + + Query parameters: + - `email` is the target users email + - `rolename` is one of [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_GROUP_MODERATOR, + FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA] + - `action` is one of ['allow', 'revoke'] + """ + course_id = CourseKey.from_string(course_id) + serializer_data = UpdateForumRoleMembershipSerializer(data=request.data) + + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) + + user = serializer_data.validated_data.get('unique_student_identifier') + if not user: + return JsonResponse({'error': 'User does not exist.'}, status=400) + + rolename = serializer_data.data['rolename'] + action = serializer_data.data['action'] + + if action == 'allow' and not is_user_enrolled_in_course(user, course_id): + CourseEnrollment.enroll(user, course_id) + + try: + update_forum_role(course_id, user, rolename, action) + except Role.DoesNotExist: + return HttpResponseBadRequest("Role does not exist.") + + return Response( + { + "course_id": str(course_id), + "action": action, + }, + status=status.HTTP_200_OK, + ) @require_POST @@ -3033,24 +3170,30 @@ class ChangeDueDate(APIView): """ serializer_data = self.serializer_class(data=request.data) if not serializer_data.is_valid(): - return HttpResponseBadRequest(reason=serializer_data.errors) + return JsonResponseBadRequest({'error': _('All fields must be filled out')}) student = serializer_data.validated_data.get('student') if not student: response_payload = { - 'error': f'Could not find student matching identifier: {request.data.get("student")}' + 'error': _( + 'Could not find student matching identifier: {student}' + ).format(student=request.data.get("student")) } - return JsonResponse(response_payload) + return JsonResponse(response_payload, status=status.HTTP_404_NOT_FOUND) + + due_datetime = serializer_data.validated_data.get('due_datetime') + try: + due_date = parse_datetime(due_datetime) + except DashboardError: + return JsonResponseBadRequest({'error': _('The extension due date and time format is incorrect')}) course = get_course_by_id(CourseKey.from_string(course_id)) - unit = find_unit(course, serializer_data.validated_data.get('url')) - due_date = parse_datetime(serializer_data.validated_data.get('due_datetime')) reason = strip_tags(serializer_data.validated_data.get('reason', '')) try: set_due_date_extension(course, unit, student, due_date, request.user, reason=reason) except Exception as error: # pylint: disable=broad-except - return JsonResponse({'error': str(error)}, status=400) + return JsonResponseBadRequest({'error': str(error)}) return JsonResponse(_( 'Successfully changed due date for student {0} for {1} ' @@ -3113,19 +3256,35 @@ class ResetDueDate(APIView): return JsonResponse({'error': str(error)}, status=400) -@handle_dashboard_error -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.GIVE_STUDENT_EXTENSION) -@require_post_params('url') -def show_unit_extensions(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ShowUnitExtensionsView(APIView): """ - Shows all of the students which have due date extensions for the given unit. + API view to retrieve a list of students who have due date extensions + for a specific unit in a course. """ - course = get_course_by_id(CourseKey.from_string(course_id)) - unit = find_unit(course, request.POST.get('url')) - return JsonResponse(dump_block_extensions(course, unit)) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + serializer_class = ShowUnitExtensionsSerializer + permission_name = permissions.GIVE_STUDENT_EXTENSION + + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Shows all of the students which have due date extensions for the given unit. + """ + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + url = serializer.validated_data['url'] + course_key = CourseKey.from_string(course_id) + course = get_course_by_id(course_key) + + try: + unit = find_unit(course, url) + data = dump_block_extensions(course, unit) + return Response(data) + + except DashboardError as error: + return error.response() @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') @@ -3293,84 +3452,130 @@ class StartCertificateGeneration(DeveloperErrorViewMixin, APIView): return JsonResponse(response_payload) -@transaction.non_atomic_requests -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.START_CERTIFICATE_REGENERATION) -@require_POST -@common_exceptions_400 -def start_certificate_regeneration(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class StartCertificateRegeneration(DeveloperErrorViewMixin, APIView): """ Start regenerating certificates for students whose certificate statuses lie with in 'certificate_statuses' entry in POST data. """ - course_key = CourseKey.from_string(course_id) - certificates_statuses = request.POST.getlist('certificate_statuses', []) - if not certificates_statuses: - return JsonResponse( - {'message': _('Please select one or more certificate statuses that require certificate regeneration.')}, - status=400 - ) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.START_CERTIFICATE_REGENERATION + serializer_class = CertificateStatusesSerializer + http_method_names = ['post'] - # Check if the selected statuses are allowed - allowed_statuses = [ - CertificateStatuses.downloadable, - CertificateStatuses.error, - CertificateStatuses.notpassing, - CertificateStatuses.audit_passing, - CertificateStatuses.audit_notpassing, - ] - if not set(certificates_statuses).issubset(allowed_statuses): - return JsonResponse( - {'message': _('Please select certificate statuses from the list only.')}, - status=400 - ) + @method_decorator(transaction.non_atomic_requests, name='dispatch') + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + certificate_statuses 'certificate_statuses' in POST data. + """ + course_key = CourseKey.from_string(course_id) + serializer = self.serializer_class(data=request.data) - task_api.regenerate_certificates(request, course_key, certificates_statuses) - response_payload = { - 'message': _('Certificate regeneration task has been started. ' - 'You can view the status of the generation task in the "Pending Tasks" section.'), - 'success': True - } - return JsonResponse(response_payload) + if not serializer.is_valid(): + return JsonResponse( + {'message': _('Please select certificate statuses from the list only.')}, + status=400 + ) + + certificates_statuses = serializer.validated_data['certificate_statuses'] + task_api.regenerate_certificates(request, course_key, certificates_statuses) + response_payload = { + 'message': _('Certificate regeneration task has been started. ' + 'You can view the status of the generation task in the "Pending Tasks" section.'), + 'success': True + } + return JsonResponse(response_payload) -@transaction.non_atomic_requests -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CERTIFICATE_EXCEPTION_VIEW) -@require_http_methods(['POST', 'DELETE']) -def certificate_exception_view(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class CertificateExceptionView(DeveloperErrorViewMixin, APIView): """ Add/Remove students to/from the certificate allowlist. - - :param request: HttpRequest object - :param course_id: course identifier of the course for whom to add/remove certificates exception. - :return: JsonResponse object with success/error message or certificate exception data. """ - course_key = CourseKey.from_string(course_id) - # Validate request data and return error response in case of invalid data - try: - certificate_exception, student = parse_request_data_and_get_user(request) - except ValueError as error: - return JsonResponse({'success': False, 'message': str(error)}, status=400) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CERTIFICATE_EXCEPTION_VIEW + serializer_class = CertificateSerializer + http_method_names = ['post', 'delete'] - # Add new Certificate Exception for the student passed in request data - if request.method == 'POST': - try: - exception = add_certificate_exception(course_key, student, certificate_exception) - except ValueError as error: - return JsonResponse({'success': False, 'message': str(error)}, status=400) - return JsonResponse(exception) + @method_decorator(transaction.non_atomic_requests, name='dispatch') + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Add certificate exception for a student. + """ + return self._handle_certificate_exception(request, course_id, action="post") - # Remove Certificate Exception for the student passed in request data - elif request.method == 'DELETE': + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def delete(self, request, course_id): + """ + Remove certificate exception for a student. + """ + return self._handle_certificate_exception(request, course_id, action="delete") + + def _handle_certificate_exception(self, request, course_id, action): + """ + Handles adding or removing certificate exceptions. + """ + course_key = CourseKey.from_string(course_id) try: - remove_certificate_exception(course_key, student) + data = request.data + except Exception: # pylint: disable=broad-except + return JsonResponse( + { + 'success': False, + 'message': + _('The record is not in the correct format. Please add a valid username or email address.')}, + status=400 + ) + + # Extract and validate the student information + student, error_response = self._get_and_validate_user(data) + + if error_response: + return error_response + + try: + if action == "post": + exception = add_certificate_exception(course_key, student, data) + return JsonResponse(exception) + elif action == "delete": + remove_certificate_exception(course_key, student) + return JsonResponse({}, status=204) except ValueError as error: return JsonResponse({'success': False, 'message': str(error)}, status=400) - return JsonResponse({}, status=204) + def _get_and_validate_user(self, raw_data): + """ + Extracts the user data from the request and validates the student. + """ + # This is only happening in case of delete. + # because content-type is coming as x-www-form-urlencoded from front-end. + if isinstance(raw_data, QueryDict): + raw_data = list(raw_data.keys())[0] + try: + raw_data = json.loads(raw_data) + except Exception as error: # pylint: disable=broad-except + return None, JsonResponse({'success': False, 'message': str(error)}, status=400) + + try: + user_data = raw_data.get('user_name', '') or raw_data.get('user_email', '') + except ValueError as error: + return None, JsonResponse({'success': False, 'message': str(error)}, status=400) + + serializer_data = self.serializer_class(data={'user': user_data}) + if not serializer_data.is_valid(): + return None, JsonResponse({'success': False, 'message': serializer_data.errors}, status=400) + + student = serializer_data.validated_data.get('user') + if not student: + response_payload = f'{user_data} does not exist in the LMS. Please check your spelling and retry.' + return None, JsonResponse({'success': False, 'message': response_payload}, status=400) + + return student, None def add_certificate_exception(course_key, student, certificate_exception): @@ -3497,153 +3702,162 @@ def get_student(username_or_email): return student -@transaction.non_atomic_requests -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.GENERATE_CERTIFICATE_EXCEPTIONS) -@require_POST -@common_exceptions_400 -def generate_certificate_exceptions(request, course_id, generate_for=None): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +@method_decorator(transaction.non_atomic_requests, name='dispatch') +class GenerateCertificateExceptions(DeveloperErrorViewMixin, APIView): """ Generate Certificate for students on the allowlist. - - :param request: HttpRequest object, - :param course_id: course identifier of the course for whom to generate certificates - :param generate_for: string to identify whether to generate certificates for 'all' or 'new' - additions to the allowlist - :return: JsonResponse object containing success/failure message and certificate exception data """ - course_key = CourseKey.from_string(course_id) - if generate_for == 'all': - # Generate Certificates for all allowlisted students - students = 'all_allowlisted' + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.GENERATE_CERTIFICATE_EXCEPTIONS - elif generate_for == 'new': - students = 'allowlisted_not_generated' + @method_decorator(transaction.non_atomic_requests) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id, generate_for=None): + """ + :param request: HttpRequest object, + :param course_id: course identifier of the course for whom to generate certificates + :param generate_for: string to identify whether to generate certificates for 'all' or 'new' + additions to the allowlist + :return: JsonResponse object containing success/failure message and certificate exception data + """ + course_key = CourseKey.from_string(course_id) - else: - # Invalid data, generate_for must be present for all certificate exceptions - return JsonResponse( - { - 'success': False, - 'message': _('Invalid data, generate_for must be "new" or "all".'), - }, - status=400 - ) + if generate_for == 'all': + # Generate Certificates for all allowlisted students + students = 'all_allowlisted' - task_api.generate_certificates_for_students(request, course_key, student_set=students) - response_payload = { - 'success': True, - 'message': _('Certificate generation started for students on the allowlist.'), - } + elif generate_for == 'new': + students = 'allowlisted_not_generated' - return JsonResponse(response_payload) + else: + # Invalid data, generate_for must be present for all certificate exceptions + return JsonResponse( + { + 'success': False, + 'message': _('Invalid data, generate_for must be "new" or "all".'), + }, + status=400 + ) + + task_api.generate_certificates_for_students(request, course_key, student_set=students) + response_payload = { + 'success': True, + 'message': _('Certificate generation started for students on the allowlist.'), + } + + return JsonResponse(response_payload) -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.GENERATE_BULK_CERTIFICATE_EXCEPTIONS) -@require_POST -def generate_bulk_certificate_exceptions(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class GenerateBulkCertificateExceptions(APIView): """ Adds students to the certificate allowlist using data from the uploaded CSV file. - - Arguments: - request (WSGIRequest): Django HTTP request object. - course_id (string): Course-Run key - Returns: - dict: - { - general_errors: [errors related to csv file e.g. csv uploading, csv attachment, content reading, etc. ], - row_errors: { - data_format_error: [users/data in csv file that are not well formatted], - user_not_exist: [users that cannot be found in the LMS], - user_already_allowlisted: [users that already appear on the allowlist of this course-run], - user_not_enrolled: [users that are not currently enrolled in this course-run], - user_on_certificate_invalidation_list: [users that have an active certificate invalidation in this - course-run] - }, - success: [list of users sucessfully added to the certificate allowlist] - } """ - user_index = 0 - notes_index = 1 - row_errors_key = [ - 'data_format_error', - 'user_not_exist', - 'user_already_allowlisted', - 'user_not_enrolled', - 'user_on_certificate_invalidation_list' - ] - course_key = CourseKey.from_string(course_id) - students, general_errors, success = [], [], [] - row_errors = {key: [] for key in row_errors_key} - def build_row_errors(key, _user, row_count): + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.GENERATE_BULK_CERTIFICATE_EXCEPTIONS + + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): """ - inner method to build dict of csv data as row errors. + Arguments: + request (WSGIRequest): Django HTTP request object. + course_id (string): Course-Run key + Returns: + dict: + { + general_errors: [errors related to csv file e.g. csv uploading, csv attachment, content reading, etc. ], + row_errors: { + data_format_error: [users/data in csv file that are not well formatted], + user_not_exist: [users that cannot be found in the LMS], + user_already_allowlisted: [users that already appear on the allowlist of this course-run], + user_not_enrolled: [users that are not currently enrolled in this course-run], + user_on_certificate_invalidation_list: [users that have an active certificate invalidation in this + course-run] + }, + success: [list of users sucessfully added to the certificate allowlist] + } """ - row_errors[key].append(_('user "{user}" in row# {row}').format(user=_user, row=row_count)) + user_index = 0 + notes_index = 1 + row_errors_key = [ + 'data_format_error', + 'user_not_exist', + 'user_already_allowlisted', + 'user_not_enrolled', + 'user_on_certificate_invalidation_list' + ] + course_key = CourseKey.from_string(course_id) + students, general_errors, success = [], [], [] + row_errors = {key: [] for key in row_errors_key} - if 'students_list' in request.FILES: - try: - upload_file = request.FILES.get('students_list') - if upload_file.name.endswith('.csv'): - students = list(csv.reader(upload_file.read().decode('utf-8-sig').splitlines())) - else: - general_errors.append(_('Make sure that the file you upload is in CSV format with no ' - 'extraneous characters or rows.')) - except Exception: # pylint: disable=broad-except - general_errors.append(_('Could not read uploaded file.')) - finally: - upload_file.close() + def build_row_errors(key, _user, row_count): + """ + inner method to build dict of csv data as row errors. + """ + row_errors[key].append(_('user "{user}" in row# {row}').format(user=_user, row=row_count)) - row_num = 0 - for student in students: - row_num += 1 - # verify that we have exactly two column in every row either email or username and notes but allow for - # blank lines - if len(student) != 2: - if student: - build_row_errors('data_format_error', student[user_index], row_num) - log.info(f'Invalid data/format in csv row# {row_num}') - continue - - user = student[user_index] + if 'students_list' in request.FILES: try: - user = get_user_by_username_or_email(user) - except ObjectDoesNotExist: - build_row_errors('user_not_exist', user, row_num) - log.info(f'Student {user} does not exist') - else: - # make sure learner doesn't have an active certificate invalidation - if certs_api.is_certificate_invalidated(user, course_key): - build_row_errors('user_on_certificate_invalidation_list', user, row_num) - log.warning(f'Student {user.id} is blocked from receiving a Certificate in Course {course_key}') - # make sure learner isn't already on the allowlist - elif certs_api.is_on_allowlist(user, course_key): - build_row_errors('user_already_allowlisted', user, row_num) - log.warning(f'Student {user.id} already appears on the allowlist in Course {course_key}.') - # make sure user is enrolled in course - elif not is_user_enrolled_in_course(user, course_key): - build_row_errors('user_not_enrolled', user, row_num) - log.warning(f'Student {user.id} is not enrolled in Course {course_key}') + upload_file = request.FILES.get('students_list') + if upload_file.name.endswith('.csv'): + students = list(csv.reader(upload_file.read().decode('utf-8-sig').splitlines())) else: - certs_api.create_or_update_certificate_allowlist_entry( - user, - course_key, - notes=student[notes_index] - ) - success.append(_('user "{username}" in row# {row}').format(username=user.username, row=row_num)) - else: - general_errors.append(_('File is not attached.')) + general_errors.append(_('Make sure that the file you upload is in CSV format with no ' + 'extraneous characters or rows.')) + except Exception: # pylint: disable=broad-except + general_errors.append(_('Could not read uploaded file.')) + finally: + upload_file.close() - results = { - 'general_errors': general_errors, - 'row_errors': row_errors, - 'success': success - } - return JsonResponse(results) + row_num = 0 + for student in students: + row_num += 1 + # verify that we have exactly two column in every row either email or username and notes but allow for + # blank lines + if len(student) != 2: + if student: + build_row_errors('data_format_error', student[user_index], row_num) + log.info(f'Invalid data/format in csv row# {row_num}') + continue + + user = student[user_index] + try: + user = get_user_by_username_or_email(user) + except ObjectDoesNotExist: + build_row_errors('user_not_exist', user, row_num) + log.info(f'Student {user} does not exist') + else: + # make sure learner doesn't have an active certificate invalidation + if certs_api.is_certificate_invalidated(user, course_key): + build_row_errors('user_on_certificate_invalidation_list', user, row_num) + log.warning(f'Student {user.id} is blocked from receiving a Certificate in Course {course_key}') + # make sure learner isn't already on the allowlist + elif certs_api.is_on_allowlist(user, course_key): + build_row_errors('user_already_allowlisted', user, row_num) + log.warning(f'Student {user.id} already appears on the allowlist in Course {course_key}.') + # make sure user is enrolled in course + elif not is_user_enrolled_in_course(user, course_key): + build_row_errors('user_not_enrolled', user, row_num) + log.warning(f'Student {user.id} is not enrolled in Course {course_key}') + else: + certs_api.create_or_update_certificate_allowlist_entry( + user, + course_key, + notes=student[notes_index] + ) + success.append(_('user "{username}" in row# {row}').format(username=user.username, row=row_num)) + else: + general_errors.append(_('File is not attached.')) + + results = { + 'general_errors': general_errors, + 'row_errors': row_errors, + 'success': success + } + return JsonResponse(results) @method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 491de58ce3..f47fc2d299 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -25,51 +25,53 @@ urlpatterns = [ path('register_and_enroll_students', api.RegisterAndEnrollStudents.as_view(), name='register_and_enroll_students'), path('list_course_role_members', api.ListCourseRoleMembersView.as_view(), name='list_course_role_members'), path('modify_access', api.ModifyAccess.as_view(), name='modify_access'), - path('bulk_beta_modify_access', api.bulk_beta_modify_access, name='bulk_beta_modify_access'), - path('get_problem_responses', api.get_problem_responses, name='get_problem_responses'), + path('bulk_beta_modify_access', api.BulkBetaModifyAccess.as_view(), name='bulk_beta_modify_access'), + path('get_problem_responses', api.GetProblemResponses.as_view(), name='get_problem_responses'), + path('get_issued_certificates/', api.GetIssuedCertificates.as_view(), name='get_issued_certificates'), re_path(r'^get_students_features(?P/csv)?$', api.GetStudentsFeatures.as_view(), name='get_students_features'), path('get_grading_config', api.GetGradingConfig.as_view(), name='get_grading_config'), - path('get_issued_certificates/', api.get_issued_certificates, name='get_issued_certificates'), path('get_students_who_may_enroll', api.GetStudentsWhoMayEnroll.as_view(), name='get_students_who_may_enroll'), + path('get_enrolled_students_with_inactive_account', api.GetInactiveEnrolledStudents.as_view(), + name='get_enrolled_students_with_inactive_account'), path('get_anon_ids', api.GetAnonIds.as_view(), name='get_anon_ids'), path('get_student_enrollment_status', api.GetStudentEnrollmentStatus.as_view(), name="get_student_enrollment_status"), path('get_student_progress_url', api.StudentProgressUrl.as_view(), name='get_student_progress_url'), path('reset_student_attempts', api.ResetStudentAttempts.as_view(), name='reset_student_attempts'), - path('rescore_problem', api.rescore_problem, name='rescore_problem'), + path('rescore_problem', api.RescoreProblem.as_view(), name='rescore_problem'), path('override_problem_score', api.override_problem_score, name='override_problem_score'), path('reset_student_attempts_for_entrance_exam', api.reset_student_attempts_for_entrance_exam, name='reset_student_attempts_for_entrance_exam'), - path('rescore_entrance_exam', api.rescore_entrance_exam, name='rescore_entrance_exam'), + path('rescore_entrance_exam', api.RescoreEntranceExamView.as_view(), name='rescore_entrance_exam'), path('list_entrance_exam_instructor_tasks', api.ListEntranceExamInstructorTasks.as_view(), name='list_entrance_exam_instructor_tasks'), path('mark_student_can_skip_entrance_exam', api.MarkStudentCanSkipEntranceExam.as_view(), name='mark_student_can_skip_entrance_exam'), path('list_instructor_tasks', api.ListInstructorTasks.as_view(), name='list_instructor_tasks'), - path('list_background_email_tasks', api.list_background_email_tasks, name='list_background_email_tasks'), + path('list_background_email_tasks', api.ListBackgroundEmailTasks.as_view(), name='list_background_email_tasks'), path('list_email_content', api.ListEmailContent.as_view(), name='list_email_content'), - path('list_forum_members', api.list_forum_members, name='list_forum_members'), - path('update_forum_role_membership', api.update_forum_role_membership, name='update_forum_role_membership'), + path('list_forum_members', api.ListForumMembers.as_view(), name='list_forum_members'), + path('update_forum_role_membership', api.UpdateForumRoleMembership.as_view(), name='update_forum_role_membership'), path('change_due_date', api.ChangeDueDate.as_view(), name='change_due_date'), path('send_email', api.SendEmail.as_view(), name='send_email'), path('reset_due_date', api.ResetDueDate.as_view(), name='reset_due_date'), - path('show_unit_extensions', api.show_unit_extensions, name='show_unit_extensions'), + path('show_unit_extensions', api.ShowUnitExtensionsView.as_view(), name='show_unit_extensions'), path('show_student_extensions', api.ShowStudentExtensions.as_view(), name='show_student_extensions'), # proctored exam downloads... - path('get_proctored_exam_results', api.get_proctored_exam_results, name='get_proctored_exam_results'), + path('get_proctored_exam_results', api.GetProctoredExamResults.as_view(), name='get_proctored_exam_results'), # Grade downloads... path('list_report_downloads', api.ListReportDownloads.as_view(), name='list_report_downloads'), - path('calculate_grades_csv', api.calculate_grades_csv, name='calculate_grades_csv'), - path('problem_grade_report', api.problem_grade_report, name='problem_grade_report'), + path('calculate_grades_csv', api.CalculateGradesCsvView.as_view(), name='calculate_grades_csv'), + path('problem_grade_report', api.ProblemGradeReport.as_view(), name='problem_grade_report'), # Reports.. - path('get_course_survey_results', api.get_course_survey_results, name='get_course_survey_results'), - path('export_ora2_data', api.export_ora2_data, name='export_ora2_data'), - path('export_ora2_summary', api.export_ora2_summary, name='export_ora2_summary'), + path('get_course_survey_results', api.GetCourseSurveyResults.as_view(), name='get_course_survey_results'), + path('export_ora2_data', api.ExportOra2DataView.as_view(), name='export_ora2_data'), + path('export_ora2_summary', api.ExportOra2SummaryView.as_view(), name='export_ora2_summary'), - path('export_ora2_submission_files', api.export_ora2_submission_files, + path('export_ora2_submission_files', api.ExportOra2SubmissionFilesView.as_view(), name='export_ora2_submission_files'), # spoc gradebook @@ -78,16 +80,17 @@ urlpatterns = [ path('gradebook/', gradebook_api.spoc_gradebook, name='spoc_gradebook'), # Cohort management - path('add_users_to_cohorts', api.add_users_to_cohorts, name='add_users_to_cohorts'), + path('add_users_to_cohorts', api.AddUsersToCohorts.as_view(), name='add_users_to_cohorts'), # Certificates path('enable_certificate_generation', api.enable_certificate_generation, name='enable_certificate_generation'), path('start_certificate_generation', api.StartCertificateGeneration.as_view(), name='start_certificate_generation'), - path('start_certificate_regeneration', api.start_certificate_regeneration, name='start_certificate_regeneration'), - path('certificate_exception_view/', api.certificate_exception_view, name='certificate_exception_view'), - re_path(r'^generate_certificate_exceptions/(?P[^/]*)', api.generate_certificate_exceptions, + path('start_certificate_regeneration', api.StartCertificateRegeneration.as_view(), + name='start_certificate_regeneration'), + path('certificate_exception_view/', api.CertificateExceptionView.as_view(), name='certificate_exception_view'), + re_path(r'^generate_certificate_exceptions/(?P[^/]*)', api.GenerateCertificateExceptions.as_view(), name='generate_certificate_exceptions'), - path('generate_bulk_certificate_exceptions', api.generate_bulk_certificate_exceptions, + path('generate_bulk_certificate_exceptions', api.GenerateBulkCertificateExceptions.as_view(), name='generate_bulk_certificate_exceptions'), path( 'certificate_invalidation_view/', diff --git a/lms/djangoapps/instructor/views/instructor_dashboard.py b/lms/djangoapps/instructor/views/instructor_dashboard.py index b6057f8194..be2ae51dbd 100644 --- a/lms/djangoapps/instructor/views/instructor_dashboard.py +++ b/lms/djangoapps/instructor/views/instructor_dashboard.py @@ -650,6 +650,9 @@ def _section_data_download(course, access): 'get_students_who_may_enroll_url': reverse( 'get_students_who_may_enroll', kwargs={'course_id': str(course_key)} ), + 'get_inactive_enrolled_students_url': reverse( + 'get_enrolled_students_with_inactive_account', kwargs={'course_id': str(course_key)} + ), 'get_anon_ids_url': reverse('get_anon_ids', kwargs={'course_id': str(course_key)}), 'list_proctored_results_url': reverse( 'get_proctored_exam_results', kwargs={'course_id': str(course_key)} diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py index 2ac794bc29..5327a17116 100644 --- a/lms/djangoapps/instructor/views/serializer.py +++ b/lms/djangoapps/instructor/views/serializer.py @@ -1,12 +1,26 @@ """ Instructor apis serializers. """ +import re from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.exceptions import ValidationError from django.utils.translation import gettext as _ from rest_framework import serializers -from .tools import get_student_from_identifier +from lms.djangoapps.certificates.models import CertificateStatuses from lms.djangoapps.instructor.access import ROLES +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_GROUP_MODERATOR, + FORUM_ROLE_MODERATOR, + Role +) +from lms.djangoapps.discussion.django_comment_client.utils import ( + get_group_id_for_user, + get_group_name +) + +from .tools import get_student_from_identifier class RoleNameSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -67,6 +81,63 @@ class AccessSerializer(UniqueStudentIdentifierSerializer): ) +class ForumRoleNameSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Serializer for forum rolename. + """ + + rolename = serializers.CharField(help_text=_("Role name")) + users = serializers.SerializerMethodField() + + def validate_rolename(self, value): + """ + Check that the rolename is valid. + """ + if value not in [ + FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_GROUP_MODERATOR, FORUM_ROLE_MODERATOR + ]: + raise ValidationError(_("Invalid role name.")) + return value + + def get_users(self, obj): + """ + Retrieve a list of users associated with the specified role and course. + + Args: + obj (dict): A dictionary containing the 'rolename' for which to retrieve users. + This dictionary is the data passed to the serializer. + + Returns: + list: A list of dictionaries, each representing a user associated with the specified role. + Each user dictionary contains 'username', 'email', 'first_name', 'last_name', and 'group_name'. + If no users are found, an empty list is returned. + + """ + course_id = self.context.get('course_id') + rolename = obj['rolename'] + try: + role = Role.objects.get(name=rolename, course_id=course_id) + users = role.users.all().order_by('username') + except Role.DoesNotExist: + users = [] + + return [extract_user_info(user, self.context.get('course_discussion_settings')) for user in users] + + +def extract_user_info(user, course_discussion_settings): + """ utility method to convert user into dict for JSON rendering. """ + group_id = get_group_id_for_user(user, course_discussion_settings) + group_name = get_group_name(group_id, course_discussion_settings) + + return { + 'username': user.username, + 'email': user.email, + 'first_name': user.first_name, + 'last_name': user.last_name, + 'group_name': group_name, + } + + class ListInstructorTaskInputSerializer(serializers.Serializer): # pylint: disable=abstract-method """ Serializer for handling the input data for the problem response report generation API. @@ -122,6 +193,21 @@ class ShowStudentExtensionSerializer(serializers.Serializer): return user +class ShowUnitExtensionsSerializer(serializers.Serializer): + """ + Serializer for showing all students who have due date extensions + for a specific unit (block). + + Fields: + url (str): The URL (block ID) of the unit for which student extensions should be retrieved. + """ + url = serializers.CharField( + required=True, + max_length=2048, + help_text="The unit URL (block ID) to retrieve student extensions for." + ) + + class StudentAttemptsSerializer(serializers.Serializer): """ Serializer for resetting a students attempts counter or starts a task to reset all students @@ -170,7 +256,27 @@ class StudentAttemptsSerializer(serializers.Serializer): if value is not None: return value in ['true', 'True', True] - return False + +class UpdateForumRoleMembershipSerializer(AccessSerializer): + """ + Serializer for managing user's forum role. + + This serializer extends the AccessSerializer to allow for different action + choices specific to this API. It validates and processes the data required + to modify user access within a system. + + Attributes: + unique_student_identifier (str): The email or username of the user whose access is being modified. + rolename (str): The role name to assign to the user. + action (str): The specific action to perform on the user's access, with options 'activate' or 'deactivate'. + """ + rolename = serializers.ChoiceField( + choices=[ + FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, + FORUM_ROLE_GROUP_MODERATOR, FORUM_ROLE_COMMUNITY_TA + ], + help_text="Rolename assign to given user." + ) class SendEmailSerializer(serializers.Serializer): @@ -230,9 +336,129 @@ class BlockDueDateSerializer(serializers.Serializer): self.fields['due_datetime'].required = False +class ProblemResetSerializer(UniqueStudentIdentifierSerializer): + """ + serializer for resetting problem. + """ + problem_to_reset = serializers.CharField( + help_text=_("The URL name of the problem to reset."), + error_messages={ + 'blank': _("Problem URL name cannot be blank."), + } + ) + all_students = serializers.BooleanField( + default=False, + help_text=_("Whether to reset the problem for all students."), + ) + only_if_higher = serializers.BooleanField( + default=False, + ) + + # Override the unique_student_identifier field to make it optional + unique_student_identifier = serializers.CharField( + required=False, # Make this field optional + allow_null=True, + help_text=_("unique student identifier.") + ) + + +class ModifyAccessSerializer(serializers.Serializer): + """ + serializers for enroll or un-enroll users in beta testing program. + """ + identifiers = serializers.CharField( + help_text="A comma separated list of emails or usernames.", + required=True + ) + action = serializers.ChoiceField( + choices=["add", "remove"], + help_text="Action to perform: add or remove.", + required=True + ) + + email_students = serializers.BooleanField( + default=False, + help_text="Boolean flag to indicate if students should be emailed." + ) + + auto_enroll = serializers.BooleanField( + default=False, + help_text="Boolean flag to indicate if the user should be auto-enrolled." + ) + + def validate_identifiers(self, value): + """ + Validate the 'identifiers' field which is now a list of strings. + """ + # Iterate over the list of identifiers and validate each one + validated_list = _split_input_list(value) + if not validated_list: + raise serializers.ValidationError("The identifiers list cannot be empty.") + + return validated_list + + def validate_email_students(self, value): + """ + handle string values like 'true' or 'false'. + """ + if isinstance(value, str): + return value.lower() == 'true' + return bool(value) + + def validate_auto_enroll(self, value): + """ + handle string values like 'true' or 'false'. + """ + if isinstance(value, str): + return value.lower() == 'true' + return bool(value) + + +def _split_input_list(str_list): + """ + Separate out individual student email from the comma, or space separated string. + + e.g. + in: "Lorem@ipsum.dolor, sit@amet.consectetur\nadipiscing@elit.Aenean\r convallis@at.lacus\r, ut@lacinia.Sed" + out: ['Lorem@ipsum.dolor', 'sit@amet.consectetur', 'adipiscing@elit.Aenean', 'convallis@at.lacus', 'ut@lacinia.Sed'] + + `str_list` is a string coming from an input text area + returns a list of separated values + """ + new_list = re.split(r'[,\s\n\r]+', str_list) + new_list = [s.strip() for s in new_list] + new_list = [s for s in new_list if s != ''] + + return new_list + + +class CertificateStatusesSerializer(serializers.Serializer): + """ + Serializer for validating and serializing certificate status inputs. + + This serializer is used to ensure that the provided certificate statuses + conform to the predefined set of valid statuses defined in the + `CertificateStatuses` enumeration. + """ + certificate_statuses = serializers.ListField( + child=serializers.ChoiceField(choices=[ + CertificateStatuses.downloadable, + CertificateStatuses.error, + CertificateStatuses.notpassing, + CertificateStatuses.audit_passing, + CertificateStatuses.audit_notpassing, + ]), + allow_empty=False # Set to True if you want to allow empty lists + ) + + class CertificateSerializer(serializers.Serializer): """ - Serializer for resetting a students attempts counter or starts a task to reset all students + Serializer for multiple operations related with certificates. + resetting a students attempts counter or starts a task to reset all students + attempts counters + Also Add/Remove students to/from the certificate allowlist. + Also For resetting a students attempts counter or starts a task to reset all students attempts counters. """ user = serializers.CharField( @@ -250,3 +476,10 @@ class CertificateSerializer(serializers.Serializer): return None return user + + +class RescoreEntranceExamSerializer(serializers.Serializer): + """Serializer for entrance exam rescoring""" + unique_student_identifier = serializers.CharField(required=False, allow_null=True) + all_students = serializers.BooleanField(required=False) + only_if_higher = serializers.BooleanField(required=False, allow_null=True) diff --git a/lms/djangoapps/instructor_analytics/basic.py b/lms/djangoapps/instructor_analytics/basic.py index c7bc6ca6da..6b5a772519 100644 --- a/lms/djangoapps/instructor_analytics/basic.py +++ b/lms/djangoapps/instructor_analytics/basic.py @@ -13,7 +13,7 @@ from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.exceptions import ObjectDoesNotExist from django.core.serializers.json import DjangoJSONEncoder -from django.db.models import Count # lint-amnesty, pylint: disable=unused-import +from django.db.models import Count, F from django.urls import reverse from edx_proctoring.api import get_exam_violation_report from opaque_keys.edx.keys import CourseKey, UsageKey @@ -219,6 +219,34 @@ def list_may_enroll(course_key, features): return [extract_student(student, features) for student in may_enroll_and_unenrolled] +def list_inactive_enrolled_students(course_key, features): + """ + Return info about students who are enrolled in a course but have not activated their account. + + list_enrolled_inactive_students(course_key, ['email']) + would return [ + {'email': 'email1'} + {'email': 'email2'} + {'email': 'email3'} + ] + """ + enrolled_inactive_user_emails = CourseEnrollment.objects.filter( + course_id=course_key, + is_active=True, + user__is_active=False + ).annotate( + email=F('user__email') + ).values('email') + + def extract_student(student, features): + """ + Build dict containing information about a single inactive enrolled student. + """ + return {feature: student.get(feature, None) for feature in features} + + return [extract_student(student, features) for student in enrolled_inactive_user_emails] + + def get_proctored_exam_results(course_key, features): """ Return info about proctored exam results in a course as a dict. diff --git a/lms/djangoapps/instructor_task/api.py b/lms/djangoapps/instructor_task/api.py index 62095c3ae3..6474efc1d3 100644 --- a/lms/djangoapps/instructor_task/api.py +++ b/lms/djangoapps/instructor_task/api.py @@ -32,6 +32,7 @@ from lms.djangoapps.instructor_task.data import InstructorTaskTypes from lms.djangoapps.instructor_task.models import InstructorTask, InstructorTaskSchedule, SCHEDULED from lms.djangoapps.instructor_task.tasks import ( calculate_grades_csv, + calculate_inactive_enrolled_students_info_csv, calculate_may_enroll_csv, calculate_problem_grade_report, calculate_problem_responses_csv, @@ -409,6 +410,21 @@ def submit_calculate_may_enroll_csv(request, course_key, features): return submit_task(request, task_type, task_class, course_key, task_input, task_key) +def submit_calculate_inactive_enrolled_students_csv(request, course_key, features): + """ + Submits a task to generate a CSV file containing information about + enrolled students in a course who have not activated their account yet. + + Raises AlreadyRunningError if said file is already being updated. + """ + task_type = InstructorTaskTypes.INACTIVE_ENROLLED_STUDENTS_INFO_CSV + task_class = calculate_inactive_enrolled_students_info_csv + task_input = {'features': features} + task_key = "" + + return submit_task(request, task_type, task_class, course_key, task_input, task_key) + + def submit_course_survey_report(request, course_key): """ Submits a task to generate a HTML File containing the executive summary report. diff --git a/lms/djangoapps/instructor_task/api_helper.py b/lms/djangoapps/instructor_task/api_helper.py index 9c3f900fee..9e6bd2f6c9 100644 --- a/lms/djangoapps/instructor_task/api_helper.py +++ b/lms/djangoapps/instructor_task/api_helper.py @@ -114,7 +114,7 @@ def generate_already_running_error_message(task_type): 'proctored_exam_results_report': _('proctored exam results'), 'export_ora2_data': _('ORA data'), 'grade_course': _('grade'), - + 'inactive_enrolled_students_info_csv': _('inactive enrollment') } if report_types.get(task_type): diff --git a/lms/djangoapps/instructor_task/data.py b/lms/djangoapps/instructor_task/data.py index c9da5eda7d..7a09f7d082 100644 --- a/lms/djangoapps/instructor_task/data.py +++ b/lms/djangoapps/instructor_task/data.py @@ -24,6 +24,7 @@ class InstructorTaskTypes(str, Enum): GRADE_COURSE = "grade_course" GRADE_PROBLEMS = "grade_problems" MAY_ENROLL_INFO_CSV = "may_enroll_info_csv" + INACTIVE_ENROLLED_STUDENTS_INFO_CSV = "inactive_enrolled_students_info_csv" OVERRIDE_PROBLEM_SCORE = "override_problem_score" PROBLEM_RESPONSES_CSV = "problem_responses_csv" PROCTORED_EXAM_RESULTS_REPORT = "proctored_exam_results_report" diff --git a/lms/djangoapps/instructor_task/tasks.py b/lms/djangoapps/instructor_task/tasks.py index 9603a4fa18..7a9dabe3d5 100644 --- a/lms/djangoapps/instructor_task/tasks.py +++ b/lms/djangoapps/instructor_task/tasks.py @@ -30,7 +30,11 @@ from edx_django_utils.monitoring import set_code_owner_attribute from lms.djangoapps.bulk_email.tasks import perform_delegate_email_batches from lms.djangoapps.instructor_task.tasks_base import BaseInstructorTask from lms.djangoapps.instructor_task.tasks_helper.certs import generate_students_certificates -from lms.djangoapps.instructor_task.tasks_helper.enrollments import upload_may_enroll_csv, upload_students_csv +from lms.djangoapps.instructor_task.tasks_helper.enrollments import ( + upload_inactive_enrolled_students_info_csv, + upload_may_enroll_csv, + upload_students_csv +) from lms.djangoapps.instructor_task.tasks_helper.grades import CourseGradeReport, ProblemGradeReport, ProblemResponses from lms.djangoapps.instructor_task.tasks_helper.misc import ( cohort_students_and_upload, @@ -267,6 +271,20 @@ def calculate_may_enroll_csv(entry_id, xblock_instance_args): return run_main_task(entry_id, task_fn, action_name) +@shared_task(base=BaseInstructorTask) +@set_code_owner_attribute +def calculate_inactive_enrolled_students_info_csv(entry_id, xblock_instance_args): + """ + Compute information about invited students who have not enrolled + in a given course yet and upload the CSV to an S3 bucket for + download. + """ + # Translators: This is a past-tense verb that is inserted into task progress messages as {action}. + action_name = gettext_noop('generated') + task_fn = partial(upload_inactive_enrolled_students_info_csv, xblock_instance_args) + return run_main_task(entry_id, task_fn, action_name) + + @shared_task(base=BaseInstructorTask) @set_code_owner_attribute def generate_certificates(entry_id, xblock_instance_args): diff --git a/lms/djangoapps/instructor_task/tasks_helper/enrollments.py b/lms/djangoapps/instructor_task/tasks_helper/enrollments.py index 0309b7883a..468786323b 100644 --- a/lms/djangoapps/instructor_task/tasks_helper/enrollments.py +++ b/lms/djangoapps/instructor_task/tasks_helper/enrollments.py @@ -7,7 +7,11 @@ import logging from datetime import datetime from time import time from pytz import UTC -from lms.djangoapps.instructor_analytics.basic import enrolled_students_features, list_may_enroll +from lms.djangoapps.instructor_analytics.basic import ( + enrolled_students_features, + list_inactive_enrolled_students, + list_may_enroll, +) from lms.djangoapps.instructor_analytics.csvs import format_dictlist from common.djangoapps.student.models import CourseEnrollment # lint-amnesty, pylint: disable=unused-import @@ -50,6 +54,38 @@ def upload_may_enroll_csv(_xblock_instance_args, _entry_id, course_id, task_inpu return task_progress.update_task_state(extra_meta=current_step) +def upload_inactive_enrolled_students_info_csv(_xblock_instance_args, _entry_id, course_id, task_input, action_name): + """ + For a given `course_id`, generate a CSV file containing + information about students who are enrolled in a course but have not + activated their account yet, and store using a `ReportStore`. + """ + start_time = time() + start_date = datetime.now(UTC) + num_reports = 1 + task_progress = TaskProgress(action_name, num_reports, start_time) + current_step = {'step': 'Calculating info about students who are enrolled and their account is inactive'} + task_progress.update_task_state(extra_meta=current_step) + + # Compute result table and format it + query_features = task_input.get('features') + student_data = list_inactive_enrolled_students(course_id, query_features) + header, rows = format_dictlist(student_data, query_features) + + task_progress.attempted = task_progress.succeeded = len(rows) + task_progress.skipped = task_progress.total - task_progress.attempted + + rows.insert(0, header) + + current_step = {'step': 'Uploading CSV'} + task_progress.update_task_state(extra_meta=current_step) + + # Perform the upload + upload_csv_to_report_store(rows, 'inactive_enrolled_students_info', course_id, start_date) + + return task_progress.update_task_state(extra_meta=current_step) + + def upload_students_csv(_xblock_instance_args, _entry_id, course_id, task_input, action_name): """ For a given `course_id`, generate a CSV file containing profile diff --git a/lms/djangoapps/instructor_task/tests/test_integration.py b/lms/djangoapps/instructor_task/tests/test_integration.py index 004cba1cda..267f8021cd 100644 --- a/lms/djangoapps/instructor_task/tests/test_integration.py +++ b/lms/djangoapps/instructor_task/tests/test_integration.py @@ -22,6 +22,7 @@ from django.urls import reverse from xmodule.capa.responsetypes import StudentInputError from xmodule.capa.tests.response_xml_factory import CodeResponseXMLFactory, CustomResponseXMLFactory +from xmodule.capa.tests.test_util import use_unsafe_codejail from lms.djangoapps.courseware.model_data import StudentModule from lms.djangoapps.grades.api import CourseGradeFactory from lms.djangoapps.instructor_task.api import ( @@ -71,6 +72,7 @@ class TestIntegrationTask(InstructorTaskModuleTestCase): @ddt.ddt @override_settings(RATELIMIT_ENABLE=False) +@use_unsafe_codejail() class TestRescoringTask(TestIntegrationTask): """ Integration-style tests for rescoring problems in a background task. diff --git a/lms/djangoapps/learner_dashboard/api/urls.py b/lms/djangoapps/learner_dashboard/api/urls.py deleted file mode 100644 index 07b808a81e..0000000000 --- a/lms/djangoapps/learner_dashboard/api/urls.py +++ /dev/null @@ -1,10 +0,0 @@ -""" -Learner Dashboard API URLs. -""" - -from django.urls import include, path - -app_name = 'learner_dashboard' -urlpatterns = [ - path('v0/', include('lms.djangoapps.learner_dashboard.api.v0.urls')), -] diff --git a/lms/djangoapps/learner_dashboard/api/v0/urls.py b/lms/djangoapps/learner_dashboard/api/v0/urls.py deleted file mode 100644 index 93035c817d..0000000000 --- a/lms/djangoapps/learner_dashboard/api/v0/urls.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -Learner Dashboard API v0 URLs. -""" - -from django.urls import re_path - -from lms.djangoapps.learner_dashboard.api.v0.views import ( - Programs, - ProgramProgressDetailView -) - -UUID_REGEX_PATTERN = r'[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?4[0-9a-fA-F]{3}-?[89abAB][0-9a-fA-F]{3}-?[0-9a-fA-F]{12}' - -app_name = 'v0' -urlpatterns = [ - re_path( - fr'^programs/(?P{UUID_REGEX_PATTERN})/$', - Programs.as_view(), - name='program_list' - ), - re_path(r'^programs/(?P[0-9a-f-]+)/progress_details/$', ProgramProgressDetailView.as_view(), - name='program_progress_detail'), -] diff --git a/lms/djangoapps/learner_dashboard/api/v0/views.py b/lms/djangoapps/learner_dashboard/api/v0/views.py deleted file mode 100644 index 1579fdd26a..0000000000 --- a/lms/djangoapps/learner_dashboard/api/v0/views.py +++ /dev/null @@ -1,336 +0,0 @@ -""" API v0 views. """ -import logging - -from enterprise.models import EnterpriseCourseEnrollment -from rest_framework.permissions import IsAuthenticated -from rest_framework.response import Response -from rest_framework.views import APIView - -from common.djangoapps.student.models import CourseEnrollment -from openedx.core.djangoapps.programs.utils import ( - ProgramProgressMeter, - get_certificates, - get_industry_and_credit_pathways, - get_program_and_course_data, - get_program_urls, -) - - -logger = logging.getLogger(__name__) - - -class Programs(APIView): - """ - **Use Case** - - * Get a list of all programs in which request user has enrolled. - - **Example Request** - - GET /api/dashboard/v0/programs/{enterprise_uuid}/ - - **GET Parameters** - - A GET request must include the following parameters. - - * enterprise_uuid: UUID of an enterprise customer. - - **Example GET Response** - - [ - { - "uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c", - "title": "edX Demonstration Program", - "type": "MicroMasters", - "banner_image": { - "large": { - "url": "http://localhost:18381/media/programs/banner_images/ff41a5eb-2a73-4933-8e80.large.jpg", - "width": 1440, - "height": 480 - }, - "medium": { - "url": "http://localhost:18381/media/programs/banner_images/ff41a5eb-2a73-4933-8e80.medium.jpg", - "width": 726, - "height": 242 - }, - "small": { - "url": "http://localhost:18381/media/programs/banner_images/ff41a5eb-2a73-4933-8e80.small.jpg", - "width": 435, - "height": 145 - }, - "x-small": { - "url": "http://localhost:18381/media/programs/banner_images/ff41a5eb-2a73-4933-8e8.x-small.jpg", - "width": 348, - "height": 116 - } - }, - "authoring_organizations": [ - { - "key": "edX" - } - ], - "progress": { - "uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c", - "completed": 0, - "in_progress": 0, - "not_started": 2 - } - } - ] - """ - - permission_classes = (IsAuthenticated,) - - def get(self, request, enterprise_uuid): - """ - Return a list of a enterprise learner's all enrolled programs with their progress. - - Args: - request (Request): DRF request object. - enterprise_uuid (string): UUID of an enterprise customer. - """ - user = request.user - - enrollments = self._get_enterprise_course_enrollments(enterprise_uuid, user) - # return empty reponse if no enterprise enrollments exists for a user - if not enrollments: - return Response([]) - - meter = ProgramProgressMeter( - request.site, - user, - enrollments=enrollments, - mobile_only=False, - include_course_entitlements=False - ) - engaged_programs = meter.engaged_programs - progress = meter.progress(programs=engaged_programs) - programs = self._extract_minimal_required_programs_data(engaged_programs) - programs = self._combine_programs_data_and_progress(programs, progress) - - return Response(programs) - - def _combine_programs_data_and_progress(self, programs_data, programs_progress): - """ - Return the combined program and progress data so that api clinet can easily process the data. - """ - for program_data in programs_data: - program_progress = next((item for item in programs_progress if item['uuid'] == program_data['uuid']), None) - program_data['progress'] = program_progress - - return programs_data - - def _extract_minimal_required_programs_data(self, programs_data): - """ - Return only the minimal required program data need for program listing page. - """ - def transform(key, value): - transformers = {'authoring_organizations': transform_authoring_organizations} - - if key in transformers: - return transformers[key](value) - - return value - - def transform_authoring_organizations(authoring_organizations): - """ - Extract only the required data for `authoring_organizations` for a program - """ - transformed_authoring_organizations = [] - for authoring_organization in authoring_organizations: - transformed_authoring_organizations.append( - { - 'key': authoring_organization['key'], - 'logo_image_url': authoring_organization['logo_image_url'] - } - ) - - return transformed_authoring_organizations - - program_data_keys = ['uuid', 'title', 'type', 'banner_image', 'authoring_organizations'] - programs = [] - for program_data in programs_data: - program = {} - for program_data_key in program_data_keys: - program[program_data_key] = transform(program_data_key, program_data[program_data_key]) - - programs.append(program) - - return programs - - def _get_enterprise_course_enrollments(self, enterprise_uuid, user): - """ - Return only enterprise enrollments for a user. - """ - enterprise_enrollment_course_ids = list(EnterpriseCourseEnrollment.objects.filter( - enterprise_customer_user__user_id=user.id, - enterprise_customer_user__enterprise_customer__uuid=enterprise_uuid, - ).values_list('course_id', flat=True)) - - course_enrollments = CourseEnrollment.enrollments_for_user(user).filter( - course_id__in=enterprise_enrollment_course_ids - ).select_related('course') - - return list(course_enrollments) - - -class ProgramProgressDetailView(APIView): - """ - **Use Case** - - * Get progress details of a learner enrolled in a program. - - **Example Request** - - GET api/dashboard/v0/programs/{program_uuid}/progress_details/ - - **GET Parameters** - - A GET request must include the following parameters. - - * program_uuid: A string representation of uuid of the program. - - **GET Response Values** - - If the request for information about the program is successful, an HTTP 200 "OK" response - is returned. - - The HTTP 200 response has the following values. - - * urls: Urls to enroll/purchase a course or view program record. - - * program_data: Holds meta information about the program. - - * course_data: Learner's progress details for all courses in the program (in-progress/remaining/completed). - - * certificate_data: Details about learner's certificates status for all courses in the program and the - program itself. - - * industry_pathways: Industry pathways for the program, comes under additional credit opportunities. - - * credit_pathways: Credit pathways for the program, comes under additional credit opportunities. - - **Example GET Response** - - { - "urls": { - "program_listing_url": "/dashboard/programs/", - "track_selection_url": "/course_modes/choose/", - "commerce_api_url": "/api/commerce/v0/baskets/", - "buy_button_url": "http://ecommerce.com/basket/add/?", - "program_record_url": "https://credentials.example.com/records/programs/121234235525242344" - }, - "program_data": { - "uuid": "a156a6e2-de91-4ce7-947a-888943e6b12a", - "title": "edX Demonstration Program", - "subtitle": "", - "type": "MicroMasters", - "status": "active", - "marketing_slug": "demo-program", - "marketing_url": "micromasters/demo-program", - "authoring_organizations": [], - "card_image_url": "http://edx.devstack.lms:18000/asset-v1:edX+DemoX+Demo_Course.jpg", - "is_program_eligible_for_one_click_purchase": false, - "pathway_ids": [ - 1, - 2 - ], - "is_learner_eligible_for_one_click_purchase": false, - "skus": ["AUD122342"], - }, - "course_data": { - "uuid": "a156a6e2-de91-4ce7-947a-888943e6b12a", - "completed": [], - "in_progress": [], - "not_started": [ - { - "key": "edX+DemoX", - "uuid": "fe1a9ad4-a452-45cd-80e5-9babd3d43f96", - "title": "Demonstration Course", - "course_runs": [], - "entitlements": [], - "owners": [], - "image": "", - "short_description": "", - "type": "457f07ec-a78f-45b4-ba09-5fb176520d8a", - } - ], - }, - "certificate_data": [{ - "type": "course", - "title": "edX Demo Course", - 'url': "/certificates/6e57d3cce8e34cfcb60bd8e8b04r07e0", - }], - "industry_pathways": [ - { - "id": 2, - "uuid": "1b8fadf1-f6aa-4282-94e3-325b922a027f", - "name": "Demo Industry Pathway", - "org_name": "edX", - "email": "edx@edx.com", - "description": "Sample demo industry pathway", - "destination_url": "http://rit.edu/online/pathways/gtx-analytics-essential-tools-methods", - "pathway_type": "industry", - "program_uuids": [ - "a156a6e2-de91-4ce7-947a-888943e6b12a" - ] - } - ], - "credit_pathways": [ - { - "id": 1, - "uuid": "86b9701a-61e6-48a2-92eb-70a824521c1f", - "name": "Demo Credit Pathway", - "org_name": "edX", - "email": "edx@edx.com", - "description": "Sample demo credit pathway!", - "destination_url": "http://rit.edu/online/pathways/ritx-design-thinking", - "pathway_type": "credit", - "program_uuids": [ - "a156a6e2-de91-4ce7-947a-888943e6b12a" - ] - } - ] - } - """ - - permission_classes = (IsAuthenticated,) - - def get(self, request, program_uuid): - """ - Retrieves progress details of a user in a specified program. - - Args: - request (Request): Django request object. - program_uuid (string): URI element specifying uuid of the program. - - Return: - """ - user = request.user - site = request.site - program_data, course_data = get_program_and_course_data(site, user, program_uuid) - if not program_data: - return Response( - status=404, - data={'error_code': 'No program data available.'} - ) - - certificate_data = get_certificates(user, program_data) - program_data.pop('courses') - - urls = get_program_urls(program_data) - if not certificate_data: - urls['program_record_url'] = None - - industry_pathways, credit_pathways = get_industry_and_credit_pathways(program_data, site) - - return Response( - { - 'urls': urls, - 'program_data': program_data, - 'course_data': course_data, - 'certificate_data': certificate_data, - 'industry_pathways': industry_pathways, - 'credit_pathways': credit_pathways, - } - ) diff --git a/lms/djangoapps/learner_dashboard/api/__init__.py b/lms/djangoapps/learner_home/rest_api/__init__.py similarity index 100% rename from lms/djangoapps/learner_dashboard/api/__init__.py rename to lms/djangoapps/learner_home/rest_api/__init__.py diff --git a/lms/djangoapps/learner_home/rest_api/urls.py b/lms/djangoapps/learner_home/rest_api/urls.py new file mode 100644 index 0000000000..647ac84fa4 --- /dev/null +++ b/lms/djangoapps/learner_home/rest_api/urls.py @@ -0,0 +1,11 @@ +""" +Programs API URLs. +""" + +from django.urls import include, path + +from openedx.core.djangoapps.programs.rest_api.v1 import urls as v1_programs_rest_api_urls + +urlpatterns = [ + path("v1/", include((v1_programs_rest_api_urls, "v1"))), +] diff --git a/lms/djangoapps/learner_home/serializers.py b/lms/djangoapps/learner_home/serializers.py index c464e0c6a4..f7eed25d22 100644 --- a/lms/djangoapps/learner_home/serializers.py +++ b/lms/djangoapps/learner_home/serializers.py @@ -97,6 +97,7 @@ class CourseRunSerializer(serializers.Serializer): max_digits=5, decimal_places=2, source="course_overview.lowest_passing_grade" ) startDate = serializers.DateTimeField(source="course_overview.start") + advertisedStart = serializers.DateTimeField(source="course_overview.advertised_start") endDate = serializers.DateTimeField(source="course_overview.end") homeUrl = serializers.SerializerMethodField() marketingUrl = serializers.URLField( @@ -146,7 +147,9 @@ class CourseRunSerializer(serializers.Serializer): def to_representation(self, instance): """Serialize the courserun instance to be able to update the values before the API finishes rendering.""" serialized_courserun = super().to_representation(instance) - serialized_courserun = CourseRunAPIRenderStarted().run_filter( + # .. filter_implemented_name: CourseRunAPIRenderStarted + # .. filter_type: org.openedx.learning.home.courserun.api.rendered.started.v1 + serialized_courserun = CourseRunAPIRenderStarted.run_filter( serialized_courserun=serialized_courserun, ) return serialized_courserun @@ -263,7 +266,9 @@ class EnrollmentSerializer(serializers.Serializer): def to_representation(self, instance): """Serialize the enrollment instance to be able to update the values before the API finishes rendering.""" serialized_enrollment = super().to_representation(instance) - course_key, serialized_enrollment = CourseEnrollmentAPIRenderStarted().run_filter( + # .. filter_implemented_name: CourseEnrollmentAPIRenderStarted + # .. filter_type: org.openedx.learning.home.enrollment.api.rendered.v1 + course_key, serialized_enrollment = CourseEnrollmentAPIRenderStarted.run_filter( course_key=instance.course_id, serialized_enrollment=serialized_enrollment, ) diff --git a/lms/djangoapps/learner_home/test_serializers.py b/lms/djangoapps/learner_home/test_serializers.py index e7d22d5e71..7b8268b42c 100644 --- a/lms/djangoapps/learner_home/test_serializers.py +++ b/lms/djangoapps/learner_home/test_serializers.py @@ -74,7 +74,10 @@ class LearnerDashboardBaseTest(SharedModuleStoreTestCase): def create_test_enrollment(self, course_mode=CourseMode.AUDIT): """Create a test user, course, and enrollment. Return the enrollment.""" - course = CourseFactory(self_paced=True) + course = CourseFactory( + self_paced=True, + advertised_start="Winter 2015", + ) CourseModeFactory( course_id=course.id, mode_slug=course_mode, diff --git a/lms/djangoapps/learner_home/test_utils.py b/lms/djangoapps/learner_home/test_utils.py index f8a7dc29f7..3533dec220 100644 --- a/lms/djangoapps/learner_home/test_utils.py +++ b/lms/djangoapps/learner_home/test_utils.py @@ -64,9 +64,12 @@ def datetime_to_django_format(datetime_obj): return datetime_obj.strftime("%Y-%m-%dT%H:%M:%SZ") -def create_test_enrollment(user, course_mode=CourseMode.AUDIT): +def create_test_enrollment(user, course_mode=CourseMode.AUDIT, advertised_start=None): """Create a test user, course, course overview, and enrollment. Return the enrollment.""" - course = CourseFactory(self_paced=True) + course = CourseFactory( + self_paced=True, + advertised_start=advertised_start, + ) CourseModeFactory( course_id=course.id, diff --git a/lms/djangoapps/learner_home/test_views.py b/lms/djangoapps/learner_home/test_views.py index b95a53f9cb..5b09893971 100644 --- a/lms/djangoapps/learner_home/test_views.py +++ b/lms/djangoapps/learner_home/test_views.py @@ -94,7 +94,7 @@ class TestGetUserAccountConfirmationInfo(SharedModuleStoreTestCase): """Tests for get_user_account_confirmation_info""" MOCK_SETTINGS = { - "ACTIVATION_EMAIL_SUPPORT_LINK": "activation.example.com", + "SEND_ACTIVATION_EMAIL_URL": "activation.example.com", "SUPPORT_SITE_LINK": "support.example.com", } @@ -120,24 +120,24 @@ class TestGetUserAccountConfirmationInfo(SharedModuleStoreTestCase): assert user_account_confirmation_info["isNeeded"] == (not user_is_active) @patch( - "django.conf.settings.ACTIVATION_EMAIL_SUPPORT_LINK", - MOCK_SETTINGS["ACTIVATION_EMAIL_SUPPORT_LINK"], + "django.conf.settings.SEND_ACTIVATION_EMAIL_URL", + MOCK_SETTINGS["SEND_ACTIVATION_EMAIL_URL"], ) def test_email_url_support_link(self): - # Given an ACTIVATION_EMAIL_SUPPORT_LINK is supplied + # Given an SEND_ACTIVATION_EMAIL_URL is supplied # When I get user account confirmation info user_account_confirmation_info = get_user_account_confirmation_info(self.user) # Then that link should be returned as the sendEmailUrl self.assertEqual( user_account_confirmation_info["sendEmailUrl"], - self.MOCK_SETTINGS["ACTIVATION_EMAIL_SUPPORT_LINK"], + self.MOCK_SETTINGS["SEND_ACTIVATION_EMAIL_URL"], ) @patch("lms.djangoapps.learner_home.views.configuration_helpers") @patch("django.conf.settings.SUPPORT_SITE_LINK", MOCK_SETTINGS["SUPPORT_SITE_LINK"]) def test_email_url_support_fallback_link(self, mock_config_helpers): - # Given an ACTIVATION_EMAIL_SUPPORT_LINK is NOT supplied + # Given an SEND_ACTIVATION_EMAIL_URL is NOT supplied mock_config_helpers.get_value.return_value = None # When I get user account confirmation info @@ -576,6 +576,35 @@ class TestDashboardView(BaseTestDashboardView): assert expected_keys == response_data.keys() + @patch.dict(settings.FEATURES, ENTERPRISE_ENABLED=False) + def test_response_course_advertised_start(self): + """Basic test for correct response structure""" + + # Given I am logged in + self.log_in() + + # Creating course + advertised_start = "Winter 2025" + create_test_enrollment( + self.user, advertised_start=advertised_start + ) + + # When I request the dashboard + response = self.client.get(self.view_url) + + # Then I get the expected success response + assert response.status_code == 200 + + response_data = json.loads(response.content) + assert "courses" in response_data + assert len(response_data["courses"]) > 0 + + for course in response_data["courses"]: + assert "courseRun" in course + course_run = course["courseRun"] + assert "advertisedStart" in course_run + assert course_run["advertisedStart"] == advertised_start + @patch.dict(settings.FEATURES, ENTERPRISE_ENABLED=False) @patch("lms.djangoapps.learner_home.views.get_user_account_confirmation_info") def test_email_confirmation(self, mock_user_conf_info): diff --git a/lms/djangoapps/learner_home/urls.py b/lms/djangoapps/learner_home/urls.py index ad7cfef463..c56ccb5971 100644 --- a/lms/djangoapps/learner_home/urls.py +++ b/lms/djangoapps/learner_home/urls.py @@ -6,6 +6,8 @@ from django.urls import path from django.urls import include, re_path from lms.djangoapps.learner_home import views +from .rest_api import urls as rest_api_urls + app_name = "learner_home" @@ -13,4 +15,5 @@ app_name = "learner_home" urlpatterns = [ re_path(r"^init/?", views.InitializeView.as_view(), name="initialize"), path("mock/", include("lms.djangoapps.learner_home.mock.urls")), + path("", include(rest_api_urls)), ] diff --git a/lms/djangoapps/learner_home/views.py b/lms/djangoapps/learner_home/views.py index 1fa115be58..b97f9a20f4 100644 --- a/lms/djangoapps/learner_home/views.py +++ b/lms/djangoapps/learner_home/views.py @@ -81,17 +81,16 @@ def get_platform_settings(): @function_trace("get_user_account_confirmation_info") def get_user_account_confirmation_info(user): """Determine if a user needs to verify their account and related URL info""" - - activation_email_support_link = ( + send_activation_email_url = ( configuration_helpers.get_value( - "ACTIVATION_EMAIL_SUPPORT_LINK", settings.ACTIVATION_EMAIL_SUPPORT_LINK + "SEND_ACTIVATION_EMAIL_URL", settings.SEND_ACTIVATION_EMAIL_URL ) or settings.SUPPORT_SITE_LINK ) email_confirmation = { "isNeeded": not user.is_active, - "sendEmailUrl": activation_email_support_link, + "sendEmailUrl": send_activation_email_url, } return email_confirmation diff --git a/lms/djangoapps/mobile_api/course_info/serializers.py b/lms/djangoapps/mobile_api/course_info/serializers.py index 572afbfbef..8e32d2c5b1 100644 --- a/lms/djangoapps/mobile_api/course_info/serializers.py +++ b/lms/djangoapps/mobile_api/course_info/serializers.py @@ -16,6 +16,7 @@ from lms.djangoapps.courseware.access_utils import check_course_open_for_learner from lms.djangoapps.courseware.courses import get_assignments_completions from lms.djangoapps.mobile_api.course_info.utils import get_user_certificate_download_url from lms.djangoapps.mobile_api.users.serializers import ModeSerializer +from lms.djangoapps.mobile_api.utils import get_course_organization_logo from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.features.course_duration_limits.access import get_user_course_expiration_date @@ -28,6 +29,7 @@ class CourseInfoOverviewSerializer(serializers.ModelSerializer): name = serializers.CharField(source='display_name') number = serializers.CharField(source='display_number_with_default') org = serializers.CharField(source='display_org_with_default') + org_logo = serializers.SerializerMethodField() is_self_paced = serializers.BooleanField(source='self_paced') media = serializers.SerializerMethodField() course_sharing_utm_parameters = serializers.SerializerMethodField() @@ -41,6 +43,7 @@ class CourseInfoOverviewSerializer(serializers.ModelSerializer): 'name', 'number', 'org', + 'org_logo', 'start', 'start_display', 'start_type', @@ -85,6 +88,9 @@ class CourseInfoOverviewSerializer(serializers.ModelSerializer): """ return get_assignments_completions(obj.id, self.context.get('user')) + def get_org_logo(self, course_overview): + return get_course_organization_logo(course_overview.id) + class MobileCourseEnrollmentSerializer(serializers.ModelSerializer): """ diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py index 0a173863db..caa6a991dc 100644 --- a/lms/djangoapps/mobile_api/course_info/views.py +++ b/lms/djangoapps/mobile_api/course_info/views.py @@ -321,10 +321,16 @@ class BlocksInfoInCourseView(BlocksInCourseView): request - Django request object """ - response = super().list(request, kwargs) + api_version = self.kwargs.get('api_version') + if api_version is None or api_version in ['v0.5', 'v1', 'v2', 'v3']: + response = super().list(request, kwargs) + else: + # The previous implementation unintentionally passed kwargs as the positional argument to + # `hide_access_denial`, leading to potential issues. This new condition for version > v3 removes that risk + # while preserving the original behavior for older clients. + response = super().list(request) if request.GET.get('return_type', 'dict') == 'dict': - api_version = self.kwargs.get('api_version') course_id = request.query_params.get('course_id', None) course_key = CourseKey.from_string(course_id) course_overview = CourseOverview.get_from_id(course_key) diff --git a/lms/djangoapps/mobile_api/users/serializers.py b/lms/djangoapps/mobile_api/users/serializers.py index d8de11e50f..95db34f5ed 100644 --- a/lms/djangoapps/mobile_api/users/serializers.py +++ b/lms/djangoapps/mobile_api/users/serializers.py @@ -17,7 +17,7 @@ from lms.djangoapps.certificates.api import certificate_downloadable_status from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.courses import get_assignments_completions, get_past_and_future_course_assignments from lms.djangoapps.course_home_api.dates.serializers import DateSummarySerializer -from lms.djangoapps.mobile_api.utils import API_V4 +from lms.djangoapps.mobile_api.utils import API_V4, get_course_organization_logo from openedx.features.course_duration_limits.access import get_user_course_expiration_date from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem @@ -39,6 +39,7 @@ class CourseOverviewField(serializers.RelatedField): # lint-amnesty, pylint: di 'name': course_overview.display_name, 'number': course_overview.display_number_with_default, 'org': course_overview.display_org_with_default, + 'org_logo': get_course_organization_logo(course_id), # dates 'start': course_overview.start, diff --git a/lms/djangoapps/mobile_api/users/tests.py b/lms/djangoapps/mobile_api/users/tests.py index 2cfefaa058..7c4b3e437d 100644 --- a/lms/djangoapps/mobile_api/users/tests.py +++ b/lms/djangoapps/mobile_api/users/tests.py @@ -285,6 +285,35 @@ class TestUserEnrollmentApi(UrlResetMixin, MobileAPITestCase, MobileAuthUserTest for entry in courses: assert entry['course']['org'] == 'edX' + @ddt.data(API_V05, API_V1, API_V2) + @patch('lms.djangoapps.mobile_api.users.views.get_current_site_orgs', return_value=['edX']) + def test_filter_by_current_site_orgs(self, api_version, get_current_site_orgs_mock): + self.login() + + # Create list of courses with various organizations + courses = [ + CourseFactory.create(org='edX', mobile_available=True), + CourseFactory.create(org='edX', mobile_available=True), + CourseFactory.create(org='edX', mobile_available=True, visible_to_staff_only=True), + CourseFactory.create(org='Proversity.org', mobile_available=True), + CourseFactory.create(org='MITx', mobile_available=True), + CourseFactory.create(org='HarvardX', mobile_available=True), + ] + + # Enroll in all the courses + for course in courses: + self.enroll(course.id) + + response = self.api_response(api_version=api_version) + courses = response.data['enrollments'] if api_version == API_V2 else response.data + + # Test for 3 expected courses + self.assertEqual(len(courses), 3) + + # Verify only edX courses are returned + for entry in courses: + self.assertEqual(entry['course']['org'], 'edX') + def create_enrollment(self, expired): """ Create an enrollment diff --git a/lms/djangoapps/mobile_api/users/views.py b/lms/djangoapps/mobile_api/users/views.py index c86f3add9d..324db83a37 100644 --- a/lms/djangoapps/mobile_api/users/views.py +++ b/lms/djangoapps/mobile_api/users/views.py @@ -40,6 +40,7 @@ from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.courseware.views.index import save_positions_recursively_up from lms.djangoapps.mobile_api.models import MobileConfig from lms.djangoapps.mobile_api.utils import API_V1, API_V05, API_V2, API_V3, API_V4 +from openedx.core.djangoapps.site_configuration.helpers import get_current_site_orgs from openedx.features.course_duration_limits.access import check_course_expired from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order @@ -350,6 +351,11 @@ class UserCourseEnrollmentsList(generics.ListAPIView): """ Check course org matches request org param or no param provided """ + current_orgs = get_current_site_orgs() + + if current_orgs and course_org not in current_orgs: + return False + return check_org is None or (check_org.lower() == course_org.lower()) def get_serializer_context(self): diff --git a/lms/djangoapps/mobile_api/utils.py b/lms/djangoapps/mobile_api/utils.py index 9204b27ab4..8b2521337f 100644 --- a/lms/djangoapps/mobile_api/utils.py +++ b/lms/djangoapps/mobile_api/utils.py @@ -1,6 +1,7 @@ """ Common utility methods for Mobile APIs. """ +from organizations import api as organizations_api API_V05 = 'v0.5' API_V1 = 'v1' @@ -12,3 +13,16 @@ API_V4 = 'v4' def parsed_version(version): """ Converts string X.X.X.Y to int tuple (X, X, X) """ return tuple(map(int, (version.split(".")[:3]))) + + +def get_course_organization_logo(course_key): + """ + Get organization logo of given course key. + """ + organization_logo = None + organizations = organizations_api.get_course_organizations(course_key=course_key) + if organizations: + organization = organizations[0] + organization_logo = organization.get('logo', None) + + return str(organization_logo.url) if organization_logo else '' diff --git a/lms/djangoapps/ora_staff_grader/views.py b/lms/djangoapps/ora_staff_grader/views.py index 16c84ca27a..50a1e03196 100644 --- a/lms/djangoapps/ora_staff_grader/views.py +++ b/lms/djangoapps/ora_staff_grader/views.py @@ -105,7 +105,7 @@ class InitializeView(StaffGraderBaseView): # This toggle is documented on the edx-ora2 repo in openassessment/xblock/config_mixin.py # Note: Do not copy this practice of directly using a toggle from a library. # Instead, see docs for exposing a wrapper api: - # https://edx.readthedocs.io/projects/edx-toggles/en/latest/how_to/implement_the_right_toggle_type.html#using-other-toggles pylint: disable=line-too-long + # https://docs.openedx.org/projects/edx-toggles/en/latest/how_to/implement_the_right_toggle_type.html#using-other-toggles pylint: disable=line-too-long # pylint: disable=toggle-missing-annotation enhanced_staff_grader_flag = CourseWaffleFlag( f"{WAFFLE_NAMESPACE}.{ENHANCED_STAFF_GRADER}", diff --git a/lms/djangoapps/program_enrollments/management/commands/send_program_course_nudge_email.py b/lms/djangoapps/program_enrollments/management/commands/send_program_course_nudge_email.py index c095b2d161..af2bfdca75 100644 --- a/lms/djangoapps/program_enrollments/management/commands/send_program_course_nudge_email.py +++ b/lms/djangoapps/program_enrollments/management/commands/send_program_course_nudge_email.py @@ -15,6 +15,7 @@ from django.contrib.auth import get_user_model from django.contrib.sites.models import Site from django.core.management import BaseCommand from django.utils import timezone +from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import CourseLocator from common.djangoapps.track import segment @@ -140,7 +141,9 @@ class Command(BaseCommand): ) break for course_run in candidate_course['course_runs']: - if self.valid_course_run(course_run) and course_run['key'] != completed_course_id: + course_org = CourseKey.from_string(course_run['key']).org + if self.valid_course_run(course_run) and course_run['key'] != completed_course_id \ + and course_org not in settings.DISABLED_ORGS_FOR_PROGRAM_NUDGE: return program, course_run, candidate_course return None, None, None diff --git a/lms/djangoapps/static_template_view/views.py b/lms/djangoapps/static_template_view/views.py index a788f77a95..f8962897c0 100644 --- a/lms/djangoapps/static_template_view/views.py +++ b/lms/djangoapps/static_template_view/views.py @@ -92,8 +92,7 @@ def render_press_release(request, slug): resp = render_to_response('static_templates/press_releases/' + template, {}) except TemplateDoesNotExist: raise Http404 # lint-amnesty, pylint: disable=raise-missing-from - else: - return resp + return resp @fix_crum_request diff --git a/lms/djangoapps/support/static/support/jsx/.eslintrc.js b/lms/djangoapps/support/static/support/jsx/.eslintrc.js deleted file mode 100644 index 24039825bd..0000000000 --- a/lms/djangoapps/support/static/support/jsx/.eslintrc.js +++ /dev/null @@ -1,19 +0,0 @@ -module.exports = { - extends: '@edx/eslint-config', - root: true, - settings: { - 'import/resolver': { - webpack: { - config: 'webpack.dev.config.js', - }, - }, - }, - rules: { - 'import/prefer-default-export': 'off', - indent: ['error', 4], - 'react/jsx-indent': ['error', 4], - 'react/jsx-indent-props': ['error', 4], - 'import/extensions': 'off', - 'import/no-unresolved': 'off', - }, -}; diff --git a/lms/djangoapps/support/static/support/jsx/entitlements/data/api/client.js b/lms/djangoapps/support/static/support/jsx/entitlements/data/api/client.js index 0431298041..2e71653a82 100644 --- a/lms/djangoapps/support/static/support/jsx/entitlements/data/api/client.js +++ b/lms/djangoapps/support/static/support/jsx/entitlements/data/api/client.js @@ -1,4 +1,3 @@ -import 'whatwg-fetch'; import Cookies from 'js-cookie'; import { entitlementApi } from './endpoints'; diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py index 147532bf2e..4abc4508ea 100644 --- a/lms/djangoapps/support/tests/test_views.py +++ b/lms/djangoapps/support/tests/test_views.py @@ -23,7 +23,7 @@ from edx_proctoring.models import ProctoredExam from edx_proctoring.runtime import set_runtime_service from edx_proctoring.statuses import ProctoredExamStudentAttemptStatus from edx_proctoring.tests.test_services import MockLearningSequencesService, MockScheduleItemData -from edx_proctoring.tests.utils import ProctoredExamTestCase +from edx_proctoring.tests.test_utils.utils import ProctoredExamTestCase from oauth2_provider.models import AccessToken, RefreshToken from opaque_keys.edx.locator import BlockUsageLocator from organizations.tests.factories import OrganizationFactory diff --git a/lms/djangoapps/support/views/manage_user.py b/lms/djangoapps/support/views/manage_user.py index e29652a905..df2527fce4 100644 --- a/lms/djangoapps/support/views/manage_user.py +++ b/lms/djangoapps/support/views/manage_user.py @@ -11,6 +11,7 @@ from django.utils.translation import gettext as _ from django.views.generic import View from rest_framework.generics import GenericAPIView +from common.djangoapps.track import segment from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.student.models import UserPasswordToggleHistory from common.djangoapps.util.json_request import JsonResponse @@ -76,11 +77,13 @@ class ManageUserDetailView(GenericAPIView): user=user, comment=comment, created_by=request.user, disabled=True ) retire_dot_oauth2_models(user) + segment.identify(user.id, {'is_disabled': 'true'}) else: user.set_password(generate_password(length=25)) UserPasswordToggleHistory.objects.create( user=user, comment=comment, created_by=request.user, disabled=False ) + segment.identify(user.id, {'is_disabled': 'false'}) user.save() if user.has_usable_password(): diff --git a/lms/djangoapps/teams/static/teams/js/.eslintrc.json b/lms/djangoapps/teams/static/teams/js/.eslintrc.json deleted file mode 100644 index 0ab11857ae..0000000000 --- a/lms/djangoapps/teams/static/teams/js/.eslintrc.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "rules": { - "comma-dangle": "off", - "object-curly-spacing": "off", - "no-underscore-dangle": "off" - } -} diff --git a/lms/djangoapps/utils.py b/lms/djangoapps/utils.py index 23245e9286..cabf13b84f 100644 --- a/lms/djangoapps/utils.py +++ b/lms/djangoapps/utils.py @@ -27,7 +27,7 @@ def _get_key(key_or_id, key_cls): ) -def get_braze_client(): +def get_email_client(): """ Returns a Braze client. """ if not BrazeClient: return None diff --git a/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py b/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py index 04f75ef424..0bfef6d0ac 100644 --- a/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py +++ b/lms/djangoapps/verify_student/management/commands/send_verification_expiry_email.py @@ -188,10 +188,11 @@ class Command(BaseCommand): return True site = Site.objects.get_current() + account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') message_context = get_base_template_context(site) message_context.update({ 'platform_name': settings.PLATFORM_NAME, - 'lms_verification_link': f'{settings.ACCOUNT_MICROFRONTEND_URL}/id-verification', + 'lms_verification_link': f'{account_base_url}/id-verification', 'help_center_link': settings.ID_VERIFICATION_SUPPORT_LINK }) diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index 95dbccf0d5..5caede3dab 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -251,7 +251,8 @@ class IDVerificationService: Returns a string: Returns URL for IDV on Account Microfrontend """ - location = f'{settings.ACCOUNT_MICROFRONTEND_URL}/id-verification' + account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') + location = f'{account_base_url}/id-verification' if course_id: location += f'?course_id={quote(str(course_id))}' diff --git a/lms/djangoapps/verify_student/signals/signals.py b/lms/djangoapps/verify_student/signals/signals.py index c03d5f2631..a2f452e832 100644 --- a/lms/djangoapps/verify_student/signals/signals.py +++ b/lms/djangoapps/verify_student/signals/signals.py @@ -41,6 +41,7 @@ def emit_idv_attempt_created_event(attempt_id, user, status, name, expiration_da user_data = _create_user_data(user) # .. event_implemented_name: IDV_ATTEMPT_CREATED + # .. event_type: org.openedx.learning.idv_attempt.created.v1 IDV_ATTEMPT_CREATED.send_event( idv_attempt=VerificationAttemptData( attempt_id=attempt_id, @@ -60,6 +61,7 @@ def emit_idv_attempt_pending_event(attempt_id, user, status, name, expiration_da user_data = _create_user_data(user) # .. event_implemented_name: IDV_ATTEMPT_PENDING + # .. event_type: org.openedx.learning.idv_attempt.pending.v1 IDV_ATTEMPT_PENDING.send_event( idv_attempt=VerificationAttemptData( attempt_id=attempt_id, @@ -79,6 +81,7 @@ def emit_idv_attempt_approved_event(attempt_id, user, status, name, expiration_d user_data = _create_user_data(user) # .. event_implemented_name: IDV_ATTEMPT_APPROVED + # .. event_type: org.openedx.learning.idv_attempt.approved.v1 IDV_ATTEMPT_APPROVED.send_event( idv_attempt=VerificationAttemptData( attempt_id=attempt_id, @@ -98,6 +101,7 @@ def emit_idv_attempt_denied_event(attempt_id, user, status, name, expiration_dat user_data = _create_user_data(user) # .. event_implemented_name: IDV_ATTEMPT_DENIED + # .. event_type: org.openedx.learning.idv_attempt.denied.v1 IDV_ATTEMPT_DENIED.send_event( idv_attempt=VerificationAttemptData( attempt_id=attempt_id, diff --git a/lms/djangoapps/verify_student/views.py b/lms/djangoapps/verify_student/views.py index 1b6a47bee8..deda08e0c7 100644 --- a/lms/djangoapps/verify_student/views.py +++ b/lms/djangoapps/verify_student/views.py @@ -1128,7 +1128,8 @@ def results_callback(request): # lint-amnesty, pylint: disable=too-many-stateme log.info("[COSMO-184] Denied verification for receipt_id={receipt_id}.".format(receipt_id=receipt_id)) attempt.deny(json.dumps(reason), error_code=error_code) - reverify_url = f'{settings.ACCOUNT_MICROFRONTEND_URL}/id-verification' + account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') + reverify_url = f'{account_base_url}/id-verification' verification_status_email_vars['reasons'] = reason verification_status_email_vars['reverify_url'] = reverify_url verification_status_email_vars['faq_url'] = settings.ID_VERIFICATION_SUPPORT_LINK diff --git a/lms/envs/analytics_exporter.py b/lms/envs/analytics_exporter.py deleted file mode 100644 index a6b53a2711..0000000000 --- a/lms/envs/analytics_exporter.py +++ /dev/null @@ -1,13 +0,0 @@ -""" -Settings for running management commands for the Analytics Exporter. - -The Analytics Exporter jobs run edxapp management commands using production -settings and configuration, however they currently DO NOT use edxapp production -environments (such as edxapp Amazon AMIs or Docker images) where theme files -get installed. As a result we must disable comprehensive theming or else -startup checks from the theming app will throw an error due to missing themes. -""" - -from .production import * # pylint: disable=wildcard-import, unused-wildcard-import - -ENABLE_COMPREHENSIVE_THEMING = False diff --git a/lms/envs/common.py b/lms/envs/common.py index 29e90eddfb..d635ac23e4 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -64,13 +64,10 @@ from enterprise.constants import ( DEFAULT_ENTERPRISE_ENROLLMENT_INTENTIONS_ROLE, ) -from openedx.core.constants import COURSE_KEY_REGEX, COURSE_KEY_PATTERN, COURSE_ID_PATTERN -from openedx.core.djangoapps.theming.helpers_dirs import ( - get_themes_unchecked, - get_theme_base_dirs_from_settings -) -from openedx.core.lib.derived import derived, derived_collection_entry +from openedx.core.lib.derived import Derived from openedx.core.release import doc_version +from openedx.envs.common import * # pylint: disable=wildcard-import + from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin ################################### FEATURES ################################### @@ -320,7 +317,7 @@ FEATURES = { # .. toggle_default: False # .. toggle_description: Set to True to enable Custom Courses for edX, a feature that is more commonly known as # CCX. Documentation for configuring and using this feature is available at - # https://edx.readthedocs.io/projects/open-edx-ca/en/latest/set_up_course/custom_courses.html + # https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/enable_ccx.html # .. toggle_warning: When set to true, 'lms.djangoapps.ccx.overrides.CustomCoursesForEdxOverrideProvider' will # be added to MODULESTORE_FIELD_OVERRIDE_PROVIDERS # .. toggle_use_cases: opt_in, circuit_breaker @@ -633,7 +630,7 @@ FEATURES = { # .. toggle_description: Set to True to enable course certificates on your instance of Open edX. # .. toggle_warning: You must enable this feature flag in both Studio and the LMS and complete the configuration tasks # described here: - # https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/enable_certificates.html pylint: disable=line-too-long,useless-suppression + # https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/enable_certificates.html pylint: disable=line-too-long,useless-suppression # .. toggle_use_cases: open_edx # .. toggle_creation_date: 2015-03-13 # .. toggle_target_removal_date: None @@ -706,7 +703,7 @@ FEATURES = { # and applications. # .. toggle_warning: After enabling this feature flag there are multiple steps involved to configure edX # as LTI provider. Full guide is available here: - # https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/lti/index.html + # https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/lti/index.html # .. toggle_use_cases: open_edx # .. toggle_creation_date: 2015-04-24 # .. toggle_tickets: https://github.com/openedx/edx-platform/pull/7689 @@ -881,6 +878,15 @@ FEATURES = { # toggle does not have a target removal date. 'ENABLE_AUTHN_MICROFRONTEND': os.environ.get("EDXAPP_ENABLE_AUTHN_MFE", False), + # .. toggle_name: FEATURES['ENABLE_CATALOG_MICROFRONTEND'] + # .. toggle_implementation: DjangoSetting + # .. toggle_default: False + # .. toggle_description: Supports staged rollout of a new micro-frontend-based implementation of the catalog. + # .. toggle_use_cases: temporary + # .. toggle_creation_date: 2025-05-15 + # .. toggle_target_removal_date: 2025-11-01 + 'ENABLE_CATALOG_MICROFRONTEND': False, + ### ORA Feature Flags ### # .. toggle_name: FEATURES['ENABLE_ORA_ALL_FILE_URLS'] # .. toggle_implementation: DjangoSetting @@ -1059,18 +1065,6 @@ FEATURES = { # .. toggle_creation_date: 2024-04-24 'ENABLE_COURSEWARE_SEARCH_VERIFIED_ENROLLMENT_REQUIRED': False, - # .. toggle_name: FEATURES['ENABLE_BLAKE2B_HASHING'] - # .. toggle_implementation: DjangoSetting - # .. toggle_default: False - # .. toggle_description: Enables the memcache to use the blake2b hash algorithm instead of depreciated md4 for keys - # exceeding 250 characters - # .. toggle_use_cases: open_edx - # .. toggle_creation_date: 2024-04-02 - # .. toggle_target_removal_date: 2024-12-09 - # .. toggle_warning: For consistency, keep the value in sync with the setting of the same name in the LMS and CMS. - # .. toggle_tickets: https://github.com/openedx/edx-platform/pull/34442 - 'ENABLE_BLAKE2B_HASHING': False, - # .. toggle_name: FEATURES['BADGES_ENABLED'] # .. toggle_implementation: DjangoSetting # .. toggle_default: False @@ -1086,15 +1080,9 @@ FEATURES = { # e.g. COURSE_BLOCKS_API_EXTRA_FIELDS = [ ('course', 'other_course_settings'), ("problem", "weight") ] COURSE_BLOCKS_API_EXTRA_FIELDS = [] - -ASSET_IGNORE_REGEX = r"(^\._.*$)|(^\.DS_Store$)|(^.*~$)" - # Used for A/B testing DEFAULT_GROUPS = [] -# If this is true, random scores will be generated for the purpose of debugging the profile graphs -GENERATE_PROFILE_SCORES = False - # .. setting_name: GRADEBOOK_FREEZE_DAYS # .. setting_default: 30 # .. setting_description: Sets the number of days after which the gradebook will freeze following the course's end. @@ -1285,9 +1273,6 @@ OAUTH2_PROVIDER = { 'REQUEST_APPROVAL_PROMPT': 'auto_even_if_expired', 'ERROR_RESPONSE_WITH_SCOPES': True, } -# This is required for the migrations in oauth_dispatch.models -# otherwise it fails saying this attribute is not present in Settings -OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application' # Automatically clean up edx-django-oauth2-provider tokens on use OAUTH_DELETE_EXPIRED = True @@ -1327,19 +1312,6 @@ MAKO_TEMPLATE_DIRS_BASE = [ OPENEDX_ROOT / 'features' / 'course_experience' / 'templates', ] - -def _make_mako_template_dirs(settings): - """ - Derives the final Mako template directories list from other settings. - """ - if settings.ENABLE_COMPREHENSIVE_THEMING: - themes_dirs = get_theme_base_dirs_from_settings(settings.COMPREHENSIVE_THEME_DIRS) - for theme in get_themes_unchecked(themes_dirs, settings.PROJECT_ROOT): - if theme.themes_base_dir not in settings.MAKO_TEMPLATE_DIRS_BASE: - settings.MAKO_TEMPLATE_DIRS_BASE.insert(0, theme.themes_base_dir) - return settings.MAKO_TEMPLATE_DIRS_BASE - - CONTEXT_PROCESSORS = [ 'django.template.context_processors.request', 'django.template.context_processors.static', @@ -1367,9 +1339,7 @@ CONTEXT_PROCESSORS = [ 'lms.djangoapps.mobile_api.context_processor.is_from_mobile_app', # Context processor necessary for the survey report message appear on the admin site - 'openedx.features.survey_report.context_processors.admin_extra_context' - - + 'openedx.features.survey_report.context_processors.admin_extra_context', ] # Django templating @@ -1407,7 +1377,7 @@ TEMPLATES = [ # Don't look for template source files inside installed applications. 'APP_DIRS': False, # Instead, look for template source files in these dirs. - 'DIRS': _make_mako_template_dirs, + 'DIRS': Derived(make_mako_template_dirs), # Options specific to this backend. 'OPTIONS': { 'context_processors': CONTEXT_PROCESSORS, @@ -1416,7 +1386,6 @@ TEMPLATES = [ } }, ] -derived_collection_entry('TEMPLATES', 1, 'DIRS') DEFAULT_TEMPLATE_ENGINE = TEMPLATES[0] DEFAULT_TEMPLATE_ENGINE_DIRS = DEFAULT_TEMPLATE_ENGINE['DIRS'][:] @@ -1518,36 +1487,10 @@ WIKI_ENABLED = True ### -COURSE_MODE_DEFAULTS = { - 'android_sku': None, - 'bulk_sku': None, - 'currency': 'usd', - 'description': None, - 'expiration_datetime': None, - 'ios_sku': None, - 'min_price': 0, - 'name': _('Audit'), - 'sku': None, - 'slug': 'audit', - 'suggested_prices': '', -} - # IP addresses that are allowed to reload the course, etc. # TODO (vshnayder): Will probably need to change as we get real access control in. LMS_MIGRATION_ALLOWED_IPS = [] -USAGE_KEY_PATTERN = r'(?P(?:i4x://?[^/]+/[^/]+/[^/]+/[^@]+(?:@[^/]+)?)|(?:[^/]+))' -ASSET_KEY_PATTERN = r'(?P(?:/?c4x(:/)?/[^/]+/[^/]+/[^/]+/[^@]+(?:@[^/]+)?)|(?:[^/]+))' -USAGE_ID_PATTERN = r'(?P(?:i4x://?[^/]+/[^/]+/[^/]+/[^@]+(?:@[^/]+)?)|(?:[^/]+))' - - -# The space is required for space-dependent languages like Arabic and Farsi. -# However, backward compatibility with Ficus older releases is still maintained (space is still not valid) -# in the AccountCreationForm and the user_api through the ENABLE_UNICODE_USERNAME feature flag. -USERNAME_REGEX_PARTIAL = r'[\w .@_+-]+' -USERNAME_PATTERN = fr'(?P{USERNAME_REGEX_PARTIAL})' - - ############################## EVENT TRACKING ################################# LMS_SEGMENT_KEY = None @@ -1637,10 +1580,6 @@ OPTIMIZELY_FULLSTACK_SDK_KEY = None ######################## HOTJAR ########################### HOTJAR_SITE_ID = 00000 -######################## ALGOLIA SEARCH ########################### -ALGOLIA_APP_ID = None -ALGOLIA_SEARCH_API_KEY = None - ######################## subdomain specific settings ########################### COURSE_LISTINGS = {} @@ -1746,7 +1685,7 @@ MODULESTORE = { 'DOC_STORE_CONFIG': DOC_STORE_CONFIG, 'OPTIONS': { 'default_class': 'xmodule.hidden_block.HiddenBlock', - 'fs_root': lambda settings: settings.DATA_DIR, + 'fs_root': Derived(lambda settings: settings.DATA_DIR), 'render_template': 'common.djangoapps.edxmako.shortcuts.render_to_string', } }, @@ -1756,7 +1695,7 @@ MODULESTORE = { 'DOC_STORE_CONFIG': DOC_STORE_CONFIG, 'OPTIONS': { 'default_class': 'xmodule.hidden_block.HiddenBlock', - 'fs_root': lambda settings: settings.DATA_DIR, + 'fs_root': Derived(lambda settings: settings.DATA_DIR), 'render_template': 'common.djangoapps.edxmako.shortcuts.render_to_string', } } @@ -1870,7 +1809,6 @@ CODE_JAIL_REST_SERVICE_READ_TIMEOUT = 3.5 # time in seconds ############################### DJANGO BUILT-INS ############################### # Change DEBUG in your environment settings files, not here DEBUG = False -USE_TZ = True SESSION_COOKIE_SECURE = False SESSION_SAVE_EVERY_REQUEST = False SESSION_SERIALIZER = 'openedx.core.lib.session_serializers.PickleSerializer' @@ -1928,6 +1866,10 @@ MANAGERS = ADMINS # Static content STATIC_URL = '/static/' STATIC_ROOT = os.environ.get('STATIC_ROOT_LMS', ENV_ROOT / "staticfiles") +# .. setting_name: STATIC_URL_BASE +# .. setting_default: "/static/" +# .. setting_description: The LMS uses this to construct ``STATIC_URL`` by appending +# a slash (if needed). STATIC_URL_BASE = '/static/' STATICFILES_DIRS = [ @@ -1941,106 +1883,11 @@ STATICFILES_DIRS = [ ] FAVICON_PATH = 'images/favicon.ico' -DEFAULT_COURSE_ABOUT_IMAGE_URL = 'images/pencils.jpg' - -# User-uploaded content -MEDIA_ROOT = '/edx/var/edxapp/media/' -MEDIA_URL = '/media/' # Locale/Internationalization CELERY_TIMEZONE = 'UTC' TIME_ZONE = 'UTC' LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html -# these languages display right to left -LANGUAGES_BIDI = ("he", "ar", "fa", "ur", "fa-ir", "rtl") - -LANGUAGE_COOKIE_NAME = "openedx-language-preference" - -# Sourced from http://www.localeplanet.com/icu/ and wikipedia -LANGUAGES = [ - ('en', 'English'), - ('rtl', 'Right-to-Left Test Language'), - ('eo', 'Dummy Language (Esperanto)'), # Dummy languaged used for testing - - ('am', 'አማርኛ'), # Amharic - ('ar', 'العربية'), # Arabic - ('az', 'azərbaycanca'), # Azerbaijani - ('bg-bg', 'български (България)'), # Bulgarian (Bulgaria) - ('bn-bd', 'বাংলা (বাংলাদেশ)'), # Bengali (Bangladesh) - ('bn-in', 'বাংলা (ভারত)'), # Bengali (India) - ('bs', 'bosanski'), # Bosnian - ('ca', 'Català'), # Catalan - ('ca@valencia', 'Català (València)'), # Catalan (Valencia) - ('cs', 'Čeština'), # Czech - ('cy', 'Cymraeg'), # Welsh - ('da', 'dansk'), # Danish - ('de-de', 'Deutsch (Deutschland)'), # German (Germany) - ('el', 'Ελληνικά'), # Greek - ('en-uk', 'English (United Kingdom)'), # English (United Kingdom) - ('en@lolcat', 'LOLCAT English'), # LOLCAT English - ('en@pirate', 'Pirate English'), # Pirate English - ('es-419', 'Español (Latinoamérica)'), # Spanish (Latin America) - ('es-ar', 'Español (Argentina)'), # Spanish (Argentina) - ('es-ec', 'Español (Ecuador)'), # Spanish (Ecuador) - ('es-es', 'Español (España)'), # Spanish (Spain) - ('es-mx', 'Español (México)'), # Spanish (Mexico) - ('es-pe', 'Español (Perú)'), # Spanish (Peru) - ('et-ee', 'Eesti (Eesti)'), # Estonian (Estonia) - ('eu-es', 'euskara (Espainia)'), # Basque (Spain) - ('fa', 'فارسی'), # Persian - ('fa-ir', 'فارسی (ایران)'), # Persian (Iran) - ('fi-fi', 'Suomi (Suomi)'), # Finnish (Finland) - ('fil', 'Filipino'), # Filipino - ('fr', 'Français'), # French - ('gl', 'Galego'), # Galician - ('gu', 'ગુજરાતી'), # Gujarati - ('he', 'עברית'), # Hebrew - ('hi', 'हिन्दी'), # Hindi - ('hr', 'hrvatski'), # Croatian - ('hu', 'magyar'), # Hungarian - ('hy-am', 'Հայերեն (Հայաստան)'), # Armenian (Armenia) - ('id', 'Bahasa Indonesia'), # Indonesian - ('it-it', 'Italiano (Italia)'), # Italian (Italy) - ('ja-jp', '日本語 (日本)'), # Japanese (Japan) - ('kk-kz', 'қазақ тілі (Қазақстан)'), # Kazakh (Kazakhstan) - ('km-kh', 'ភាសាខ្មែរ (កម្ពុជា)'), # Khmer (Cambodia) - ('kn', 'ಕನ್ನಡ'), # Kannada - ('ko-kr', '한국어 (대한민국)'), # Korean (Korea) - ('lt-lt', 'Lietuvių (Lietuva)'), # Lithuanian (Lithuania) - ('ml', 'മലയാളം'), # Malayalam - ('mn', 'Монгол хэл'), # Mongolian - ('mr', 'मराठी'), # Marathi - ('ms', 'Bahasa Melayu'), # Malay - ('nb', 'Norsk bokmål'), # Norwegian Bokmål - ('ne', 'नेपाली'), # Nepali - ('nl-nl', 'Nederlands (Nederland)'), # Dutch (Netherlands) - ('or', 'ଓଡ଼ିଆ'), # Oriya - ('pl', 'Polski'), # Polish - ('pt-br', 'Português (Brasil)'), # Portuguese (Brazil) - ('pt-pt', 'Português (Portugal)'), # Portuguese (Portugal) - ('ro', 'română'), # Romanian - ('ru', 'Русский'), # Russian - ('si', 'සිංහල'), # Sinhala - ('sk', 'Slovenčina'), # Slovak - ('sl', 'Slovenščina'), # Slovenian - ('sq', 'shqip'), # Albanian - ('sr', 'Српски'), # Serbian - ('sv', 'svenska'), # Swedish - ('sw', 'Kiswahili'), # Swahili - ('ta', 'தமிழ்'), # Tamil - ('te', 'తెలుగు'), # Telugu - ('th', 'ไทย'), # Thai - ('tr-tr', 'Türkçe (Türkiye)'), # Turkish (Turkey) - ('uk', 'Українська'), # Ukranian - ('ur', 'اردو'), # Urdu - ('vi', 'Tiếng Việt'), # Vietnamese - ('uz', 'Ўзбек'), # Uzbek - ('zh-cn', '中文 (简体)'), # Chinese (China) - ('zh-hk', '中文 (香港)'), # Chinese (Hong Kong) - ('zh-tw', '中文 (台灣)'), # Chinese (Taiwan) -] - -LANGUAGE_DICT = dict(LANGUAGES) # Languages supported for custom course certificate templates CERTIFICATE_TEMPLATE_LANGUAGES = { @@ -2053,28 +1900,12 @@ USE_L10N = True STATICI18N_FILENAME_FUNCTION = 'statici18n.utils.legacy_filename' STATICI18N_ROOT = PROJECT_ROOT / "static" -STATICI18N_OUTPUT_DIR = "js/i18n" - - -# Localization strings (e.g. django.po) are under these directories -def _make_locale_paths(settings): # pylint: disable=missing-function-docstring - locale_paths = list(settings.PREPEND_LOCALE_PATHS) - locale_paths += [settings.REPO_ROOT + '/conf/locale'] # edx-platform/conf/locale/ - - if settings.ENABLE_COMPREHENSIVE_THEMING: - # Add locale paths to settings for comprehensive theming. - for locale_path in settings.COMPREHENSIVE_THEME_LOCALE_PATHS: - locale_paths += (path(locale_path), ) - return locale_paths -LOCALE_PATHS = _make_locale_paths -derived('LOCALE_PATHS') # Messages MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' # Guidelines for translators -TRANSLATORS_GUIDE = 'https://edx.readthedocs.org/projects/edx-developer-guide/en/latest/' \ - 'conventions/internationalization/i18n_translators_guide.html' +TRANSLATORS_GUIDE = 'https://docs.openedx.org/en/latest/translators/index.html' #################################### AWS ####################################### # The number of seconds that a generated URL is valid for. @@ -2197,12 +2028,6 @@ EDXNOTES_CONNECT_TIMEOUT = 0.5 # time in seconds # edx_notes_api service internal endpoint. EDXNOTES_READ_TIMEOUT = 1.5 # time in seconds -########################## Parental controls config ####################### - -# The age at which a learner no longer requires parental consent, or None -# if parental consent is never required. -PARENTAL_CONSENT_AGE_LIMIT = 13 - ######################### Branded Footer ################################### # Constants for the footer used on the site and shared with other sites # (such as marketing and the blog) via the branding API. @@ -2261,9 +2086,6 @@ MIDDLEWARE = [ 'edx_django_utils.monitoring.DeploymentMonitoringMiddleware', 'edx_django_utils.monitoring.FrontendMonitoringMiddleware', - # Before anything that looks at cookies, especially the session middleware - 'openedx.core.djangoapps.cookie_metadata.middleware.CookieNameChange', - # Monitoring and logging for ignored errors 'openedx.core.lib.request_utils.IgnoredErrorMiddleware', @@ -2415,7 +2237,6 @@ base_vendor_js = [ 'js/vendor/url.min.js', 'common/js/vendor/underscore.js', 'common/js/vendor/underscore.string.js', - 'common/js/vendor/picturefill.js', # Make some edX UI Toolkit utilities available in the global "edx" namespace 'edx-ui-toolkit/js/utils/global-loader.js', @@ -2826,33 +2647,8 @@ WEBPACK_LOADER = { } } -# .. setting_name: WEBPACK_CONFIG_PATH -# .. setting_default: "webpack.prod.config.js" -# .. setting_description: Path to the Webpack configuration file. Used by Paver scripts. -# .. setting_warning: This Django setting is DEPRECATED! Starting in Sumac, Webpack will no longer -# use Django settings. Please set the WEBPACK_CONFIG_PATH environment variable instead. For details, -# see: https://github.com/openedx/edx-platform/issues/31895 -WEBPACK_CONFIG_PATH = os.environ.get('WEBPACK_CONFIG_PATH', 'webpack.prod.config.js') - -########################## DJANGO DEBUG TOOLBAR ############################### - -# We don't enable Django Debug Toolbar universally, but whenever we do, we want -# to avoid patching settings. Patched settings can cause circular import -# problems: https://django-debug-toolbar.readthedocs.org/en/1.0/installation.html#explicit-setup - -DEBUG_TOOLBAR_PATCH_SETTINGS = False - ################################# CELERY ###################################### -# Until we've tested protocol 2, stay with protocol 1. It should be -# fine to just switch to protocol 2, since we're well past celery -# version 3.1.25 (the first version to support it) but we'll want to -# test this in a stage environment first. -# -# - Docs: https://docs.celeryq.dev/en/stable/history/whatsnew-4.0.html#new-task-message-protocol -# - Ticket: https://github.com/edx/edx-arch-experiments/issues/800 -CELERY_TASK_PROTOCOL = 1 - CELERY_IMPORTS = [ # Since xblock-poll is not a Django app, and XBlocks don't get auto-imported # by celery workers, its tasks will not get auto-discovered: @@ -2940,18 +2736,6 @@ CELERY_BROKER_PASSWORD = 'celery' ############################## HEARTBEAT ###################################### -# Checks run in normal mode by the heartbeat djangoapp -HEARTBEAT_CHECKS = [ - 'openedx.core.djangoapps.heartbeat.default_checks.check_modulestore', - 'openedx.core.djangoapps.heartbeat.default_checks.check_database', -] - -# Other checks to run by default in "extended"/heavy mode -HEARTBEAT_EXTENDED_CHECKS = ( - 'openedx.core.djangoapps.heartbeat.default_checks.check_celery', -) - -HEARTBEAT_CELERY_TIMEOUT = 5 HEARTBEAT_CELERY_ROUTING_KEY = HIGH_PRIORITY_QUEUE ################################ Block Structures ################################### @@ -3079,6 +2863,9 @@ INSTALLED_APPS = [ 'django.contrib.sessions', 'django.contrib.sites', + 'dal', + 'dal_select2', + # Tweaked version of django.contrib.staticfiles 'openedx.core.djangoapps.staticfiles.apps.EdxPlatformStaticFilesConfig', @@ -3296,9 +3083,6 @@ INSTALLED_APPS = [ # Enables default site and redirects 'django_sites_extensions', - # Email marketing integration - 'lms.djangoapps.email_marketing.apps.EmailMarketingConfig', - # additional release utilities to ease automation 'release_util', @@ -3333,7 +3117,6 @@ INSTALLED_APPS = [ 'openedx.features.course_bookmarks', 'openedx.features.course_experience', 'openedx.features.enterprise_support.apps.EnterpriseSupportConfig', - 'openedx.features.learner_profile', 'openedx.features.course_duration_limits', 'openedx.features.content_type_gating', 'openedx.features.discounts', @@ -3401,9 +3184,11 @@ INSTALLED_APPS = [ "openedx_learning.apps.authoring.components", "openedx_learning.apps.authoring.contents", "openedx_learning.apps.authoring.publishing", + "openedx_learning.apps.authoring.units", + "openedx_learning.apps.authoring.subsections", + "openedx_learning.apps.authoring.sections", ] - ######################### CSRF ######################################### # Forwards-compatibility with Django 1.7 @@ -3412,39 +3197,36 @@ CSRF_COOKIE_AGE = 60 * 60 * 24 * 7 * 52 # end users CSRF_COOKIE_SECURE = False CSRF_TRUSTED_ORIGINS = [] -CSRF_TRUSTED_ORIGINS_WITH_SCHEME = [] -CROSS_DOMAIN_CSRF_COOKIE_DOMAIN = '' + +# If setting a cross-domain cookie, it's really important to choose +# a name for the cookie that is DIFFERENT than the cookies used +# by each subdomain. For example, suppose the applications +# at these subdomains are configured to use the following cookie names: +# +# 1) foo.example.com --> "csrftoken" +# 2) baz.example.com --> "csrftoken" +# 3) bar.example.com --> "csrftoken" +# +# For the cross-domain version of the CSRF cookie, you need to choose +# a name DIFFERENT than "csrftoken"; otherwise, the new token configured +# for ".example.com" could conflict with the other cookies, +# non-deterministically causing 403 responses. CROSS_DOMAIN_CSRF_COOKIE_NAME = '' -######################### Django Rest Framework ######################## +# When setting the domain for the "cross-domain" version of the CSRF +# cookie, you should choose something like: ".example.com" +# (note the leading dot), where both the referer and the host +# are subdomains of "example.com". +# +# Browser security rules require that +# the cookie domain matches the domain of the server; otherwise +# the cookie won't get set. And once the cookie gets set, the client +# needs to be on a domain that matches the cookie domain, otherwise +# the client won't be able to read the cookie. +CROSS_DOMAIN_CSRF_COOKIE_DOMAIN = '' -REST_FRAMEWORK = { - # These default classes add observability around endpoints using defaults, and should - # not be used anywhere else. - # Notes on Order: - # 1. `JwtAuthentication` does not check `is_active`, so email validation does not affect it. However, - # `SessionAuthentication` does. These work differently, and order changes in what way, which really stinks. See - # https://github.com/openedx/public-engineering/issues/165 for details. - # 2. `JwtAuthentication` may also update the database based on contents. Since the LMS creates these JWTs, this - # shouldn't have any affect at this time. But it could, when and if another service started creating the JWTs. - 'DEFAULT_AUTHENTICATION_CLASSES': [ - 'openedx.core.djangolib.default_auth_classes.DefaultJwtAuthentication', - 'openedx.core.djangolib.default_auth_classes.DefaultSessionAuthentication', - ], - 'DEFAULT_PAGINATION_CLASS': 'edx_rest_framework_extensions.paginators.DefaultPagination', - 'DEFAULT_RENDERER_CLASSES': ( - 'rest_framework.renderers.JSONRenderer', - ), - 'EXCEPTION_HANDLER': 'openedx.core.lib.request_utils.ignored_error_exception_handler', - 'PAGE_SIZE': 10, - 'URL_FORMAT_OVERRIDE': None, - 'DEFAULT_THROTTLE_RATES': { - 'user': '60/minute', - 'service_user': '800/minute', - 'registration_validation': '30/minute', - 'high_service_user': '2000/minute', - }, -} + +######################### Django Rest Framework ######################## # .. setting_name: REGISTRATION_VALIDATION_RATELIMIT # .. setting_default: 30/7d @@ -3503,6 +3285,7 @@ SUPPORT_SITE_LINK = '' ID_VERIFICATION_SUPPORT_LINK = '' PASSWORD_RESET_SUPPORT_LINK = '' ACTIVATION_EMAIL_SUPPORT_LINK = '' +SEND_ACTIVATION_EMAIL_URL = '' LOGIN_ISSUE_SUPPORT_LINK = '' # .. setting_name: SECURITY_PAGE_URL @@ -3664,7 +3447,6 @@ VERIFICATION_EXPIRY_EMAIL = { "DEFAULT_EMAILS": 2, } -DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH = "verify_student_disable_account_activation_requirement" ################ Enable credit eligibility feature #################### ENABLE_CREDIT_ELIGIBILITY = True @@ -3757,10 +3539,6 @@ REGISTRATION_FIELD_ORDER = [ "terms_of_service", ] -# Optional setting to restrict registration / account creation to only emails -# that match a regex in this list. Set to None to allow any email (default). -REGISTRATION_EMAIL_PATTERNS_ALLOWED = None - # String length for the configurable part of the auto-generated username AUTO_GENERATED_USERNAME_RANDOM_STRING_LENGTH = 4 @@ -3802,24 +3580,6 @@ FINANCIAL_REPORTS = { POLICY_CHANGE_TASK_RATE_LIMIT = '900/h' #### PASSWORD POLICY SETTINGS ##### -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "common.djangoapps.util.password_policy_validators.MinimumLengthValidator", - "OPTIONS": { - "min_length": 8 - } - }, - { - "NAME": "common.djangoapps.util.password_policy_validators.MaximumLengthValidator", - "OPTIONS": { - "max_length": 75 - } - }, -] - PASSWORD_POLICY_COMPLIANCE_ROLLOUT_CONFIG = { 'ENFORCE_COMPLIANCE_ON_LOGIN': False } @@ -3900,200 +3660,6 @@ VIDEO_TRANSCRIPTS_SETTINGS = dict( VIDEO_TRANSCRIPTS_MAX_AGE = 31536000 -# Source: -# http://loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt according to http://en.wikipedia.org/wiki/ISO_639-1 -# Note that this is used as the set of choices to the `code` field of the -# `LanguageProficiency` model. -ALL_LANGUAGES = [ - ["aa", "Afar"], - ["ab", "Abkhazian"], - ["af", "Afrikaans"], - ["ak", "Akan"], - ["sq", "Albanian"], - ["am", "Amharic"], - ["ar", "Arabic"], - ["an", "Aragonese"], - ["hy", "Armenian"], - ["as", "Assamese"], - ["av", "Avaric"], - ["ae", "Avestan"], - ["ay", "Aymara"], - ["az", "Azerbaijani"], - ["ba", "Bashkir"], - ["bm", "Bambara"], - ["eu", "Basque"], - ["be", "Belarusian"], - ["bn", "Bengali"], - ["bh", "Bihari languages"], - ["bi", "Bislama"], - ["bs", "Bosnian"], - ["br", "Breton"], - ["bg", "Bulgarian"], - ["my", "Burmese"], - ["ca", "Catalan"], - ["ch", "Chamorro"], - ["ce", "Chechen"], - ["zh", "Chinese"], - ["zh_HANS", "Simplified Chinese"], - ["zh_HANT", "Traditional Chinese"], - ["cu", "Church Slavic"], - ["cv", "Chuvash"], - ["kw", "Cornish"], - ["co", "Corsican"], - ["cr", "Cree"], - ["cs", "Czech"], - ["da", "Danish"], - ["dv", "Divehi"], - ["nl", "Dutch"], - ["dz", "Dzongkha"], - ["en", "English"], - ["eo", "Esperanto"], - ["et", "Estonian"], - ["ee", "Ewe"], - ["fo", "Faroese"], - ["fj", "Fijian"], - ["fi", "Finnish"], - ["fr", "French"], - ["fy", "Western Frisian"], - ["ff", "Fulah"], - ["ka", "Georgian"], - ["de", "German"], - ["gd", "Gaelic"], - ["ga", "Irish"], - ["gl", "Galician"], - ["gv", "Manx"], - ["el", "Greek"], - ["gn", "Guarani"], - ["gu", "Gujarati"], - ["ht", "Haitian"], - ["ha", "Hausa"], - ["he", "Hebrew"], - ["hz", "Herero"], - ["hi", "Hindi"], - ["ho", "Hiri Motu"], - ["hr", "Croatian"], - ["hu", "Hungarian"], - ["ig", "Igbo"], - ["is", "Icelandic"], - ["io", "Ido"], - ["ii", "Sichuan Yi"], - ["iu", "Inuktitut"], - ["ie", "Interlingue"], - ["ia", "Interlingua"], - ["id", "Indonesian"], - ["ik", "Inupiaq"], - ["it", "Italian"], - ["jv", "Javanese"], - ["ja", "Japanese"], - ["kl", "Kalaallisut"], - ["kn", "Kannada"], - ["ks", "Kashmiri"], - ["kr", "Kanuri"], - ["kk", "Kazakh"], - ["km", "Central Khmer"], - ["ki", "Kikuyu"], - ["rw", "Kinyarwanda"], - ["ky", "Kirghiz"], - ["kv", "Komi"], - ["kg", "Kongo"], - ["ko", "Korean"], - ["kj", "Kuanyama"], - ["ku", "Kurdish"], - ["lo", "Lao"], - ["la", "Latin"], - ["lv", "Latvian"], - ["li", "Limburgan"], - ["ln", "Lingala"], - ["lt", "Lithuanian"], - ["lb", "Luxembourgish"], - ["lu", "Luba-Katanga"], - ["lg", "Ganda"], - ["mk", "Macedonian"], - ["mh", "Marshallese"], - ["ml", "Malayalam"], - ["mi", "Maori"], - ["mr", "Marathi"], - ["ms", "Malay"], - ["mg", "Malagasy"], - ["mt", "Maltese"], - ["mn", "Mongolian"], - ["na", "Nauru"], - ["nv", "Navajo"], - ["nr", "Ndebele, South"], - ["nd", "Ndebele, North"], - ["ng", "Ndonga"], - ["ne", "Nepali"], - ["nn", "Norwegian Nynorsk"], - ["nb", "Bokmål, Norwegian"], - ["no", "Norwegian"], - ["ny", "Chichewa"], - ["oc", "Occitan"], - ["oj", "Ojibwa"], - ["or", "Oriya"], - ["om", "Oromo"], - ["os", "Ossetian"], - ["pa", "Panjabi"], - ["fa", "Persian"], - ["pi", "Pali"], - ["pl", "Polish"], - ["pt", "Portuguese"], - ["ps", "Pushto"], - ["qu", "Quechua"], - ["rm", "Romansh"], - ["ro", "Romanian"], - ["rn", "Rundi"], - ["ru", "Russian"], - ["sg", "Sango"], - ["sa", "Sanskrit"], - ["si", "Sinhala"], - ["sk", "Slovak"], - ["sl", "Slovenian"], - ["se", "Northern Sami"], - ["sm", "Samoan"], - ["sn", "Shona"], - ["sd", "Sindhi"], - ["so", "Somali"], - ["st", "Sotho, Southern"], - ["es", "Spanish"], - ["sc", "Sardinian"], - ["sr", "Serbian"], - ["ss", "Swati"], - ["su", "Sundanese"], - ["sw", "Swahili"], - ["sv", "Swedish"], - ["ty", "Tahitian"], - ["ta", "Tamil"], - ["tt", "Tatar"], - ["te", "Telugu"], - ["tg", "Tajik"], - ["tl", "Tagalog"], - ["th", "Thai"], - ["bo", "Tibetan"], - ["ti", "Tigrinya"], - ["to", "Tonga (Tonga Islands)"], - ["tn", "Tswana"], - ["ts", "Tsonga"], - ["tk", "Turkmen"], - ["tr", "Turkish"], - ["tw", "Twi"], - ["ug", "Uighur"], - ["uk", "Ukrainian"], - ["ur", "Urdu"], - ["uz", "Uzbek"], - ["ve", "Venda"], - ["vi", "Vietnamese"], - ["vo", "Volapük"], - ["cy", "Welsh"], - ["wa", "Walloon"], - ["wo", "Wolof"], - ["xh", "Xhosa"], - ["yi", "Yiddish"], - ["yo", "Yoruba"], - ["za", "Zhuang"], - ["zu", "Zulu"] -] - - ### Apps only installed in some instances # The order of INSTALLED_APPS matters, so this tuple is the app name and the item in INSTALLED_APPS # that this app should be inserted *before*. A None here means it should be appended to the list. @@ -4126,6 +3692,16 @@ OPTIONAL_APPS = [ ('integrated_channels.canvas', None), ('integrated_channels.moodle', None), + # Channel Integrations Apps + ('channel_integrations.integrated_channel', None), + ('channel_integrations.degreed2', None), + ('channel_integrations.sap_success_factors', None), + ('channel_integrations.cornerstone', None), + ('channel_integrations.xapi', None), + ('channel_integrations.blackboard', None), + ('channel_integrations.canvas', None), + ('channel_integrations.moodle', None), + # Required by the Enterprise App ('django_object_actions', None), # https://github.com/crccheck/django-object-actions ] @@ -4247,6 +3823,14 @@ ACCOUNT_VISIBILITY_CONFIGURATION = { ], } +# .. setting_name: PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS +# .. setting_default: ["year_of_birth"] +# .. setting_description: List of private fields that will be hidden from the profile information report. +# .. setting_use_cases: open_edx +# .. setting_creation_date: 2025-07-07 +# .. setting_tickets: https://github.com/openedx/edx-platform/pull/36688 +PROFILE_INFORMATION_REPORT_PRIVATE_FIELDS = ["year_of_birth"] + # The list of all fields that are shared with other users using the bulk 'all_users' privacy setting ACCOUNT_VISIBILITY_CONFIGURATION["bulk_shareable_fields"] = ( ACCOUNT_VISIBILITY_CONFIGURATION["public_fields"] + [ @@ -4332,18 +3916,28 @@ ECOMMERCE_API_SIGNING_KEY = 'SET-ME-PLEASE' # Exam Service EXAMS_SERVICE_URL = 'http://localhost:18740/api/v1' +############## Settings for JWT token handling ############## TOKEN_SIGNING = { 'JWT_ISSUER': 'http://127.0.0.1:8740', 'JWT_SIGNING_ALGORITHM': 'RS512', 'JWT_SUPPORTED_VERSION': '1.2.0', + 'JWT_PRIVATE_SIGNING_JWK': None, 'JWT_PUBLIC_SIGNING_JWK_SET': None, } +# NOTE: In order to create both JWT_PRIVATE_SIGNING_JWK and JWT_PUBLIC_SIGNING_JWK_SET, +# in an lms shell run the following command: +# > python manage.py lms generate_jwt_signing_key +# This will output asymmetric JWTs to use here. Read more on this on: +# https://github.com/openedx/edx-platform/blob/master/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst + COURSE_CATALOG_URL_ROOT = 'http://localhost:8008' COURSE_CATALOG_API_URL = f'{COURSE_CATALOG_URL_ROOT}/api/v1' CREDENTIALS_INTERNAL_SERVICE_URL = 'http://localhost:8005' CREDENTIALS_PUBLIC_SERVICE_URL = 'http://localhost:8005' +# time between scheduled runs, in seconds +NOTIFY_CREDENTIALS_FREQUENCY = 14400 COMMENTS_SERVICE_URL = 'http://localhost:18080' COMMENTS_SERVICE_KEY = 'password' @@ -4362,35 +3956,6 @@ FIELD_OVERRIDE_PROVIDERS = () MODULESTORE_FIELD_OVERRIDE_PROVIDERS = ('openedx.features.content_type_gating.' 'field_override.ContentTypeGatingFieldOverride',) -# PROFILE IMAGE CONFIG -# WARNING: Certain django storage backends do not support atomic -# file overwrites (including the default, OverwriteStorage) - instead -# there are separate calls to delete and then write a new file in the -# storage backend. This introduces the risk of a race condition -# occurring when a user uploads a new profile image to replace an -# earlier one (the file will temporarily be deleted). -PROFILE_IMAGE_BACKEND = { - 'class': 'openedx.core.storage.OverwriteStorage', - 'options': { - 'location': os.path.join(MEDIA_ROOT, 'profile-images/'), - 'base_url': os.path.join(MEDIA_URL, 'profile-images/'), - }, -} -PROFILE_IMAGE_DEFAULT_FILENAME = 'images/profiles/default' -PROFILE_IMAGE_DEFAULT_FILE_EXTENSION = 'png' -# This key is used in generating unguessable URLs to users' -# profile images. Once it has been set, changing it will make the -# platform unaware of current image URLs. -PROFILE_IMAGE_HASH_SEED = 'placeholder_secret_key' -PROFILE_IMAGE_MAX_BYTES = 1024 * 1024 -PROFILE_IMAGE_MIN_BYTES = 100 -PROFILE_IMAGE_SIZES_MAP = { - 'full': 500, - 'large': 120, - 'medium': 50, - 'small': 30 -} - # Sets the maximum number of courses listed on the homepage # If set to None, all courses will be listed on the homepage HOMEPAGE_COURSE_MAX = None @@ -4416,9 +3981,6 @@ CREDIT_TASK_DEFAULT_RETRY_DELAY = 30 # to throttling. CREDIT_TASK_MAX_RETRIES = 5 -# Dummy secret key for dev/test -SECRET_KEY = 'dev key' - # Secret keys shared with credit providers. # Used to digitally sign credit requests (us --> provider) # and validate responses (provider --> us). @@ -4468,45 +4030,6 @@ DEFAULT_JWT_ISSUER = { JWT_EXPIRATION = 30 JWT_PRIVATE_SIGNING_KEY = None -JWT_AUTH = { - 'JWT_VERIFY_EXPIRATION': True, - - 'JWT_PAYLOAD_GET_USERNAME_HANDLER': lambda d: d.get('username'), - 'JWT_LEEWAY': 1, - 'JWT_DECODE_HANDLER': 'edx_rest_framework_extensions.auth.jwt.decoder.jwt_decode_handler', - - 'JWT_AUTH_COOKIE': 'edx-jwt-cookie', - - # Number of seconds before JWTs expire - 'JWT_EXPIRATION': 30, - 'JWT_IN_COOKIE_EXPIRATION': 60 * 60, - - 'JWT_LOGIN_CLIENT_ID': 'login-service-client-id', - 'JWT_LOGIN_SERVICE_USERNAME': 'login_service_user', - - 'JWT_SUPPORTED_VERSION': '1.2.0', - - 'JWT_ALGORITHM': 'HS256', - 'JWT_SECRET_KEY': SECRET_KEY, - - 'JWT_SIGNING_ALGORITHM': 'RS512', - 'JWT_PRIVATE_SIGNING_JWK': None, - 'JWT_PUBLIC_SIGNING_JWK_SET': None, - - 'JWT_ISSUER': 'http://127.0.0.1:8000/oauth2', - 'JWT_AUDIENCE': 'change-me', - 'JWT_ISSUERS': [ - { - 'ISSUER': 'http://127.0.0.1:8000/oauth2', - 'AUDIENCE': 'change-me', - 'SECRET_KEY': SECRET_KEY - } - ], - 'JWT_AUTH_COOKIE_HEADER_PAYLOAD': 'edx-jwt-cookie-header-payload', - 'JWT_AUTH_COOKIE_SIGNATURE': 'edx-jwt-cookie-signature', - 'JWT_AUTH_HEADER_PREFIX': 'JWT', -} - EDX_DRF_EXTENSIONS = { # Set this value to an empty dict in order to prevent automatically updating # user data from values in (possibly stale) JWTs. @@ -4572,12 +4095,6 @@ CREDENTIALS_COURSE_COMPLETION_STATE = 'awarded' # Queue to use for award program certificates PROGRAM_CERTIFICATES_ROUTING_KEY = 'edx.lms.core.default' -# Settings for Comprehensive Theming app - -# See https://github.com/openedx/edx-django-sites-extensions for more info -# Default site to use if site matching request headers does not exist -SITE_ID = 1 - # .. setting_name: COMPREHENSIVE_THEME_DIRS # .. setting_default: [] # .. setting_description: A list of paths to directories, each of which will @@ -4636,23 +4153,15 @@ AUTH_DOCUMENTATION_URL = 'https://course-catalog-api-guide.readthedocs.io/en/lat # Affiliate cookie tracking AFFILIATE_COOKIE_NAME = 'dev_affiliate_id' -############## Settings for RedirectMiddleware ############### - -# Setting this to None causes Redirect data to never expire -# The cache is cleared when Redirect models are saved/deleted -REDIRECT_CACHE_TIMEOUT = None # The length of time we cache Redirect model data -REDIRECT_CACHE_KEY_PREFIX = 'redirects' - ############## Settings for LMS Context Sensitive Help ############## HELP_TOKENS_INI_FILE = REPO_ROOT / "lms" / "envs" / "help_tokens.ini" -HELP_TOKENS_LANGUAGE_CODE = lambda settings: settings.LANGUAGE_CODE -HELP_TOKENS_VERSION = lambda settings: doc_version() +HELP_TOKENS_LANGUAGE_CODE = Derived(lambda settings: settings.LANGUAGE_CODE) +HELP_TOKENS_VERSION = Derived(lambda settings: doc_version()) HELP_TOKENS_BOOKS = { 'learner': 'https://edx.readthedocs.io/projects/open-edx-learner-guide', 'course_author': 'https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course', } -derived('HELP_TOKENS_LANGUAGE_CODE', 'HELP_TOKENS_VERSION') ############## OPEN EDX ENTERPRISE SERVICE CONFIGURATION ###################### # The Open edX Enterprise service is currently hosted via the LMS container/process. @@ -4686,7 +4195,6 @@ ENTERPRISE_CONSENT_API_URL = LMS_INTERNAL_ROOT_URL + '/consent/api/v1/' ENTERPRISE_SERVICE_WORKER_USERNAME = 'enterprise_worker' ENTERPRISE_API_CACHE_TIMEOUT = 3600 # Value is in seconds ENTERPRISE_CUSTOMER_LOGO_IMAGE_SIZE = 512 # Enterprise logo image size limit in KB's -ENTERPRISE_CATALOG_INTERNAL_ROOT_URL = 'http://enterprise.catalog.app:18160' # Defines the usernames of service users who should be throttled # at a higher rate than normal users when making requests of enterprise endpoints. ENTERPRISE_ALL_SERVICE_USERNAMES = [ @@ -4772,91 +4280,8 @@ DATA_CONSENT_SHARE_CACHE_TIMEOUT = 8 * 60 * 60 # 8 hours ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS = {} ENTERPRISE_TAGLINE = '' +TRANSCRIPT_LANG_CACHE_TIMEOUT = 60 * 60 * 24 # 24 hours -############## Settings for Course Enrollment Modes ###################### -# The min_price key refers to the minimum price allowed for an instance -# of a particular type of course enrollment mode. This is not to be confused -# with the min_price field of the CourseMode model, which refers to the actual -# price of the CourseMode. -COURSE_ENROLLMENT_MODES = { - "audit": { - "id": 1, - "slug": "audit", - "display_name": _("Audit"), - "min_price": 0, - }, - "verified": { - "id": 2, - "slug": "verified", - "display_name": _("Verified"), - "min_price": 1, - }, - "professional": { - "id": 3, - "slug": "professional", - "display_name": _("Professional"), - "min_price": 1, - }, - "no-id-professional": { - "id": 4, - "slug": "no-id-professional", - "display_name": _("No-Id-Professional"), - "min_price": 0, - }, - "credit": { - "id": 5, - "slug": "credit", - "display_name": _("Credit"), - "min_price": 0, - }, - "honor": { - "id": 6, - "slug": "honor", - "display_name": _("Honor"), - "min_price": 0, - }, - "masters": { - "id": 7, - "slug": "masters", - "display_name": _("Master's"), - "min_price": 0, - }, - "executive-education": { - "id": 8, - "slug": "executive-educations", - "display_name": _("Executive Education"), - "min_price": 1 - }, - "unpaid-executive-education": { - "id": 9, - "slug": "unpaid-executive-education", - "display_name": _("Unpaid Executive Education"), - "min_price": 0 - }, - "paid-executive-education": { - "id": 10, - "slug": "paid-executive-education", - "display_name": _("Paid Executive Education"), - "min_price": 1 - }, - "unpaid-bootcamp": { - "id": 11, - "slug": "unpaid-bootcamp", - "display_name": _("Unpaid Bootcamp"), - "min_price": 0 - }, - "paid-bootcamp": { - "id": 12, - "slug": "paid-bootcamp", - "display_name": _("Paid Bootcamp"), - "min_price": 1 - }, -} - -CONTENT_TYPE_GATE_GROUP_IDS = { - 'limited_access': 1, - 'full_access': 2, -} ############## Settings for the Discovery App ###################### @@ -4909,13 +4334,6 @@ OPTIONAL_FIELD_API_RATELIMIT = '10/h' PASSWORD_RESET_IP_RATE = '1/m' PASSWORD_RESET_EMAIL_RATE = '2/h' - -#### BRAZE API SETTINGS #### - -EDX_BRAZE_API_KEY = None -EDX_BRAZE_API_SERVER = None -BRAZE_COURSE_ENROLLMENT_CANVAS_ID = '' - # Keeping this for back compatibility with learner dashboard api GENERAL_RECOMMENDATION = {} @@ -4940,14 +4358,13 @@ RETIRED_EMAIL_DOMAIN = 'retired.invalid' # .. setting_description: Set the format a retired user username field gets transformed into, where {} # is replaced with the hash of the original username. This is a derived setting that depends on # RETIRED_USERNAME_PREFIX value. -RETIRED_USERNAME_FMT = lambda settings: settings.RETIRED_USERNAME_PREFIX + '{}' +RETIRED_USERNAME_FMT = Derived(lambda settings: settings.RETIRED_USERNAME_PREFIX + '{}') # .. setting_name: RETIRED_EMAIL_FMT # .. setting_default: retired__user_{}@retired.invalid # .. setting_description: Set the format a retired user email field gets transformed into, where {} is # replaced with the hash of the original email. This is a derived setting that depends on # RETIRED_EMAIL_PREFIX and RETIRED_EMAIL_DOMAIN values. -RETIRED_EMAIL_FMT = lambda settings: settings.RETIRED_EMAIL_PREFIX + '{}@' + settings.RETIRED_EMAIL_DOMAIN -derived('RETIRED_USERNAME_FMT', 'RETIRED_EMAIL_FMT') +RETIRED_EMAIL_FMT = Derived(lambda settings: settings.RETIRED_EMAIL_PREFIX + '{}@' + settings.RETIRED_EMAIL_DOMAIN) # .. setting_name: RETIRED_USER_SALTS # .. setting_default: ['abc', '123'] # .. setting_description: Set a list of salts used for hashing usernames and emails on users retirement. @@ -5070,49 +4487,80 @@ DISCUSSIONS_MFE_FEEDBACK_URL = None # .. setting_default: None # .. setting_description: Base URL of the exams dashboard micro-frontend for instructors. EXAMS_DASHBOARD_MICROFRONTEND_URL = None + +# .. setting_name: DISCUSSION_SPAM_URLS +# .. setting_default: [] +# .. setting_description: Urls to filter from discussion content to avoid spam +DISCUSSION_SPAM_URLS = [] + +# .. setting_name: CONTENT_FOR_SPAM_POSTS +# .. setting_default: "" +# .. setting_description: Content to replace spam posts with +CONTENT_FOR_SPAM_POSTS = "" + # .. toggle_name: ENABLE_AUTHN_RESET_PASSWORD_HIBP_POLICY # .. toggle_implementation: DjangoSetting # .. toggle_default: False -# .. toggle_description: When enabled, this toggle activates the use of the password validation -# HIBP Policy. +# .. toggle_description: When enabled, this toggle prevents the use of known-vulnerable passwords in +# the password reset flow. +# See ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY for more details. # .. toggle_use_cases: open_edx # .. toggle_creation_date: 2021-12-03 -# .. toggle_tickets: https://openedx.atlassian.net/browse/VAN-666 ENABLE_AUTHN_RESET_PASSWORD_HIBP_POLICY = False + # .. toggle_name: ENABLE_AUTHN_REGISTER_HIBP_POLICY # .. toggle_implementation: DjangoSetting # .. toggle_default: False -# .. toggle_description: When enabled, this toggle activates the use of the password validation -# HIBP Policy on Authn MFE's registration. +# .. toggle_description: When enabled, this toggle prevents the use of known-vulnerable passwords in +# the registration flow if their frequency exceeds a threshold. +# See ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY for more details. # .. toggle_use_cases: open_edx # .. toggle_creation_date: 2022-03-25 -# .. toggle_tickets: https://openedx.atlassian.net/browse/VAN-669 ENABLE_AUTHN_REGISTER_HIBP_POLICY = False -HIBP_REGISTRATION_PASSWORD_FREQUENCY_THRESHOLD = 3 +# .. setting_name: HIBP_REGISTRATION_PASSWORD_FREQUENCY_THRESHOLD +# .. setting_default: 3.0 +# .. setting_description: Log10 threshold in effect for ENABLE_AUTHN_REGISTER_HIBP_POLICY. +# See ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY for more details. +HIBP_REGISTRATION_PASSWORD_FREQUENCY_THRESHOLD = 3.0 # .. toggle_name: ENABLE_AUTHN_LOGIN_NUDGE_HIBP_POLICY # .. toggle_implementation: DjangoSetting # .. toggle_default: False -# .. toggle_description: When enabled, this toggle activates the use of the password validation -# on Authn MFE's login. -# .. toggle_use_cases: temporary +# .. toggle_description: When enabled, the login flow detects vulnerable passwords +# and prompts users to change their password if their frequency exceeds a threshold. +# See ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY for more details. +# .. toggle_use_cases: open_edx # .. toggle_creation_date: 2022-03-29 -# .. toggle_target_removal_date: None -# .. toggle_tickets: https://openedx.atlassian.net/browse/VAN-668 ENABLE_AUTHN_LOGIN_NUDGE_HIBP_POLICY = False -HIBP_LOGIN_NUDGE_PASSWORD_FREQUENCY_THRESHOLD = 3 +# .. setting_name: HIBP_LOGIN_NUDGE_PASSWORD_FREQUENCY_THRESHOLD +# .. setting_default: 3.0 +# .. setting_description: Log10 threshold in effect for ENABLE_AUTHN_LOGIN_NUDGE_HIBP_POLICY. +# See ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY for more details. +HIBP_LOGIN_NUDGE_PASSWORD_FREQUENCY_THRESHOLD = 3.0 # .. toggle_name: ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY # .. toggle_implementation: DjangoSetting # .. toggle_default: False -# .. toggle_description: When enabled, this toggle activates the use of the password validation -# on Authn MFE's login. -# .. toggle_use_cases: temporary +# .. toggle_description: When enabled, this toggle prevents the use of known-vulnerable passwords in +# the login flow if their frequency exceeds a threshold. Passwords are assessed by calling the +# Pwned Passwords service using a k-anonymity method that does not expose the password. The +# service tells us whether the password has been seen in any data breaches, and if so, how +# often. This count is converted to a "frequency" by taking the logarithm base 10. The login flow +# can reject all vulnerable passwords, or only passwords with a frequency above a configured +# threshold. In existing deployments, the threshold should be set high and tightened +# gradually in order to avoid large spikes in password resets and support requests. For example, +# setting ``HIBP_LOGIN_BLOCK_PASSWORD_FREQUENCY_THRESHOLD`` to 5 would reject logins when the +# password has been seen 100,000 or more times in the Pwned Passwords dataset. The goal should be +# to gradually reduce this to 0, meaning even a single occurrence will cause a rejection. (The +# threshold can take any real-number value.) +# .. toggle_use_cases: open_edx # .. toggle_creation_date: 2022-03-29 -# .. toggle_target_removal_date: None -# .. toggle_tickets: https://openedx.atlassian.net/browse/VAN-667 ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY = False -HIBP_LOGIN_BLOCK_PASSWORD_FREQUENCY_THRESHOLD = 5 +# .. setting_name: HIBP_LOGIN_BLOCK_PASSWORD_FREQUENCY_THRESHOLD +# .. setting_default: 5.0 +# .. setting_description: Log10 threshold in effect for ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY. +# See ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY for more details. +HIBP_LOGIN_BLOCK_PASSWORD_FREQUENCY_THRESHOLD = 5.0 # .. toggle_name: ENABLE_DYNAMIC_REGISTRATION_FIELDS # .. toggle_implementation: DjangoSetting @@ -5260,16 +4708,16 @@ SHOW_ACCOUNT_ACTIVATION_CTA = False ################# Documentation links for course apps ################# # pylint: disable=line-too-long -CALCULATOR_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/exercises_tools/calculator.html" -DISCUSSIONS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_components/create_discussion.html" -EDXNOTES_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/exercises_tools/notes.html" -PROGRESS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/pages.html?highlight=progress#hiding-or-showing-the-wiki-or-progress-pages" -TEAMS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_features/teams/teams_setup.html" -TEXTBOOKS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/textbooks.html" -WIKI_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/course_wiki.html" -CUSTOM_PAGES_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/pages.html#adding-custom-pages" -COURSE_BULK_EMAIL_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/manage_live_course/bulk_email.html" -ORA_SETTINGS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/pages.html#configuring-course-level-open-response-assessment-settings" +CALCULATOR_HELP_URL = "https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/add_calculator.html" +DISCUSSIONS_HELP_URL = "https://docs.openedx.org/en/latest/educators/concepts/communication/about_course_discussions.html" +EDXNOTES_HELP_URL = "https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/enable_notes.html" +PROGRESS_HELP_URL = "https://docs.openedx.org/en/latest/educators/references/data/progress_page.html" +TEAMS_HELP_URL = "https://docs.openedx.org/en/latest/educators/navigation/advanced_features.html#use-teams-in-your-course" +TEXTBOOKS_HELP_URL = "https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_textbooks.html" +WIKI_HELP_URL = "https://docs.openedx.org/en/latest/educators/concepts/communication/about_course_wiki.html" +CUSTOM_PAGES_HELP_URL = "https://docs.openedx.org/en/latest/educators/how-tos/course_development/manage_custom_page.html" +COURSE_BULK_EMAIL_HELP_URL = "https://docs.openedx.org/en/latest/educators/references/communication/bulk_email.html" +ORA_SETTINGS_HELP_URL = "https://docs.openedx.org/en/latest/educators/how-tos/course_development/exercise_tools/Manage_ORA_Assignment.html" ################# Bulk Course Email Settings ################# # If set, recipients of bulk course email messages will be filtered based on the last_login date of their User account. @@ -5306,11 +4754,6 @@ IS_ELIGIBLE_FOR_FINANCIAL_ASSISTANCE_URL = '/core/api/course_eligibility/' FINANCIAL_ASSISTANCE_APPLICATION_STATUS_URL = "/core/api/financial_assistance_application/status/" CREATE_FINANCIAL_ASSISTANCE_APPLICATION_URL = '/core/api/financial_assistance_applications' -######################## Enterprise API Client ######################## -ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_KEY = "enterprise-backend-service-key" -ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_SECRET = "enterprise-backend-service-secret" -ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL = "http://127.0.0.1:8000/oauth2" - # keys for big blue button live provider COURSE_LIVE_GLOBAL_CREDENTIALS = {} @@ -5381,12 +4824,6 @@ COOL_OFF_DAYS = 14 ############ Settings for externally hosted executive education courses ############ EXEC_ED_LANDING_PAGE = "https://www.getsmarter.com/account" -############## PLOTLY ############## - -ENTERPRISE_PLOTLY_SECRET = "I am a secret" - -############## PLOTLY ############## - ############ Internal Enterprise Settings ############ ENTERPRISE_VSF_UUID = "e815503343644ac7845bc82325c34460" ############ Internal Enterprise Settings ############ @@ -5402,6 +4839,23 @@ NOTIFICATION_CREATION_BATCH_SIZE = 76 NOTIFICATIONS_DEFAULT_FROM_EMAIL = "no-reply@example.com" NOTIFICATION_TYPE_ICONS = {} DEFAULT_NOTIFICATION_ICON_URL = "" +NOTIFICATION_DIGEST_LOGO = DEFAULT_EMAIL_LOGO_URL + +############## SELF PACED EMAIL ############## +SELF_PACED_BANNER_URL = "" +SELF_PACED_CLOUD_URL = "" + +############## GOAL REMINDER EMAIL ############## +GOAL_REMINDER_BANNER_URL = "" +GOAL_REMINDER_PROFILE_URL = "" + +############## NUDGE EMAILS ############### +# .. setting_name: DISABLED_ORGS_FOR_PROGRAM_NUDGE +# .. setting_default: [] +# .. setting_description: List of organization codes that should be disabled +# .. for program nudge emails. +# .. eg ['BTDx', 'MYTx'] +DISABLED_ORGS_FOR_PROGRAM_NUDGE = [] ############################ AI_TRANSLATIONS ################################## AI_TRANSLATIONS_API_URL = 'http://localhost:18760/api/v1' @@ -5426,18 +4880,18 @@ def _should_send_learning_badge_events(settings): # Each topic configuration dictionary contains # * `enabled`: a toggle denoting whether the event will be published to the topic. These should be annotated # according to -# https://edx.readthedocs.io/projects/edx-toggles/en/latest/how_to/documenting_new_feature_toggles.html +# https://docs.openedx.org/projects/edx-toggles/en/latest/how_to/documenting_new_feature_toggles.html # * `event_key_field` which is a period-delimited string path to event data field to use as event key. # Note: The topic names should not include environment prefix as it will be dynamically added based on # EVENT_BUS_TOPIC_PREFIX setting. EVENT_BUS_PRODUCER_CONFIG = { 'org.openedx.learning.certificate.created.v1': { 'learning-certificate-lifecycle': - {'event_key_field': 'certificate.course.course_key', 'enabled': _should_send_certificate_events}, + {'event_key_field': 'certificate.course.course_key', 'enabled': Derived(_should_send_certificate_events)}, }, 'org.openedx.learning.certificate.revoked.v1': { 'learning-certificate-lifecycle': - {'event_key_field': 'certificate.course.course_key', 'enabled': _should_send_certificate_events}, + {'event_key_field': 'certificate.course.course_key', 'enabled': Derived(_should_send_certificate_events)}, }, 'org.openedx.learning.course.unenrollment.completed.v1': { 'course-unenrollment-lifecycle': @@ -5499,33 +4953,16 @@ EVENT_BUS_PRODUCER_CONFIG = { "org.openedx.learning.course.passing.status.updated.v1": { "learning-badges-lifecycle": { "event_key_field": "course_passing_status.course.course_key", - "enabled": _should_send_learning_badge_events, + "enabled": Derived(_should_send_learning_badge_events), }, }, "org.openedx.learning.ccx.course.passing.status.updated.v1": { "learning-badges-lifecycle": { "event_key_field": "course_passing_status.course.ccx_course_key", - "enabled": _should_send_learning_badge_events, + "enabled": Derived(_should_send_learning_badge_events), }, }, } -derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.learning.certificate.created.v1', - 'learning-certificate-lifecycle', 'enabled') -derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.learning.certificate.revoked.v1', - 'learning-certificate-lifecycle', 'enabled') - -derived_collection_entry( - "EVENT_BUS_PRODUCER_CONFIG", - "org.openedx.learning.course.passing.status.updated.v1", - "learning-badges-lifecycle", - "enabled", -) -derived_collection_entry( - "EVENT_BUS_PRODUCER_CONFIG", - "org.openedx.learning.ccx.course.passing.status.updated.v1", - "learning-badges-lifecycle", - "enabled", -) BEAMER_PRODUCT_ID = "" @@ -5557,13 +4994,19 @@ SURVEY_REPORT_CHECK_THRESHOLD = 6 # .. setting_description: Dictionary with additional information that you want to share in the report. SURVEY_REPORT_EXTRA_DATA = {} - -# .. setting_name: DISABLED_COUNTRIES -# .. setting_default: [] -# .. setting_description: List of country codes that should be disabled -# .. for now it wil impact country listing in auth flow and user profile. -# .. eg ['US', 'CA'] -DISABLED_COUNTRIES = [] - - LMS_COMM_DEFAULT_FROM_EMAIL = "no-reply@example.com" + +# .. setting_name: RECAPTCHA_PRIVATE_KEY +# .. setting_default: empty string +# .. setting_description: Add recaptcha private key to use captcha feature in discussion app. +RECAPTCHA_PRIVATE_KEY = "" + +# .. setting_name: RECAPTCHA_VERIFY_URL +# .. setting_default: empty string +# .. setting_description: Add recaptcha verification api url to verify capthca tokens. +RECAPTCHA_VERIFY_URL = "" + +# .. setting_name: RECAPTCHA_SITE_KEY +# .. setting_default: empty string +# .. setting_description: Add recaptcha site key to use captcha feature in discussion MFE. +RECAPTCHA_SITE_KEY = "" diff --git a/lms/envs/devstack-experimental.yml b/lms/envs/devstack-experimental.yml deleted file mode 100644 index 43486ca024..0000000000 --- a/lms/envs/devstack-experimental.yml +++ /dev/null @@ -1,656 +0,0 @@ -# This file is an experimental extraction of /edx/etc/lms.yml from -# a LMS devstack container. -# -# When devstack is configured to use the new `openedx/` images -# instead of the old `edxops/edxapp` image, it will use this file -# as input to lms/envs/production.py (and, in turn, lms/envs/devstack.py). -# If you are using devstack with the `edxops/edxapp` image, though, -# this file is NOT used. -# -# Q. Should I update this file when I update devstack.py? -# A. You don't *have* to, because settings in devstack.py -# override these settings. But, it doesn't harm to also make them -# here in order to quell confusion. The hope is that we'll -# adopt OEP-45 eventually, which recommends against having -# a devstack.py at all. -# -# This is part of the effort to move our dev tools off of Ansible and -# Paver, described here: https://github.com/openedx/devstack/pull/866 -# TODO: If the effort described above is abandoned, then this file should -# probably be deleted. -ACCOUNT_MICROFRONTEND_URL: null -ACE_CHANNEL_DEFAULT_EMAIL: django_email -ACE_CHANNEL_SAILTHRU_API_KEY: '' -ACE_CHANNEL_SAILTHRU_API_SECRET: '' -ACE_CHANNEL_SAILTHRU_DEBUG: true -ACE_CHANNEL_SAILTHRU_TEMPLATE_NAME: null -ACE_CHANNEL_TRANSACTIONAL_EMAIL: django_email -ACE_ENABLED_CHANNELS: -- django_email -ACE_ENABLED_POLICIES: -- bulk_email_optout -ACE_ROUTING_KEY: edx.lms.core.default -ACTIVATION_EMAIL_SUPPORT_LINK: '' -AFFILIATE_COOKIE_NAME: dev_affiliate_id -ALTERNATE_WORKER_QUEUES: cms -ANALYTICS_API_KEY: '' -ANALYTICS_API_URL: http://localhost:18100 -ANALYTICS_DASHBOARD_NAME: Your Platform Name Here Insights -ANALYTICS_DASHBOARD_URL: http://localhost:18110/courses -API_ACCESS_FROM_EMAIL: api-requests@example.com -API_ACCESS_MANAGER_EMAIL: api-access@example.com -API_DOCUMENTATION_URL: http://course-catalog-api-guide.readthedocs.io/en/latest/ -AUTH_DOCUMENTATION_URL: http://course-catalog-api-guide.readthedocs.io/en/latest/authentication/index.html -AUTH_PASSWORD_VALIDATORS: -- NAME: django.contrib.auth.password_validation.UserAttributeSimilarityValidator -- NAME: common.djangoapps.util.password_policy_validators.MinimumLengthValidator - OPTIONS: - min_length: 2 -- NAME: common.djangoapps.util.password_policy_validators.MaximumLengthValidator - OPTIONS: - max_length: 75 -AWS_ACCESS_KEY_ID: null -AWS_QUERYSTRING_AUTH: false -AWS_S3_CUSTOM_DOMAIN: SET-ME-PLEASE (ex. bucket-name.s3.amazonaws.com) -AWS_SECRET_ACCESS_KEY: null -AWS_SES_REGION_ENDPOINT: email.us-east-1.amazonaws.com -AWS_SES_REGION_NAME: us-east-1 -AWS_STORAGE_BUCKET_NAME: SET-ME-PLEASE (ex. bucket-name) -BASE_COOKIE_DOMAIN: localhost -BLOCK_STRUCTURES_SETTINGS: - COURSE_PUBLISH_TASK_DELAY: 30 - TASK_DEFAULT_RETRY_DELAY: 30 - TASK_MAX_RETRIES: 5 -BRANCH_IO_KEY: '' -BUGS_EMAIL: bugs@example.com -BULK_EMAIL_DEFAULT_FROM_EMAIL: no-reply@example.com -BULK_EMAIL_EMAILS_PER_TASK: 500 -BULK_EMAIL_LOG_SENT_EMAILS: false -BULK_EMAIL_ROUTING_KEY_SMALL_JOBS: edx.lms.core.default -CACHES: - celery: - BACKEND: django.core.cache.backends.memcached.PyMemcacheCache - OPTIONS: - no_delay: true - ignore_exc: true - use_pooling: true - connect_timeout: 0.5 - KEY_FUNCTION: common.djangoapps.util.memcache.safe_key - KEY_PREFIX: celery - LOCATION: - - edx.devstack.memcached:11211 - TIMEOUT: '7200' - configuration: - BACKEND: django.core.cache.backends.memcached.PyMemcacheCache - OPTIONS: - no_delay: true - ignore_exc: true - use_pooling: true - connect_timeout: 0.5 - KEY_FUNCTION: common.djangoapps.util.memcache.safe_key - KEY_PREFIX: 78f87108afce - LOCATION: - - edx.devstack.memcached:11211 - course_structure_cache: - BACKEND: django.core.cache.backends.memcached.PyMemcacheCache - OPTIONS: - no_delay: true - ignore_exc: true - use_pooling: true - connect_timeout: 0.5 - KEY_FUNCTION: common.djangoapps.util.memcache.safe_key - KEY_PREFIX: course_structure - LOCATION: - - edx.devstack.memcached:11211 - TIMEOUT: '604800' - default: - BACKEND: django.core.cache.backends.memcached.PyMemcacheCache - OPTIONS: - no_delay: true - ignore_exc: true - use_pooling: true - connect_timeout: 0.5 - KEY_FUNCTION: common.djangoapps.util.memcache.safe_key - KEY_PREFIX: default - LOCATION: - - edx.devstack.memcached:11211 - VERSION: '1' - general: - BACKEND: django.core.cache.backends.memcached.PyMemcacheCache - OPTIONS: - no_delay: true - ignore_exc: true - use_pooling: true - connect_timeout: 0.5 - KEY_FUNCTION: common.djangoapps.util.memcache.safe_key - KEY_PREFIX: general - LOCATION: - - edx.devstack.memcached:11211 - mongo_metadata_inheritance: - BACKEND: django.core.cache.backends.memcached.PyMemcacheCache - OPTIONS: - no_delay: true - ignore_exc: true - use_pooling: true - connect_timeout: 0.5 - KEY_FUNCTION: common.djangoapps.util.memcache.safe_key - KEY_PREFIX: mongo_metadata_inheritance - LOCATION: - - edx.devstack.memcached:11211 - TIMEOUT: 300 - staticfiles: - BACKEND: django.core.cache.backends.memcached.PyMemcacheCache - OPTIONS: - no_delay: true - ignore_exc: true - use_pooling: true - connect_timeout: 0.5 - KEY_FUNCTION: common.djangoapps.util.memcache.safe_key - KEY_PREFIX: 78f87108afce_general - LOCATION: - - edx.devstack.memcached:11211 -CAS_ATTRIBUTE_CALLBACK: '' -CAS_EXTRA_LOGIN_PARAMS: '' -CAS_SERVER_URL: '' -CELERYBEAT_SCHEDULER: celery.beat:PersistentScheduler -CELERY_BROKER_HOSTNAME: localhost -CELERY_BROKER_PASSWORD: '' -CELERY_BROKER_TRANSPORT: redis -CELERY_BROKER_USER: '' -CELERY_BROKER_USE_SSL: false -CELERY_BROKER_VHOST: '' -CELERY_EVENT_QUEUE_TTL: null -CELERY_QUEUES: -- edx.lms.core.default -- edx.lms.core.high -- edx.lms.core.high_mem -CELERY_TIMEZONE: UTC -CERTIFICATE_TEMPLATE_LANGUAGES: - en: English - es: Español -CERT_QUEUE: certificates -CMS_BASE: edx.devstack.studio:18010 -CODE_JAIL: - limits: - CPU: 1 - FSIZE: 1048576 - PROXY: 0 - REALTIME: 3 - VMEM: 536870912 - python_bin: /edx/app/edxapp/venvs/edxapp-sandbox/bin/python - user: sandbox -COMMENTS_SERVICE_KEY: password -COMMENTS_SERVICE_URL: http://localhost:18080 -COMPREHENSIVE_THEME_DIRS: -- '' -COMPREHENSIVE_THEME_LOCALE_PATHS: [] -CONTACT_EMAIL: info@example.com -CONTACT_MAILING_ADDRESS: SET-ME-PLEASE -CONTENTSTORE: - ADDITIONAL_OPTIONS: {} - DOC_STORE_CONFIG: - authsource: '' - collection: modulestore - connectTimeoutMS: 2000 - db: edxapp - host: - - edx.devstack.mongo - password: password - port: 27017 - read_preference: SECONDARY_PREFERRED - replicaSet: '' - socketTimeoutMS: 3000 - ssl: false - user: edxapp - ENGINE: xmodule.contentstore.mongo.MongoContentStore - OPTIONS: - auth_source: '' - db: edxapp - host: - - edx.devstack.mongo - password: password - port: 27017 - ssl: false - user: edxapp -CORS_ORIGIN_ALLOW_ALL: false -CORS_ORIGIN_WHITELIST: [] -COURSES_WITH_UNSAFE_CODE: [] -COURSE_ABOUT_VISIBILITY_PERMISSION: see_exists -COURSE_CATALOG_API_URL: http://localhost:8008/api/v1 -COURSE_CATALOG_URL_ROOT: http://localhost:8008 -COURSE_CATALOG_VISIBILITY_PERMISSION: see_exists -CREDENTIALS_INTERNAL_SERVICE_URL: http://localhost:8005 -CREDENTIALS_PUBLIC_SERVICE_URL: http://localhost:8005 -CREDIT_HELP_LINK_URL: '' -CREDIT_PROVIDER_SECRET_KEYS: {} -CROSS_DOMAIN_CSRF_COOKIE_DOMAIN: '' -CROSS_DOMAIN_CSRF_COOKIE_NAME: '' -CSRF_COOKIE_SECURE: false -CSRF_TRUSTED_ORIGINS: [] -DASHBOARD_COURSE_LIMIT: null -DATABASES: - default: - ATOMIC_REQUESTS: true - CONN_MAX_AGE: 0 - ENGINE: django.db.backends.mysql - HOST: edx.devstack.mysql80 - NAME: edxapp - OPTIONS: - isolation_level: read committed - PASSWORD: password - PORT: '3306' - USER: edxapp001 - read_replica: - CONN_MAX_AGE: 0 - ENGINE: django.db.backends.mysql - HOST: edx.devstack.mysql80 - NAME: edxapp - OPTIONS: - isolation_level: read committed - PASSWORD: password - PORT: '3306' - USER: edxapp001 - student_module_history: - CONN_MAX_AGE: 0 - ENGINE: django.db.backends.mysql - HOST: edx.devstack.mysql80 - NAME: edxapp_csmh - OPTIONS: - isolation_level: read committed - PASSWORD: password - PORT: '3306' - USER: edxapp001 -DATA_DIR: /edx/var/edxapp -DCS_SESSION_COOKIE_SAMESITE: Lax -DCS_SESSION_COOKIE_SAMESITE_FORCE_ALL: true -DEFAULT_COURSE_VISIBILITY_IN_CATALOG: both -DEFAULT_FEEDBACK_EMAIL: feedback@example.com -DEFAULT_FILE_STORAGE: django.core.files.storage.FileSystemStorage -DEFAULT_FROM_EMAIL: registration@example.com -DEFAULT_JWT_ISSUER: - AUDIENCE: lms-key - ISSUER: http://edx.devstack.lms:18000/oauth2 - SECRET_KEY: lms-secret -DEFAULT_MOBILE_AVAILABLE: false -DEFAULT_SITE_THEME: '' -DEPRECATED_ADVANCED_COMPONENT_TYPES: [] -DJFS: - directory_root: /edx/var/edxapp/django-pyfs/static/django-pyfs - type: osfs - url_root: /static/django-pyfs -DOC_STORE_CONFIG: - authsource: '' - collection: modulestore - connectTimeoutMS: 2000 - db: edxapp - host: - - edx.devstack.mongo - password: password - port: 27017 - read_preference: SECONDARY_PREFERRED - replicaSet: '' - socketTimeoutMS: 3000 - ssl: false - user: edxapp -ECOMMERCE_API_SIGNING_KEY: lms-secret -ECOMMERCE_API_URL: http://localhost:8002/api/v2 -ECOMMERCE_PUBLIC_URL_ROOT: http://localhost:8002 -EDXMKTG_USER_INFO_COOKIE_NAME: edx-user-info -EDXNOTES_INTERNAL_API: http://edx.devstack.edx_notes_api:18120/api/v1 -EDXNOTES_PUBLIC_API: http://localhost:18120/api/v1 -EDX_API_KEY: PUT_YOUR_API_KEY_HERE -EDX_PLATFORM_REVISION: master -ELASTIC_SEARCH_CONFIG: -- host: edx.devstack.elasticsearch - port: 9200 - use_ssl: false -EMAIL_BACKEND: django.core.mail.backends.smtp.EmailBackend -EMAIL_HOST: localhost -EMAIL_HOST_PASSWORD: '' -EMAIL_HOST_USER: '' -EMAIL_PORT: 25 -EMAIL_USE_TLS: false -ENABLE_COMPREHENSIVE_THEMING: false -ENTERPRISE_API_URL: http://edx.devstack.lms:18000/enterprise/api/v1 -ENTERPRISE_COURSE_ENROLLMENT_AUDIT_MODES: -- audit -- honor -ENTERPRISE_CUSTOMER_SUCCESS_EMAIL: customersuccess@edx.org -ENTERPRISE_ENROLLMENT_API_URL: http://edx.devstack.lms:18000/api/enrollment/v1/ -ENTERPRISE_INTEGRATIONS_EMAIL: enterprise-integrations@edx.org -ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS: {} -ENTERPRISE_SERVICE_WORKER_USERNAME: enterprise_worker -ENTERPRISE_SUPPORT_URL: '' -ENTERPRISE_TAGLINE: '' -EVENT_TRACKING_SEGMENTIO_EMIT_WHITELIST: [] -EXTRA_MIDDLEWARE_CLASSES: [] -FACEBOOK_API_VERSION: v2.1 -FACEBOOK_APP_ID: FACEBOOK_APP_ID -FACEBOOK_APP_SECRET: FACEBOOK_APP_SECRET -FEATURES: - AUTH_USE_OPENID_PROVIDER: true - AUTOMATIC_AUTH_FOR_TESTING: false - CUSTOM_COURSES_EDX: false - ENABLE_BULK_ENROLLMENT_VIEW: false - ENABLE_COMBINED_LOGIN_REGISTRATION: true - ENABLE_CORS_HEADERS: false - ENABLE_COUNTRY_ACCESS: false - ENABLE_CREDIT_API: false - ENABLE_CREDIT_ELIGIBILITY: false - ENABLE_CROSS_DOMAIN_CSRF_COOKIE: false - ENABLE_CSMH_EXTENDED: true - ENABLE_DISCUSSION_HOME_PANEL: true - ENABLE_DISCUSSION_SERVICE: true - ENABLE_EDXNOTES: true - ENABLE_ENROLLMENT_RESET: false - ENABLE_EXPORT_GIT: false - ENABLE_GRADE_DOWNLOADS: true - ENABLE_LTI_PROVIDER: false - ENABLE_MKTG_SITE: false - ENABLE_MOBILE_REST_API: false - ENABLE_OAUTH2_PROVIDER: false - ENABLE_PUBLISHER: false - ENABLE_READING_FROM_MULTIPLE_HISTORY_TABLES: true - ENABLE_SPECIAL_EXAMS: false - ENABLE_SYSADMIN_DASHBOARD: false - ENABLE_THIRD_PARTY_AUTH: true - ENABLE_VIDEO_UPLOAD_PIPELINE: false - PREVIEW_LMS_BASE: preview.localhost:18000 - SHOW_FOOTER_LANGUAGE_SELECTOR: false - SHOW_HEADER_LANGUAGE_SELECTOR: false -FEEDBACK_SUBMISSION_EMAIL: '' -FERNET_KEYS: -- DUMMY KEY CHANGE BEFORE GOING TO PRODUCTION -FILE_UPLOAD_STORAGE_BUCKET_NAME: SET-ME-PLEASE (ex. bucket-name) -FILE_UPLOAD_STORAGE_PREFIX: submissions_attachments -FINANCIAL_REPORTS: - BUCKET: null - ROOT_PATH: sandbox - STORAGE_TYPE: localfs -GITHUB_REPO_ROOT: /edx/var/edxapp/data -GIT_REPO_DIR: /edx/var/edxapp/course_repos -GOOGLE_ANALYTICS_ACCOUNT: null -GOOGLE_ANALYTICS_LINKEDIN: '' -GOOGLE_ANALYTICS_TRACKING_ID: '' -GOOGLE_SITE_VERIFICATION_ID: '' -GRADES_DOWNLOAD: - BUCKET: '' - ROOT_PATH: '' - STORAGE_CLASS: django.core.files.storage.FileSystemStorage - STORAGE_KWARGS: - location: /tmp/edx-s3/grades - STORAGE_TYPE: '' -HELP_TOKENS_BOOKS: - course_author: http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course - learner: http://edx.readthedocs.io/projects/open-edx-learner-guide -HTTPS: 'on' -ICP_LICENSE: null -ICP_LICENSE_INFO: {} -IDA_LOGOUT_URI_LIST: [] -ID_VERIFICATION_SUPPORT_LINK: '' -INTEGRATED_CHANNELS_API_CHUNK_TRANSMISSION_LIMIT: - SAP: 1 -JWT_AUTH: - JWT_AUDIENCE: lms-key - JWT_AUTH_COOKIE_HEADER_PAYLOAD: edx-jwt-cookie-header-payload - JWT_AUTH_COOKIE_SIGNATURE: edx-jwt-cookie-signature - JWT_ISSUER: http://edx.devstack.lms:18000/oauth2 - JWT_ISSUERS: - - AUDIENCE: lms-key - ISSUER: http://edx.devstack.lms:18000/oauth2 - SECRET_KEY: lms-secret - JWT_PRIVATE_SIGNING_JWK: None - JWT_PUBLIC_SIGNING_JWK_SET: '' - JWT_SECRET_KEY: lms-secret - JWT_SIGNING_ALGORITHM: null -JWT_EXPIRATION: 30 -JWT_ISSUER: http://edx.devstack.lms:18000/oauth2 -JWT_PRIVATE_SIGNING_KEY: null -LANGUAGE_CODE: en -LANGUAGE_COOKIE: openedx-language-preference -LEARNER_PORTAL_URL_ROOT: https://learner-portal-edx.devstack.lms:18000 -LEARNING_MICROFRONTEND_URL: null -LMS_BASE: edx.devstack.lms:18000 -LMS_INTERNAL_ROOT_URL: http://edx.devstack.lms:18000 -LMS_ROOT_URL: http://edx.devstack.lms:18000 -LOCAL_LOGLEVEL: INFO -LOGGING_ENV: sandbox -LOGIN_REDIRECT_WHITELIST: [] -LOG_DIR: /edx/var/log/edx -LTI_AGGREGATE_SCORE_PASSBACK_DELAY: 900 -LTI_USER_EMAIL_DOMAIN: lti.example.com -MAILCHIMP_NEW_USER_LIST_ID: null -MAINTENANCE_BANNER_TEXT: Sample banner message -MEDIA_ROOT: /edx/var/edxapp/media/ -MEDIA_URL: /media/ -MKTG_URLS: {} -MKTG_URL_LINK_MAP: {} -MOBILE_STORE_URLS: {} -MODULESTORE: - default: - ENGINE: xmodule.modulestore.mixed.MixedModuleStore - OPTIONS: - mappings: {} - stores: - - DOC_STORE_CONFIG: - authsource: '' - collection: modulestore - connectTimeoutMS: 2000 - db: edxapp - host: - - edx.devstack.mongo - password: password - port: 27017 - read_preference: SECONDARY_PREFERRED - replicaSet: '' - socketTimeoutMS: 3000 - ssl: false - user: edxapp - ENGINE: xmodule.modulestore.split_mongo.split_draft.DraftVersioningModuleStore - NAME: split - OPTIONS: - default_class: xmodule.hidden_block.HiddenBlock - fs_root: /edx/var/edxapp/data - render_template: common.djangoapps.edxmako.shortcuts.render_to_string - - DOC_STORE_CONFIG: - authsource: '' - collection: modulestore - connectTimeoutMS: 2000 - db: edxapp - host: - - edx.devstack.mongo - password: password - port: 27017 - read_preference: PRIMARY - replicaSet: '' - socketTimeoutMS: 3000 - ssl: false - user: edxapp - ENGINE: xmodule.modulestore.mongo.DraftMongoModuleStore - NAME: draft - OPTIONS: - default_class: xmodule.hidden_block.HiddenBlock - fs_root: /edx/var/edxapp/data - render_template: common.djangoapps.edxmako.shortcuts.render_to_string -OAUTH_DELETE_EXPIRED: true -OAUTH_ENFORCE_SECURE: false -OAUTH_EXPIRE_CONFIDENTIAL_CLIENT_DAYS: 365 -OAUTH_EXPIRE_PUBLIC_CLIENT_DAYS: 30 -OPTIMIZELY_PROJECT_ID: null -ORA2_FILE_PREFIX: default_env-default_deployment/ora2 -ORDER_HISTORY_MICROFRONTEND_URL: null -ORGANIZATIONS_AUTOCREATE: true -PAID_COURSE_REGISTRATION_CURRENCY: -- usd -- $ -PARENTAL_CONSENT_AGE_LIMIT: 13 -PARTNER_SUPPORT_EMAIL: '' -PASSWORD_POLICY_COMPLIANCE_ROLLOUT_CONFIG: - ENFORCE_COMPLIANCE_ON_LOGIN: false -PASSWORD_RESET_SUPPORT_LINK: '' -PAYMENT_SUPPORT_EMAIL: billing@example.com -PDF_RECEIPT_BILLING_ADDRESS: 'Enter your receipt billing - - address here. - - ' -PDF_RECEIPT_COBRAND_LOGO_PATH: '' -PDF_RECEIPT_DISCLAIMER_TEXT: 'ENTER YOUR RECEIPT DISCLAIMER TEXT HERE. - - ' -PDF_RECEIPT_FOOTER_TEXT: 'Enter your receipt footer text here. - - ' -PDF_RECEIPT_LOGO_PATH: '' -PDF_RECEIPT_TAX_ID: 00-0000000 -PDF_RECEIPT_TAX_ID_LABEL: fake Tax ID -PDF_RECEIPT_TERMS_AND_CONDITIONS: 'Enter your receipt terms and conditions here. - - ' -PLATFORM_DESCRIPTION: Your Platform Description Here -PLATFORM_FACEBOOK_ACCOUNT: http://www.facebook.com/YourPlatformFacebookAccount -PLATFORM_NAME: Your Platform Name Here -PLATFORM_TWITTER_ACCOUNT: '@YourPlatformTwitterAccount' -POLICY_CHANGE_GRADES_ROUTING_KEY: edx.lms.core.default -SINGLE_LEARNER_COURSE_REGRADE_ROUTING_KEY: edx.lms.core.default -PREPEND_LOCALE_PATHS: [] -PRESS_EMAIL: press@example.com -PROCTORING_BACKENDS: - DEFAULT: 'null' - 'null': {} -PROCTORING_SETTINGS: {} -PROFILE_IMAGE_BACKEND: - class: openedx.core.storage.OverwriteStorage - options: - base_url: /media/profile-images/ - location: /edx/var/edxapp/media/profile-images/ -PROFILE_IMAGE_HASH_SEED: placeholder_secret_key -PROFILE_IMAGE_MAX_BYTES: 1048576 -PROFILE_IMAGE_MIN_BYTES: 100 -PROFILE_IMAGE_SIZES_MAP: - full: 500 - large: 120 - medium: 50 - small: 30 -PROFILE_MICROFRONTEND_URL: null -PROGRAM_CERTIFICATES_ROUTING_KEY: edx.lms.core.default -PROGRAM_CONSOLE_MICROFRONTEND_URL: null -RECALCULATE_GRADES_ROUTING_KEY: edx.lms.core.default -REGISTRATION_EXTRA_FIELDS: - city: hidden - confirm_email: hidden - country: required - gender: optional - goals: optional - honor_code: required - level_of_education: optional - mailing_address: hidden - terms_of_service: hidden - year_of_birth: optional -RETIRED_EMAIL_DOMAIN: retired.invalid -RETIRED_EMAIL_PREFIX: retired__user_ -RETIRED_USERNAME_PREFIX: retired__user_ -RETIRED_USER_SALTS: -- OVERRIDE ME WITH A RANDOM VALUE -- ROTATE SALTS BY APPENDING NEW VALUES -RETIREMENT_SERVICE_WORKER_USERNAME: retirement_worker -RETIREMENT_STATES: -- PENDING -- ERRORED -- ABORTED -- COMPLETE -SECRET_KEY: DUMMY KEY ONLY FOR TO DEVSTACK -SEGMENT_KEY: null -SERVER_EMAIL: sre@example.com -SESSION_COOKIE_DOMAIN: '' -SESSION_COOKIE_NAME: sessionid -SESSION_COOKIE_SECURE: false -SESSION_SAVE_EVERY_REQUEST: false -SITE_NAME: localhost -SOCIAL_AUTH_OAUTH_SECRETS: '' -SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '' -SOCIAL_AUTH_SAML_SP_PRIVATE_KEY_DICT: {} -SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: '' -SOCIAL_AUTH_SAML_SP_PUBLIC_CERT_DICT: {} -SOCIAL_MEDIA_FOOTER_URLS: {} -SOCIAL_SHARING_SETTINGS: - CERTIFICATE_FACEBOOK: false - CERTIFICATE_TWITTER: false - CUSTOM_COURSE_URLS: false - DASHBOARD_FACEBOOK: false - DASHBOARD_TWITTER: false -STATIC_ROOT_BASE: /edx/var/edxapp/staticfiles -STATIC_URL_BASE: /static/ -STUDIO_NAME: Studio -STUDIO_SHORT_NAME: Studio -SUPPORT_SITE_LINK: '' -SWIFT_AUTH_URL: null -SWIFT_AUTH_VERSION: null -SWIFT_KEY: null -SWIFT_REGION_NAME: null -SWIFT_TEMP_URL_DURATION: 1800 -SWIFT_TEMP_URL_KEY: null -SWIFT_TENANT_ID: null -SWIFT_TENANT_NAME: null -SWIFT_USERNAME: null -SWIFT_USE_TEMP_URLS: false -SYSLOG_SERVER: '' -SYSTEM_WIDE_ROLE_CLASSES: [] -TECH_SUPPORT_EMAIL: technical@example.com -THIRD_PARTY_AUTH_BACKENDS: -- social_core.backends.google.GoogleOAuth2 -- social_core.backends.linkedin.LinkedinOAuth2 -- social_core.backends.facebook.FacebookOAuth2 -- social_core.backends.azuread.AzureADOAuth2 -- common.djangoapps.third_party_auth.appleid.AppleIdAuth -- common.djangoapps.third_party_auth.identityserver3.IdentityServer3 -- common.djangoapps.third_party_auth.saml.SAMLAuthBackend -- common.djangoapps.third_party_auth.lti.LTIAuthBackend -TIME_ZONE: America/New_York -TRACKING_SEGMENTIO_WEBHOOK_SECRET: '' -UNIVERSITY_EMAIL: university@example.com -USERNAME_REPLACEMENT_WORKER: OVERRIDE THIS WITH A VALID USERNAME -VERIFY_STUDENT: - DAYS_GOOD_FOR: 365 - EXPIRING_SOON_WINDOW: 28 -VIDEO_CDN_URL: - EXAMPLE_COUNTRY_CODE: http://example.com/edx/video?s3_url= -VIDEO_IMAGE_MAX_AGE: 31536000 -VIDEO_IMAGE_SETTINGS: - DIRECTORY_PREFIX: video-images/ - STORAGE_KWARGS: - location: /edx/var/edxapp/media/ - VIDEO_IMAGE_MAX_BYTES: 2097152 - VIDEO_IMAGE_MIN_BYTES: 2048 - BASE_URL: /media/ -VIDEO_TRANSCRIPTS_MAX_AGE: 31536000 -VIDEO_TRANSCRIPTS_SETTINGS: - DIRECTORY_PREFIX: video-transcripts/ - STORAGE_KWARGS: - location: /edx/var/edxapp/media/ - VIDEO_TRANSCRIPTS_MAX_BYTES: 3145728 - BASE_URL: /media/ -VIDEO_UPLOAD_PIPELINE: - BUCKET: '' - ROOT_PATH: '' -WIKI_ENABLED: true -WRITABLE_GRADEBOOK_URL: null -XBLOCK_FS_STORAGE_BUCKET: null -XBLOCK_FS_STORAGE_PREFIX: null -XBLOCK_SETTINGS: {} -XQUEUE_INTERFACE: - basic_auth: - - edx - - edx - django_auth: - password: password - username: lms - url: http://edx.devstack.xqueue:18040 -X_FRAME_OPTIONS: DENY -YOUTUBE_API_KEY: PUT_YOUR_API_KEY_HERE -ZENDESK_API_KEY: '' -ZENDESK_CUSTOM_FIELDS: {} -ZENDESK_GROUP_ID_MAPPING: {} -ZENDESK_OAUTH_ACCESS_TOKEN: '' -ZENDESK_URL: '' -ZENDESK_USER: '' diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 01100e9240..3934a470c6 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -96,11 +96,12 @@ DEBUG_TOOLBAR_PANELS = ( 'debug_toolbar.panels.request.RequestPanel', 'debug_toolbar.panels.sql.SQLPanel', 'debug_toolbar.panels.signals.SignalsPanel', + 'debug_toolbar.panels.cache.CachePanel', 'debug_toolbar.panels.history.HistoryPanel', + # ProfilingPanel has been intentionally removed for default devstack.py - # runtimes for performance reasons. If you wish to re-enable it in your - # local development environment, please create a new settings file - # that imports and extends devstack.py. + # runtimes for performance reasons. + # 'debug_toolbar.panels.profiling.ProfilingPanel', ) DEBUG_TOOLBAR_CONFIG = { @@ -135,9 +136,6 @@ REQUIRE_DEBUG = DEBUG PIPELINE['SASS_ARGUMENTS'] = '--debug-info' -# Load development webpack configuration -WEBPACK_CONFIG_PATH = 'webpack.dev.config.js' - ########################### VERIFIED CERTIFICATES ################################# FEATURES['AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING'] = True @@ -384,6 +382,7 @@ EDXNOTES_CLIENT_NAME = 'edx_notes_api-backend-service' ############## Settings for Microfrontends ######################### LEARNING_MICROFRONTEND_URL = 'http://localhost:2000' ACCOUNT_MICROFRONTEND_URL = 'http://localhost:1997' +PROFILE_MICROFRONTEND_URL = 'http://localhost:1995' COMMUNICATIONS_MICROFRONTEND_URL = 'http://localhost:1984' AUTHN_MICROFRONTEND_URL = 'http://localhost:1999' AUTHN_MICROFRONTEND_DOMAIN = 'localhost:1999' @@ -395,6 +394,8 @@ DISCUSSIONS_MICROFRONTEND_URL = 'http://localhost:2002' ################### FRONTEND APPLICATION DISCUSSIONS FEEDBACK URL################### DISCUSSIONS_MFE_FEEDBACK_URL = None +DISCUSSION_SPAM_URLS = [] + ############## Docker based devstack settings ####################### FEATURES.update({ @@ -468,19 +469,19 @@ DCS_SESSION_COOKIE_SAMESITE_FORCE_ALL = True # If you want to enable theming in devstack, uncomment this section and add any relevant # theme directories to COMPREHENSIVE_THEME_DIRS -# We have to import the private method here because production.py calls -# derive_settings('lms.envs.production') which runs _make_mako_template_dirs with +# We have to import the make_mako_template_dirs method here because production.py calls +# derive_settings('lms.envs.production') which runs make_mako_template_dirs with # the settings from production, which doesn't include these theming settings. Thus, # the templating engine is unable to find the themed templates because they don't exist # in it's path. Re-calling derive_settings doesn't work because the settings was already # changed from a function to a list, and it can't be derived again. -# from .common import _make_mako_template_dirs +# from openedx.envs.common import make_mako_template_dirs # ENABLE_COMPREHENSIVE_THEMING = True # COMPREHENSIVE_THEME_DIRS = [ # "/edx/app/edxapp/edx-platform/themes/" # ] -# TEMPLATES[1]["DIRS"] = _make_mako_template_dirs +# TEMPLATES[1]["DIRS"] = make_mako_template_dirs # derive_settings(__name__) # Uncomment the lines below if you'd like to see SQL statements in your devstack LMS log. @@ -553,6 +554,20 @@ CSRF_TRUSTED_ORIGINS = [ 'http://localhost:1996', # frontend-app-learner-dashboard ] +RETIREMENT_STATES = [ + 'PENDING', + 'LOCKING_ACCOUNT', + 'LOCKING_COMPLETE', + 'RETIRING_ENROLLMENTS', + 'ENROLLMENTS_COMPLETE', + 'RETIRING_LMS_MISC', + 'LMS_MISC_COMPLETE', + 'RETIRING_LMS', + 'LMS_COMPLETE', + 'ERRORED', + 'ABORTED', + 'COMPLETE', +] ################# New settings must go ABOVE this line ################# ######################################################################## diff --git a/lms/envs/devstack_docker.py b/lms/envs/devstack_docker.py deleted file mode 100644 index 7ac0525113..0000000000 --- a/lms/envs/devstack_docker.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -Left over environment file from before the transition of devstack from -vagrant to docker was complete. - -This file should no longer be used, and is only around in case something -still refers to it. -""" - -from .devstack import * # pylint: disable=wildcard-import, unused-wildcard-import diff --git a/lms/envs/devstack_optimized.py b/lms/envs/devstack_optimized.py deleted file mode 100644 index 415e0e6c29..0000000000 --- a/lms/envs/devstack_optimized.py +++ /dev/null @@ -1,45 +0,0 @@ -""" -Settings to run LMS in devstack using optimized static assets. - -This configuration changes LMS to use the optimized static assets generated for testing, -rather than picking up the files directly from the source tree. - -The following Paver command can be used to run LMS in optimized mode: - - paver devstack lms --optimized - -You can also generate the assets explicitly and then run Studio: - - paver update_assets lms --settings=test_static_optimized - paver devstack lms --settings=devstack_optimized --fast - -Note that changes to JavaScript assets will not be picked up automatically -as they are for non-optimized devstack. Instead, update_assets must be -invoked each time that changes have been made. -""" - - -########################## Devstack settings ################################### - -from .devstack import * # pylint: disable=wildcard-import - -TEST_ROOT = REPO_ROOT / "test_root" - -############################ STATIC FILES ############################# - -# Enable debug so that static assets are served by Django -DEBUG = True - -# Set REQUIRE_DEBUG to false so that it behaves like production -REQUIRE_DEBUG = False - -# Fetch static files out of the pipeline's static root -STATICFILES_STORAGE = 'pipeline.storage.PipelineManifestStorage' - -# Serve static files at /static directly from the staticfiles directory under test root. -# Note: optimized files for testing are generated with settings from test_static_optimized -STATIC_URL = "/static/" -STATICFILES_FINDERS = ['django.contrib.staticfiles.finders.FileSystemFinder'] -STATICFILES_DIRS = [ - (TEST_ROOT / "staticfiles" / "lms").abspath(), -] diff --git a/lms/envs/devstack_with_worker.py b/lms/envs/devstack_with_worker.py deleted file mode 100644 index 4c865c629a..0000000000 --- a/lms/envs/devstack_with_worker.py +++ /dev/null @@ -1,23 +0,0 @@ -""" -This config file follows the devstack environment, but adds the -requirement of a celery worker running in the background to process -celery tasks. - -When testing locally, run lms/cms with this settings file as well, to test queueing -of tasks onto the appropriate workers. - -In two separate processes on devstack: - paver devstack lms --settings=devstack_with_worker - DJANGO_SETTINGS_MODULE=lms.envs.devstack_with_worker celery worker --app=lms.celery:APP -""" - - -# We intentionally define lots of variables that aren't used, and -# want to import all variables from base settings files -# pylint: disable=wildcard-import, unused-wildcard-import -from lms.envs.devstack import * - -# Require a separate celery worker -CELERY_ALWAYS_EAGER = False -CLEAR_REQUEST_CACHE_ON_TASK_COMPLETION = True -BROKER_URL = 'redis://:password@edx.devstack.redis:6379/' diff --git a/lms/envs/docker-production.py b/lms/envs/docker-production.py deleted file mode 100644 index 17a8b7c71f..0000000000 --- a/lms/envs/docker-production.py +++ /dev/null @@ -1,9 +0,0 @@ -""" -Specific overrides to the base prod settings for a docker production deployment. -""" - -from .production import * # pylint: disable=wildcard-import, unused-wildcard-import - -from openedx.core.lib.logsettings import get_docker_logger_config - -LOGGING = get_docker_logger_config() diff --git a/lms/envs/docs/README.rst b/lms/envs/docs/README.rst index 34211a5751..b0a4b42d10 100644 --- a/lms/envs/docs/README.rst +++ b/lms/envs/docs/README.rst @@ -1,7 +1,7 @@ LMS Configuration Settings ########################## -The ``lms.envs`` module contains project-wide settings, defined in python modules +The ``lms.envs`` module contains lms related settings, defined in python modules using the standard `Django Settings`_ mechanism, plus some Open edX particularities, which we describe below. @@ -56,8 +56,7 @@ For example: for locale_path in settings.COMPREHENSIVE_THEME_LOCALE_PATHS: locale_paths += (path(locale_path), ) return locale_paths - LOCALE_PATHS = _make_locale_paths - derived('LOCALE_PATHS') + LOCALE_PATHS = Derived(_make_locale_paths) In this case, ``LOCALE_PATHS`` will be defined correctly at the end of the settings module parsing no matter what ``REPO_ROOT``, @@ -71,7 +70,7 @@ when nested within each other: .. code-block:: python - def _make_mako_template_dirs(settings): + def make_mako_template_dirs(settings): """ Derives the final Mako template directories list from other settings. """ @@ -92,7 +91,6 @@ when nested within each other: 'NAME': 'mako', 'BACKEND': 'common.djangoapps.edxmako.backend.Mako', 'APP_DIRS': False, - 'DIRS': _make_mako_template_dirs, + 'DIRS': Derived(make_mako_template_dirs), }, ] - derived_collection_entry('TEMPLATES', 1, 'DIRS') diff --git a/lms/envs/minimal.yml b/lms/envs/minimal.yml index 51d7bbf499..f4f9bd8a8a 100644 --- a/lms/envs/minimal.yml +++ b/lms/envs/minimal.yml @@ -2,7 +2,8 @@ # # This is the minimal settings you need to set to be able to get django to # load when using the production.py settings files. It's useful to point -# LMS_CFG and CMS_CFG to this file to be able to run various paver commands +# LMS_CFG and CMS_CFG to this file to be able to run various npm commands +# and make targets (e.g.: building assets, pulling translations, etc.) # without needing a full docker setup. # # Follow up work will likely be done as a part of @@ -10,8 +11,6 @@ --- SECRET_KEY: aseuothsaeotuhaseotisaotenihsaoetih -FEATURES: - PREVIEW_LMS_BASE: "http://localhost" TRACKING_BACKENDS: {} EVENT_TRACKING_BACKENDS: {} JWT_AUTH: {} @@ -39,3 +38,7 @@ API_ACCESS_MANAGER_EMAIL: "api-access@example.com" # So that you can login to studio on bare-metal SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT: 'http://localhost:18000' + +# The celery broker url needs to be set even if you're running in always eager mode. +CELERY_BROKER_TRANSPORT: 'memory' +CELERY_BROKER_HOSTNAME: 'localhost' diff --git a/lms/envs/mock.yml b/lms/envs/mock.yml new file mode 100644 index 0000000000..0bcdf0e84b --- /dev/null +++ b/lms/envs/mock.yml @@ -0,0 +1,1258 @@ +# This is a mock configuration file used for testing the +# settings-loading code in production.py. +# +# WARNING: Do not use this in production -- it contains randomized and +# nonsensical values. + +ACCOUNT_MICROFRONTEND_URL: null +ACE_CHANNEL_BRAZE_API_KEY: '[encrypted]' +ACE_CHANNEL_BRAZE_APP_ID: hello +ACE_CHANNEL_BRAZE_CAMPAIGNS: + goalreminder: braze_goal_test + passwordreset: braze_channel_test +ACE_CHANNEL_BRAZE_FROM_EMAIL: Braze +ACE_CHANNEL_BRAZE_REST_ENDPOINT: rest.braze.com +ACE_CHANNEL_DEFAULT_EMAIL: django_email +ACE_CHANNEL_TRANSACTIONAL_EMAIL: django_email +ACE_EMAIL_COURSES_URL: https://localhost/search?tab=course +ACE_EMAIL_PROGRAMS_URL: https://localhost/search?tab=program +ACE_ENABLED_CHANNELS: +- django_email +ACE_ENABLED_POLICIES: +- bulk_email_optout +ACE_ROUTING_KEY: edx.lms.core.default +ACTIVATION_EMAIL_SUPPORT_LINK: '' +ADMIN_PORTAL_MICROFRONTEND_URL: https://admin-portal-deploy_host +AFFILIATE_COOKIE_NAME: sandbox.edx.affiliate_id +AI_TRANSLATIONS_API_URL: https://ai-translations.localhost/api/v1 +AI_TRANSLATIONS_URL_ROOT: https://ai-translations.localhost +ALLOWED_HOSTS: +- hello +ALLOW_ORPHANED_CONTENT_REMOVAL: true +ALTERNATE_WORKER_QUEUES: cms +AMPLITUDE_API_KEY: hello +AMPLITUDE_API_TIMEOUT: 5 +AMPLITUDE_URL: hello +ANALYTICS_API_KEY: '' +ANALYTICS_API_URL: http://localhost:18100 +ANALYTICS_DASHBOARD_NAME: Your Platform Name Here Insights +ANALYTICS_DASHBOARD_URL: http://localhost:18110/courses +API_ACCESS_FROM_EMAIL: sandbox-notifications@example.com +API_ACCESS_MANAGER_EMAIL: api-access@example.com +API_DOCUMENTATION_URL: http://course-catalog-api-guide.readthedocs.io/en/latest/ +AUTHN_MICROFRONTEND_DOMAIN: authn-deploy_host +AUTHN_MICROFRONTEND_URL: https://authn-deploy_host +AUTH_DOCUMENTATION_URL: http://course-catalog-api-guide.readthedocs.io/en/latest/authentication/index.html +AUTH_PASSWORD_VALIDATORS: +- NAME: django.contrib.auth.password_validation.CommonPasswordValidator +- NAME: django.contrib.auth.password_validation.UserAttributeSimilarityValidator +- NAME: common.djangoapps.util.password_policy_validators.MinimumLengthValidator + OPTIONS: + min_length: 8 +- NAME: common.djangoapps.util.password_policy_validators.MaximumLengthValidator + OPTIONS: + max_length: 100 +- NAME: common.djangoapps.util.password_policy_validators.AlphabeticValidator + OPTIONS: + min_alphabetic: 1 +- NAME: common.djangoapps.util.password_policy_validators.NumericValidator + OPTIONS: + min_numeric: 1 +AVAILABLE_DISCUSSION_TOURS: +- not_responded_filter +- response_sort +- notification_tour +AWS_ACCESS_KEY_ID: null +AWS_QUERYSTRING_AUTH: true +AWS_S3_CUSTOM_DOMAIN: custom.s3.amazonaws.com +AWS_SECRET_ACCESS_KEY: null +AWS_SES_AUTO_THROTTLE: 9.7 +AWS_SES_REGION_ENDPOINT: email.us-east-1.amazonaws.com +AWS_SES_REGION_NAME: us-east-1 +AWS_STORAGE_BUCKET_NAME: storage +BASE_COOKIE_DOMAIN: .sandbox.localhost +BEAMER_PRODUCT_ID: null +BIG_BLUE_BUTTON_GLOBAL_KEY: null +BIG_BLUE_BUTTON_GLOBAL_SECRET: null +BIG_BLUE_BUTTON_GLOBAL_URL: https://localhost/lti/ +BLOCKSTORE_API_URL: http://localhost:18250/api/v1 +BLOCKSTORE_PUBLIC_URL_ROOT: http://localhost:18250 +BLOCK_STRUCTURES_SETTINGS: + COURSE_PUBLISH_TASK_DELAY: 30 + DIRECTORY_PREFIX: hello + PRUNING_ACTIVE: true + STORAGE_CLASS: storages.backends.s3boto3.S3Boto3Storage + STORAGE_KWARGS: + bucket_name: hello + default_acl: hello + TASK_DEFAULT_RETRY_DELAY: 30 + TASK_MAX_RETRIES: 5 +BRANCH_IO_KEY: '' +BRAZE_GROUPS_INVITATION_EMAIL_CAMPAIGN_ID: hello +BRAZE_GROUPS_REMOVAL_EMAIL_CAMPAIGN_ID: hello +BRAZE_UNSUBSCRIBED_EMAILS_FROM_EMAIL: no-reply@example.com +BRAZE_UNSUBSCRIBED_EMAILS_RECIPIENT_EMAIL: +- braze-unsubscribed@example.com +BUGS_EMAIL: bugs@example.com +BULK_COURSE_EMAIL_LAST_LOGIN_ELIGIBILITY_PERIOD: 18 +BULK_EMAIL_DEFAULT_FROM_EMAIL: sandbox-notifications@example.com +BULK_EMAIL_EMAILS_PER_TASK: 500 +BULK_EMAIL_LOG_SENT_EMAILS: true +BULK_EMAIL_ROUTING_KEY_SMALL_JOBS: edx.lms.core.default +BULK_EMAIL_SEND_USING_EDX_ACE: true +BUNDLE_ASSET_STORAGE_SETTINGS: + STORAGE_CLASS: storages.backends.s3boto3.S3Boto3Storage + STORAGE_KWARGS: + bucket_name: hello + custom_domain: null + default_acl: hello + location: hello + querystring_auth: true + querystring_expire: 5 + signature_version: hello +BUNDLE_ASSET_URL_STORAGE_KEY: '[encrypted]' +BUNDLE_ASSET_URL_STORAGE_SECRET: '[encrypted]' +CACHES: + celery: + BACKEND: django.core.cache.backends.memcached.PyMemcacheCache + KEY_FUNCTION: common.djangoapps.util.memcache.safe_key + KEY_PREFIX: celery + LOCATION: + - localhost:11211 + OPTIONS: + connect_timeout: 0.5 + ignore_exc: true + no_delay: true + use_pooling: true + TIMEOUT: '7200' + configuration: + BACKEND: django.core.cache.backends.memcached.PyMemcacheCache + KEY_FUNCTION: common.djangoapps.util.memcache.safe_key + KEY_PREFIX: openlinux + LOCATION: + - localhost:11211 + OPTIONS: + connect_timeout: 0.5 + ignore_exc: true + no_delay: true + use_pooling: true + course_structure_cache: + BACKEND: django.core.cache.backends.memcached.PyMemcacheCache + KEY_FUNCTION: common.djangoapps.util.memcache.safe_key + KEY_PREFIX: course_structure + LOCATION: + - localhost:11211 + OPTIONS: + connect_timeout: 0.5 + ignore_exc: true + no_delay: true + use_pooling: true + TIMEOUT: null + default: + BACKEND: django.core.cache.backends.memcached.PyMemcacheCache + KEY_FUNCTION: common.djangoapps.util.memcache.safe_key + KEY_PREFIX: default + LOCATION: + - localhost:11211 + OPTIONS: + connect_timeout: 0.5 + ignore_exc: true + no_delay: true + use_pooling: true + VERSION: '1' + general: + BACKEND: django.core.cache.backends.memcached.PyMemcacheCache + KEY_FUNCTION: common.djangoapps.util.memcache.safe_key + KEY_PREFIX: general + LOCATION: + - localhost:11211 + OPTIONS: + connect_timeout: 0.5 + ignore_exc: true + no_delay: true + use_pooling: true + mongo_metadata_inheritance: + BACKEND: django.core.cache.backends.memcached.PyMemcacheCache + KEY_FUNCTION: common.djangoapps.util.memcache.safe_key + KEY_PREFIX: mongo_metadata_inheritance + LOCATION: + - localhost:11211 + OPTIONS: + connect_timeout: 0.5 + ignore_exc: true + no_delay: true + use_pooling: true + TIMEOUT: 300 + staticfiles: + BACKEND: django.core.cache.backends.memcached.PyMemcacheCache + KEY_FUNCTION: common.djangoapps.util.memcache.safe_key + KEY_PREFIX: openlinux_general + LOCATION: + - localhost:11211 + OPTIONS: + connect_timeout: 0.5 + ignore_exc: true + no_delay: true + use_pooling: true +CAS_ATTRIBUTE_CALLBACK: '' +CAS_EXTRA_LOGIN_PARAMS: '' +CAS_SERVER_URL: '' +CC_PROCESSOR: + Vendor: + ACCESS_KEY: '' + PROFILE_ID: '' + PURCHASE_ENDPOINT: /shoppingcart/payment_fake/ + SECRET_KEY: '' +CC_PROCESSOR_NAME: Vendor +CELERY_BROKER_HOSTNAME: localhost +CELERY_BROKER_PASSWORD: '' +CELERY_BROKER_TRANSPORT: redis +CELERY_BROKER_TRANSPORT_OPTIONS: + retry: hello +CELERY_BROKER_USER: '' +CELERY_BROKER_USE_SSL: true +CELERY_BROKER_VHOST: '' +CELERY_EVENT_QUEUE_TTL: null +CELERY_QUEUES: +- edx.lms.core.default +- edx.lms.core.high +- edx.lms.core.high_mem +CELERY_TIMEZONE: UTC +CERTIFICATE_TEMPLATE_LANGUAGES: + en: English + es: "Espa\xF1ol" +CERT_QUEUE: certificates +CHAT_COMPLETION_API: https://example.com/xpert/chatservice +CHAT_COMPLETION_API_CONNECT_TIMEOUT: 0.5 +CHAT_COMPLETION_API_READ_TIMEOUT: 20 +CHAT_COMPLETION_API_V2: https://example.com/xpert/v2/message +CHAT_COMPLETION_CLIENT_ID: test_client_id +CHAT_COMPLETION_MAX_TOKENS: 16385 +CHAT_COMPLETION_RESPONSE_TOKENS: 1000 +CLOSEST_CLIENT_IP_FROM_HEADERS: [] +CMS_BASE: studio-deploy_host +CODE_JAIL: + limit_overrides: + codejail_expanded_limits: + CPU: 5 + FSIZE: 5 + PROXY: 5 + REALTIME: 5 + VMEM: 5 + course-v1:Org+Course+Run: + CPU: 5 + FSIZE: 5 + PROXY: 5 + REALTIME: 5 + VMEM: 5 + limits: + CPU: 1 + FSIZE: 1048576 + PROXY: 0 + REALTIME: 3 + VMEM: 536870912 + python_bin: /edx/app/edxapp/venvs/edxapp-sandbox/bin/python + user: sandbox +COMMENTS_SERVICE_KEY: password +COMMENTS_SERVICE_URL: http://localhost:18080 +COMMERCE_COORDINATOR_REFUND_PATH: /lms/refund/ +COMMERCE_COORDINATOR_REFUND_SOURCE_SYSTEMS: +- commercetools +COMMERCE_COORDINATOR_SERVICE_WORKER_USERNAME: commerce_coordinator_worker +COMMERCE_COORDINATOR_URL_ROOT: https://commerce-coordinator.localhost +COMMUNICATIONS_MICROFRONTEND_URL: https://communications.localhost +COMPLETION_BY_VIEWING_DELAY_MS: 1000 +COMPLETION_VIDEO_COMPLETE_PERCENTAGE: 0.9 +COMPREHENSIVE_THEME_DIRS: [] +COMPREHENSIVE_THEME_LOCALE_PATHS: +- hello +CONFIG_WATCHER_SERVICE_NAME: LMS +CONFIG_WATCHER_SLACK_WEBHOOK_URL: '[encrypted]' +CONTACT_EMAIL: info@example.com +CONTACT_MAILING_ADDRESS: SET-ME-PLEASE +CONTENTSTORE: + ADDITIONAL_OPTIONS: + trashcan: + bucket: hello + DOC_STORE_CONFIG: + auth_source: null + collection: modulestore + connectTimeoutMS: 2000 + db: edxapp + host: localhost + password: password + port: 27017 + read_preference: SECONDARY_PREFERRED + replicaSet: rs0 + socketTimeoutMS: 3000 + ssl: true + user: user + ENGINE: xmodule.contentstore.mongo.MongoContentStore + OPTIONS: + auth_source: null + db: edxapp + host: localhost + password: password + port: 27017 + ssl: true + user: user +COOKIE_HEADER_SIZE_LOGGING_THRESHOLD: 9000 +COOKIE_PREFIXES_TO_REMOVE: +- - edx-user-personalized-recommendation + - courses.localhost +- - edx-user-personalized-recommendation +- - amp_cookie_test + - courses.localhost +- - amp_cookie_test +COOKIE_SAMPLING_REQUEST_COUNT: 1400 +COORDINATOR_CHECKOUT_REDIRECT_PATH: /lms/payment_page_redirect/ +COORDINATOR_FIRST_TIME_DISCOUNT_ELIGIBLE_PATH: /lms/first-time-discount-eligible/ +CORS_ORIGIN_WHITELIST: +- https://ecommerce-deploy_host +- https://learner-dashboard-deploy_host +COURSEWARE_COURSE_NOT_STARTED_ENTERPRISE_LEARNER_ERROR: true +COURSE_ABOUT_VISIBILITY_PERMISSION: see_exists +COURSE_AUTHORING_MICROFRONTEND_URL: https://course-authoring-deploy_host +COURSE_CATALOG_API_URL: https://discovery-deploy_host/api/v1 +COURSE_CATALOG_URL_ROOT: hello +COURSE_CATALOG_VISIBILITY_PERMISSION: see_exists +COURSE_OLX_VALIDATION_IGNORE_LIST: +- InvalidHTML +COURSE_OLX_VALIDATION_STAGE: 1 +CREDENTIALS_INTERNAL_SERVICE_URL: http://localhost:8005 +CREDENTIALS_PUBLIC_SERVICE_URL: http://localhost:8005 +CREDIT_HELP_LINK_URL: '' +CREDIT_PROVIDER_SECRET_KEYS: + org: + - '[encrypted]' +CROSS_DOMAIN_CSRF_COOKIE_DOMAIN: '' +CROSS_DOMAIN_CSRF_COOKIE_NAME: '' +CSRF_COOKIE_SECURE: true +CSRF_TRUSTED_ORIGINS: +- .sandbox.localhost +CSRF_TRUSTED_ORIGINS_WITH_SCHEME: +- https://*.sandbox.localhost +DASHBOARD_COURSE_LIMIT: 250 +DATABASES: + blockstore: + CONN_MAX_AGE: 5 + ENGINE: hello + HOST: hello + NAME: hello + OPTIONS: + connect_timeout: 5 + init_command: hello + PASSWORD: '[encrypted]' + PORT: hello + USER: hello + default: + ATOMIC_REQUESTS: true + CONN_MAX_AGE: 0 + ENGINE: django.db.backends.mysql + HOST: localhost + NAME: default + OPTIONS: + charset: hello + collation: hello + PASSWORD: password + PORT: '3306' + USER: user + read_replica: + CONN_MAX_AGE: 0 + ENGINE: django.db.backends.mysql + HOST: localhost + NAME: read + OPTIONS: {} + PASSWORD: password + PORT: '3306' + USER: user + student_module_history: + CONN_MAX_AGE: 0 + ENGINE: django.db.backends.mysql + HOST: localhost + NAME: smh + OPTIONS: {} + PASSWORD: password + PORT: '3306' + USER: user +DATA_DIR: /edx/var/edxapp +DEFAULT_COURSE_VISIBILITY_IN_CATALOG: both +DEFAULT_FEEDBACK_EMAIL: feedback@example.com +DEFAULT_FILE_STORAGE: storages.backends.s3boto3.S3Boto3Storage +DEFAULT_FROM_EMAIL: sandbox-notifications@example.com +DEFAULT_HASHING_ALGORITHM: sha256 +DEFAULT_JWT_ISSUER: + AUDIENCE: SET-ME-PLEASE + ISSUER: https://deploy_host/oauth2 + SECRET_KEY: SET-ME-PLEASE +DEFAULT_MOBILE_AVAILABLE: true +DEFAULT_NOTIFICATION_ICON_URL: https://notifications-static.localhost/icons/post_outline.png +DEFAULT_SITE_THEME: localhost +DEPRECATED_ADVANCED_COMPONENT_TYPES: [] +DISABLED_COUNTRIES: +- US +DISABLE_FORUM_V2: true +DISCOVERY_BACKEND_SERVICE_EDX_OAUTH2_KEY: testDISCOVERY_BACKEND_SERVICE_EDX_OAUTH2_KEY +DISCOVERY_BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL: https://courses.localhost/oauth2 +DISCOVERY_BACKEND_SERVICE_EDX_OAUTH2_SECRET: null +DISCOVERY_BASE_URL: https://discovery.localhost +DISCUSSIONS_MFE_FEEDBACK_URL: https://example.com/ +DISCUSSIONS_MICROFRONTEND_URL: https://discussions.localhost +DJFS: + bucket: hello + directory_root: /edx/var/edxapp/django-pyfs/static/django-pyfs + prefix: hello + type: osfs + url_root: /static/django-pyfs +DOC_STORE_CONFIG: + auth_source: null + collection: modulestore + connectTimeoutMS: 2000 + db: edxapp + host: localhost + password: password + port: 27017 + read_preference: SECONDARY_PREFERRED + replicaSet: rs0 + socketTimeoutMS: 3000 + ssl: true + user: user +DRY_RUN_MODE_REMOVE_DUP_TRANSMISSION_AUDIT: true +ECOMMERCE_API_SIGNING_KEY: SET-ME-PLEASE +ECOMMERCE_API_URL: https://ecommerce-deploy_host/api/v2 +ECOMMERCE_PUBLIC_URL_ROOT: https://ecommerce-deploy_host +EDXMKTG_USER_INFO_COOKIE_NAME: edx-user-info +EDXNOTES_INTERNAL_API: http://localhost:18120/api/v1 +EDXNOTES_PUBLIC_API: http://localhost:18120/api/v1 +EDX_API_KEY: PUT_YOUR_API_KEY_HERE +EDX_DRF_EXTENSIONS: + JWT_PAYLOAD_USER_ATTRIBUTE_MAPPING: {} + VERIFY_LMS_USER_ID_PROPERTY_NAME: id +EDX_PLATFORM_REVISION: master +EDX_REST_API_CLIENT_NAME: hello +ELASTIC_SEARCH_CONFIG: +- host: localhost + port: 9200 + use_ssl: true +ELASTIC_SEARCH_CONFIG_ES7: +- host: hello + port: 5 + use_ssl: true +EMAIL_BACKEND: django_ses.SESBackend +EMAIL_HOST: hello +EMAIL_HOST_PASSWORD: hello +EMAIL_HOST_USER: hello +EMAIL_PORT: 5 +EMAIL_USE_COURSE_ID_FROM_FOR_BULK: true +EMAIL_USE_TLS: true +ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY: true +ENABLE_AUTHN_LOGIN_NUDGE_HIBP_POLICY: true +ENABLE_AUTHN_REGISTER_HIBP_POLICY: true +ENABLE_AUTHN_RESET_PASSWORD_HIBP_POLICY: true +ENABLE_COMPREHENSIVE_THEMING: true +ENABLE_COPPA_COMPLIANCE: true +ENABLE_DYNAMIC_REGISTRATION_FIELDS: true +ENABLE_MFE_CONFIG_API: true +ENTERPRISE_ADMIN_PORTAL_BASE_URL: hello +ENTERPRISE_ADMIN_PORTAL_HOSTNAME: hello +ENTERPRISE_ALL_SERVICE_USERNAMES: +- hello +- hello +- hello +ENTERPRISE_API_DOC_MICROFRONTEND_URL: hello +ENTERPRISE_API_URL: https://deploy_host/enterprise/api/v1 +ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_KEY: test_enterprise_backend_oauth2key +ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL: https://courses.localhost/oauth2 +ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_SECRET: null +ENTERPRISE_BRAZE_API_KEY: null +ENTERPRISE_CATALOG_GET_CONTENT_METADATA_PAGE_SIZE: 5 +ENTERPRISE_CATALOG_INTERNAL_ROOT_URL: hello +ENTERPRISE_CORNERSTONE_MAX_CONTENT_PAYLOAD_COUNT: 5 +ENTERPRISE_COURSE_ENROLLMENT_AUDIT_MODES: +- audit +- honor +ENTERPRISE_CUSTOMER_SUCCESS_EMAIL: customersuccess@example.com +ENTERPRISE_ENROLLMENT_API_URL: https://deploy_host/api/enrollment/v1/ +ENTERPRISE_INTEGRATIONS_EMAIL: enterprise-integrations@example.com +ENTERPRISE_LEARNER_PORTAL_BASE_URL: hello +ENTERPRISE_LEARNER_PORTAL_HOSTNAME: hello +ENTERPRISE_MANUAL_REPORTING_CUSTOMER_UUIDS: +- hello +- hello +- hello +ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS: + utm_campaign: hello + utm_medium: hello + utm_source: hello +ENTERPRISE_SERVICE_WORKER_EMAIL: hello +ENTERPRISE_SERVICE_WORKER_USERNAME: enterprise_worker +ENTERPRISE_SSO_ORCHESTRATOR_BASE_URL: https://enterprise-sso-orchestrator.localhost +ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_EDX_OAUTH_PATH: configure-edx-oauth +ENTERPRISE_SSO_ORCHESTRATOR_CONFIGURE_PATH: configure +ENTERPRISE_SSO_ORCHESTRATOR_WORKER_PASSWORD: '[encrypted]' +ENTERPRISE_SSO_ORCHESTRATOR_WORKER_USERNAME: enterprise_sso_orchestrator_worker +ENTERPRISE_TAGLINE: Online learning opportunities +ENTERPRISE_VSF_UUID: hello +EVENTS_SERVICE_NAME: lms +EVENT_BUS_KAFKA_API_KEY: null +EVENT_BUS_KAFKA_API_SECRET: null +EVENT_BUS_KAFKA_BOOTSTRAP_SERVERS: bs.confluent.cloud:9092 +EVENT_BUS_KAFKA_SCHEMA_REGISTRY_API_KEY: null +EVENT_BUS_KAFKA_SCHEMA_REGISTRY_API_SECRET: null +EVENT_BUS_KAFKA_SCHEMA_REGISTRY_URL: https://sr.confluent.cloud +EVENT_BUS_PRODUCER: edx_event_bus_kafka.create_producer +EVENT_BUS_PRODUCER_CONFIG: + org.openedx.enterprise.learner_credit_course_enrollment.revoked.v1: + learner-credit-course-enrollment-lifecycle: + enabled: true + event_key_field: learner_credit_course_enrollment.uuid + org.openedx.learning.user.course_access_role.added.v1: + learning-course-access-role-lifecycle: + enabled: true + event_key_field: course_access_role_data.course_key + org.openedx.learning.user.course_access_role.removed.v1: + learning-course-access-role-lifecycle: + enabled: true + event_key_field: course_access_role_data.course_key + org.openedx.learning.xblock.skill.verified.v1: + learning-xblock-skill-verified: + enabled: true + event_key_field: xblock_info.usage_key +EVENT_BUS_TOPIC_PREFIX: prefix +EVENT_TRACKING_SEGMENTIO_EMIT_WHITELIST: +- example1 +- example2 +EXAMS_DASHBOARD_MICROFRONTEND_URL: hello +EXEC_ED_LANDING_PAGE: https://example.com/account +EXTRA_MIDDLEWARE_CLASSES: [] +FACEBOOK_API_VERSION: v2.1 +FACEBOOK_APP_ID: FACEBOOK_APP_ID +FACEBOOK_APP_SECRET: FACEBOOK_APP_SECRET +FAVICON_URL: hello +FEATURES: + ALLOW_COURSE_RERUNS: true + ALLOW_COURSE_STAFF_GRADE_DOWNLOADS: true + AUTOMATIC_AUTH_FOR_TESTING: true + AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING: true + CERTIFICATES_HTML_VIEW: true + CERTIFICATES_INSTRUCTOR_GENERATION: true + COURSEWARE_SEARCH_INCLUSION_DATE: hello + CUSTOM_CERTIFICATE_TEMPLATES_ENABLED: true + CUSTOM_COURSES_EDX: true + DISABLE_AUDIT_CERTIFICATES: true + DISABLE_COURSE_CREATION: true + DISABLE_HONOR_CERTIFICATES: true + DISABLE_LIBRARY_CREATION: true + DISABLE_MOBILE_COURSE_AVAILABLE: true + DISPLAY_ANALYTICS_DEMOGRAPHICS: true + DISPLAY_ANALYTICS_ENROLLMENTS: true + EDITABLE_SHORT_DESCRIPTION: true + EMBARGO: true + ENABLE_ANALYTICS_ACTIVE_COUNT: true + ENABLE_API_DOCS: true + ENABLE_ASYNC_ANSWER_DISTRIBUTION: true + ENABLE_AUTHN_MICROFRONTEND: true + ENABLE_CATALOG_MICROFRONTEND: true + ENABLE_AUTO_GENERATED_USERNAME: true + ENABLE_BULK_USER_RETIREMENT: true + ENABLE_CERTIFICATES_IDV_REQUIREMENT: true + ENABLE_COACHING: true + ENABLE_COMBINED_LOGIN_REGISTRATION: true + ENABLE_CORS_HEADERS: true + ENABLE_COUNTRY_ACCESS: true + ENABLE_COURSEWARE_SEARCH_FOR_COURSE_STAFF: true + ENABLE_COURSE_ASSESSMENT_GRADE_CHANGE_SIGNAL: true + ENABLE_COURSE_OLX_VALIDATION: true + ENABLE_CREATOR_GROUP: true + ENABLE_CREDIT_API: true + ENABLE_CREDIT_ELIGIBILITY: true + ENABLE_CROSS_DOMAIN_CSRF_COOKIE: true + ENABLE_CSMH_EXTENDED: true + ENABLE_DEBUG_RUN_PYTHON: true + ENABLE_DISCUSSION_HOME_PANEL: true + ENABLE_DISCUSSION_SERVICE: true + ENABLE_EDXNOTES: true + ENABLE_ENROLLMENT_RESET: true + ENABLE_ENTERPRISE_INTEGRATION: true + ENABLE_FEEDBACK_SUBMISSION: true + ENABLE_FINANCIAL_ASSISTANCE_FORM: true + ENABLE_FOOTER_MOBILE_APP_LINKS: true + ENABLE_FORUM_DAILY_DIGEST: true + ENABLE_GRADE_DOWNLOADS: true + ENABLE_INSTRUCTOR_ANALYTICS: true + ENABLE_INSTRUCTOR_BETA_DASHBOARD: true + ENABLE_INSTRUCTOR_LEGACY_DASHBOARD: true + ENABLE_INTEGRITY_SIGNATURE: true + ENABLE_MAX_FAILED_LOGIN_ATTEMPTS: true + ENABLE_MKTG_EMAIL_OPT_IN: true + ENABLE_MKTG_SITE: true + ENABLE_MOBILE_REST_API: true + ENABLE_NEW_BULK_EMAIL_EXPERIENCE: true + ENABLE_OAUTH2_PROVIDER: true + ENABLE_ORA_MOBILE_SUPPORT: true + ENABLE_ORA_USERNAMES_ON_DATA_EXPORT: true + ENABLE_PAYMENT_FAKE: true + ENABLE_PROCTORED_EXAMS: true + ENABLE_PUBLISHER: true + ENABLE_READING_FROM_MULTIPLE_HISTORY_TABLES: true + ENABLE_SERVICE_STATUS: true + ENABLE_SPECIAL_EXAMS: true + ENABLE_THIRD_PARTY_AUTH: true + ENABLE_V2_CERT_DISPLAY_SETTINGS: true + ENABLE_VERIFIED_CERTIFICATES: true + ENABLE_VIDEO_ABSTRACTION_LAYER_API: true + ENABLE_VIDEO_BUMPER: true + ENABLE_VIDEO_UPLOAD_PIPELINE: true + ENABLE_XBLOCK_VIEW_ENDPOINT: true + IS_EDX_DOMAIN: true + LICENSING: true + LTI_1P3_ENABLED: true + MILESTONES_APP: true + NOTICES_DEFAULT_REDIRECT_URL: hello + NOTICES_ENABLE_MOBILE: true + NOTICES_MAX_SNOOZE_DAYS: 5 + NOTICES_REDIRECT_ALLOWLIST: + - hello + - hello + - hello + - hello + NOTICES_SNOOZE_COUNT_LIMIT: 5 + NOTICES_SNOOZE_HOURS: 5 + PREVENT_CONCURRENT_LOGINS: true + SEND_LEARNING_CERTIFICATE_LIFECYCLE_EVENTS_TO_BUS: true + SEPARATE_VERIFICATION_FROM_PAYMENT: true + SHOW_FOOTER_LANGUAGE_SELECTOR: true + SQUELCH_PII_IN_LOGS: true + STUDIO_REQUEST_EMAIL: hello + SUBDOMAIN_BRANDING: true + SUBDOMAIN_COURSE_LISTINGS: true + USE_ENCRYPTED_USER_DATA: true +FEEDBACK_SUBMISSION_EMAIL: '' +FERNET_KEYS: +- secret +FILE_UPLOAD_STORAGE_BUCKET_NAME: uploads +FILE_UPLOAD_STORAGE_PREFIX: submissions_attachments +FINANCIAL_REPORTS: + BUCKET: null + CUSTOM_DOMAIN: hello + ROOT_PATH: sandbox + STORAGE_TYPE: localfs +FIRST_PURCHASE_DISCOUNT_OVERRIDE_CODE: FIRSTPURCHASECODE +FIRST_PURCHASE_DISCOUNT_OVERRIDE_PERCENTAGE: 30 +FOOTER_ORGANIZATION_IMAGE: images/logo-footer.png +FORUM_ELASTIC_SEARCH_CONFIG: +- host: example.us-east-1.es.amazonaws.com + port: 443 + use_ssl: true +FORUM_MONGODB_CLIENT_PARAMETERS: + authSource: source + host: + - 192.0.0.1 + password: null + port: 27017 + replicaSet: rs0 + username: name +FORUM_MONGODB_DATABASE: db +GENERAL_RECOMMENDATION: + courses: [] + is_personalized_recommendation: true +GENERAL_RECOMMENDATIONS: +- course_key: ORG.1x + course_runs: + - key: course-v1:Org+Course+Run + course_type: audit + image: + src: https://discovery.cdn.org/media/course/image/small.jpg + logo_image_url: https://discovery.cdn.org/organization/logos/xxx.png + marketing_url: https://localhost/course/introduction-to-computer-science + owners: + - key: ORG + logo_image_url: https://discovery.cdn.org/organization/logos/yyy.png + name: An Organization + title: Introduction + url_slug: intro +GITHUB_REPO_ROOT: /tmp +GIT_REPO_DIR: /tmp +GOOGLE_ANALYTICS_ACCOUNT: null +GOOGLE_ANALYTICS_LINKEDIN: '' +GOOGLE_ANALYTICS_TRACKING_ID: '' +GOOGLE_SITE_VERIFICATION_ID: '' +GRADES_DOWNLOAD: + BUCKET: '' + ROOT_PATH: '' + STORAGE_CLASS: storages.backends.s3boto3.S3Boto3Storage + STORAGE_KWARGS: + bucket_name: grades + custom_domain: null + default_acl: private + gzip: true + location: sandbox + querystring_auth: true + querystring_expire: 300 + STORAGE_TYPE: '' +HELP_TOKENS_BOOKS: + course_author: http://edx.readthedocs.io/projects/open-edx-building-and-running-a-course + learner: http://edx.readthedocs.io/projects/open-edx-learner-guide +HIBP_LOGIN_BLOCK_PASSWORD_FREQUENCY_THRESHOLD: 5 +HOME_MICROFRONTEND_URL: hello +HOTJAR_SITE_ID: 12345 +ICP_LICENSE: null +ICP_LICENSE_INFO: + icp_license: hello + icp_license_link: hello + text: hello +IDA_LOGOUT_URI_LIST: [] +ID_VERIFICATION_SUPPORT_LINK: '' +IGNORED_ERRORS: +- MODULE_AND_CLASS: hello + REASON_IGNORED: hello +- MODULE_AND_CLASS: hello + REASON_IGNORED: hello +INTEGRATED_CHANNELS_API_CHUNK_TRANSMISSION_LIMIT: + SAP: 1 +JWT_AUTH: + JWT_AUDIENCE: SET-ME-PLEASE + JWT_AUTH_COOKIE_HEADER_PAYLOAD: edx-jwt-cookie-header-payload + JWT_AUTH_COOKIE_SIGNATURE: edx-jwt-cookie-signature + JWT_ISSUER: https://deploy_host/oauth2 + JWT_ISSUERS: + - AUDIENCE: SET-ME-PLEASE + ISSUER: https://deploy_host/oauth2 + SECRET_KEY: SET-ME-PLEASE + JWT_PRIVATE_SIGNING_JWK: '' + JWT_PUBLIC_SIGNING_JWK_SET: '' + JWT_SECRET_KEY: SET-ME-PLEASE + JWT_SIGNING_ALGORITHM: null +JWT_AUTH_ADD_KID_HEADER: true +JWT_EXPIRATION: 30 +JWT_ISSUER: https://deploy_host/oauth2 +JWT_PRIVATE_SIGNING_KEY: null +LANGUAGE_CODE: en +LANGUAGE_COOKIE: openedx-language-preference +LEARNER_DASHBOARD_AMPLITUDE_MODEL_ID: hello +LEARNER_ENGAGEMENT_PROMPT_FOR_ACTIVE_CONTRACT: ' Test active contract {testing} ' +LEARNER_ENGAGEMENT_PROMPT_FOR_NON_ACTIVE_CONTRACT: Testing testing {enrolls} non active + contract Test test text +LEARNER_HOME_MICROFRONTEND_URL: https://learner-home-deploy_host +LEARNER_PORTAL_URL_ROOT: https://learner-portal-deploy_host +LEARNER_PROGRESS_PROMPT_FOR_ACTIVE_CONTRACT: learner progress prompt for active contract + test +LEARNER_PROGRESS_PROMPT_FOR_NON_ACTIVE_CONTRACT: progress prompt for non active contract. + text here +LEARNER_RECORD_MICROFRONTEND_URL: hello +LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS: 14 +LEARNING_ASSISTANT_AVAILABLE: true +LEARNING_ASSISTANT_PROMPT_TEMPLATE: ' Test template {test_content} ' +LEARNING_MICROFRONTEND_URL: https://learning-deploy_host +LIBRARY_AUTHORING_MICROFRONTEND_URL: https://library-authoring-deploy_host +LMS_BASE: deploy_host +LMS_COMM_DEFAULT_FROM_EMAIL: no-reply@example.com +LMS_INTERNAL_ROOT_URL: https://deploy_host +LMS_ROOT_URL: https://deploy_host +LOCAL_LOGLEVEL: INFO +LOGGING_ENV: sandbox +LOGIN_ISSUE_SUPPORT_LINK: https://localhost/hc/en-us/sections/115004153367-Solve-a-Sign-in-Problem +LOGIN_REDIRECT_WHITELIST: +- studio-deploy_host +- program-console-deploy_host +- authn-deploy_host +- payment-deploy_host +- learning-deploy_host +LOGISTRATION_PER_EMAIL_RATELIMIT_RATE: hello +LOGISTRATION_RATELIMIT_RATE: hello +LOGO_POWERED_BY_OPEN_EDX_URL: hello +LOGO_POWERED_BY_OPEN_EDX_URL_PNG: hello +LOGO_POWERED_BY_OPEN_EDX_URL_SVG: hello +LOGO_TRADEMARK_URL: hello +LOGO_TRADEMARK_URL_PNG: hello +LOGO_TRADEMARK_URL_SVG: hello +LOGO_URL: hello +LOGO_URL_PNG: hello +LOGO_URL_PNG_FOR_EMAIL: hello +LOGO_URL_SVG: hello +LOGO_WHITE_URL: hello +LOGO_WHITE_URL_PNG: hello +LOGO_WHITE_URL_SVG: hello +LOG_DIR: /edx/var/log/edx +LOG_REQUEST_USER_CHANGES: true +LTI_AGGREGATE_SCORE_PASSBACK_DELAY: 900 +LTI_NRPS_DISALLOW_PII: true +LTI_USER_EMAIL_DOMAIN: lti.example.com +MAILCHIMP_NEW_USER_LIST_ID: null +MAINTENANCE_BANNER_TEXT: Sample banner message +MARKETING_EMAILS_OPT_IN: true +MEDIA_ROOT: /tmp/media +MEDIA_URL: /media/ +MFE_CONFIG_OVERRIDES: + admin-portal: + ENTERPRISE_SUPPORT_LEARNER_CREDIT_URL: hello + FEATURE_ENABLE_RESTRICTED_RUN_ASSIGNMENT: true + learner-portal-enterprise: + COURSE_TYPE_CONFIG: + something: + pathSlug: hello + usesEntitlementListPrice: true + usesOrganizationOverride: true + FEATURE_ENABLE_BFF_API_FOR_ENTERPRISE_CUSTOMERS: + - hello + - hello + - hello + FEATURE_ENABLE_EMET_REDEMPTION: true + FEATURE_ENABLE_RESTRICTED_RUNS: true + LEARNER_SUPPORT_ABOUT_DEACTIVATION_URL: hello + LEARNER_SUPPORT_PACED_COURSE_MODE_URL: hello + LEARNER_SUPPORT_SPEND_ENROLLMENT_LIMITS_URL: hello + learning: + ENTERPRISE_LEARNER_PORTAL_URL: hello + support-tools: + DJANGO_ADMIN_LMS_BASE_URL: hello + DJANGO_ADMIN_SUBSIDY_BASE_URL: hello + PREDEFINED_CATALOG_QUERIES: + everything: 5 + open_courses: 5 +MKTG_URLS: + ABOUT: hello + ACCESSIBILITY: hello + AFFILIATES: hello + BLOG: hello + CAREERS: hello + CCPA: hello + CONTACT: hello + COOKIE: hello + COURSES: hello + DONATE: hello + ENTERPRISE: hello + FAQ: hello + HONOR: hello + HOW_IT_WORKS: hello + MEDIA_KIT: hello + NEWS: hello + PRESS: hello + PRIVACY: hello + PROGRAM_SUBSCRIPTIONS: hello + ROOT: hello + SCHOOLS: hello + SITE_MAP: hello + TOS: hello + TOS_AND_HONOR: hello + TRADEMARKS: hello + WHAT_IS_VERIFIED_CERT: hello +MKTG_URL_LINK_MAP: {} +MKTG_URL_OVERRIDES: + course-v1:Org+Course+Run: hello +MOBILE_STORE_URLS: + apple: hello + google: hello +MODULESTORE: + default: + ENGINE: xmodule.modulestore.mixed.MixedModuleStore + OPTIONS: + mappings: {} + stores: + - DOC_STORE_CONFIG: + auth_source: null + collection: modulestore + connectTimeoutMS: 2000 + db: edxapp + host: localhost + password: password + port: 27017 + read_preference: SECONDARY_PREFERRED + replicaSet: rs0 + socketTimeoutMS: 3000 + ssl: true + user: user + ENGINE: xmodule.modulestore.split_mongo.split_draft.DraftVersioningModuleStore + NAME: split + OPTIONS: + default_class: xmodule.hidden_block.HiddenBlock + fs_root: /edx/var/edxapp/data + render_template: common.djangoapps.edxmako.shortcuts.render_to_string + - DOC_STORE_CONFIG: + auth_source: null + collection: modulestore + connectTimeoutMS: 2000 + db: edxapp + host: localhost + password: password + port: 27017 + read_preference: PRIMARY + replicaSet: rs0 + socketTimeoutMS: 3000 + ssl: true + user: user + ENGINE: xmodule.modulestore.mongo.DraftMongoModuleStore + NAME: draft + OPTIONS: + default_class: xmodule.hidden_block.HiddenBlock + fs_root: /edx/var/edxapp/data + render_template: common.djangoapps.edxmako.shortcuts.render_to_string +NOTIFICATIONS_DEFAULT_FROM_EMAIL: no-reply@aexample.com +NOTIFICATIONS_EXPIRY: 60 +NOTIFICATION_ASSETS_ROOT_URL: https://notifications-static.localhost/ +NOTIFICATION_TYPE_ICONS: + CHECK_CIRCLE_GREEN: https://notifications-static.localhost/icons/check_circle_green.png + HELP_OUTLINE: https://notifications-static.localhost/icons/help_outline.png + NEWSPAPER: https://notifications-static.localhost/icons/newspaper.png + POST_OUTLINE: https://notifications-static.localhost/icons/post_outline.png + QUESTION_ANSWER_OUTLINE: https://notifications-static.localhost/icons/question_answer_outline.png + REPORT_RED: https://notifications-static.localhost/icons/report_red.png + VERIFIED: https://notifications-static.localhost/icons/verified.png +NOTIFY_CREDENTIALS_FREQUENCY: 5 +OAUTH_DELETE_EXPIRED: true +OAUTH_ENFORCE_SECURE: true +OAUTH_EXPIRE_CONFIDENTIAL_CLIENT_DAYS: 365 +OAUTH_EXPIRE_PUBLIC_CLIENT_DAYS: 30 +OAUTH_OIDC_ISSUER: hello +OPENEDX_TELEMETRY: +- edx_django_utils.monitoring.NewRelicBackend +- edx_django_utils.monitoring.DatadogBackend +OPENEDX_TELEMETRY_FRONTEND_SCRIPTS: "\n\n\n" +OPEN_EDX_FILTERS_CONFIG: + org.openedx.learning.course.homepage.url.creation.started.v1: + fail_silently: true + pipeline: + - federated_content_connector.filters.pipeline.CreateCustomUrlForCourseStep + org.openedx.learning.home.courserun.api.rendered.started.v1: + fail_silently: true + pipeline: + - federated_content_connector.filters.pipeline.CreateApiRenderCourseRunStep + org.openedx.learning.home.enrollment.api.rendered.v1: + fail_silently: true + pipeline: + - federated_content_connector.filters.pipeline.CreateApiRenderEnrollmentStep + org.openedx.learning.vertical_block.render.completed.v1: + fail_silently: true + pipeline: + - skill_tagging.pipeline.AddVerticalBlockSkillVerificationSection + org.openedx.learning.vertical_block_child.render.started.v1: + fail_silently: true + pipeline: + - skill_tagging.pipeline.AddVideoBlockSkillVerificationComponent + org.openedx.learning.xblock.render.started.v1: + fail_silently: true + pipeline: + - translatable_xblocks.filters.UpdateRequestLanguageCode +OPTIMIZELY_FULLSTACK_SDK_KEY: test_optimizely +OPTIMIZELY_PROJECT_ID: test_optimizely_project +ORA2_FILE_PREFIX: sandbox-edx/ora2 +ORA_GRADING_MICROFRONTEND_URL: https://ora-grading-deploy_host +ORA_MICROFRONTEND_URL: hello +ORA_WORKFLOW_UPDATE_ROUTING_KEY: hello +ORDER_HISTORY_MICROFRONTEND_URL: https://checkout.localhost/orders +OVERRIDE_GENERATE_OFFER_DATA: edx_ecommerce_extension.overrides.generate_offer_data +OVERRIDE_GET_ABSOLUTE_ECOMMERCE_URL: edx_ecommerce_extension.overrides.get_absolute_ecommerce_url +OVERRIDE_GET_CHECKOUT_PAGE_URL: edx_ecommerce_extension.overrides.get_checkout_page_url +OVERRIDE_REFUND_SEAT: edx_ecommerce_extension.overrides.refund_seat +PAID_COURSE_REGISTRATION_CURRENCY: +- usd +- $ +PARENTAL_CONSENT_AGE_LIMIT: 13 +PARTNER_SUPPORT_EMAIL: support@example.com +PASSWORD_POLICY_COMPLIANCE_ROLLOUT_CONFIG: + ELEVATED_PRIVILEGE_USER_COMPLIANCE_DEADLINE: '1970-01-01 00:00:00-00:00' + ENFORCE_COMPLIANCE_ON_LOGIN: true + GENERAL_USER_COMPLIANCE_DEADLINE: '1970-01-01 00:00:00-00:00' + STAFF_USER_COMPLIANCE_DEADLINE: '1970-01-01 00:00:00-00:00' +PASSWORD_RESET_SUPPORT_LINK: '' +PAYMENT_MICROFRONTEND_URL: https://payment-deploy_host +PAYMENT_SUPPORT_EMAIL: billing@example.com +PDF_RECEIPT_BILLING_ADDRESS: 'Enter your receipt billing + + address here. + + ' +PDF_RECEIPT_COBRAND_LOGO_PATH: '' +PDF_RECEIPT_DISCLAIMER_TEXT: 'ENTER YOUR RECEIPT DISCLAIMER TEXT HERE. + + ' +PDF_RECEIPT_FOOTER_TEXT: 'Enter your receipt footer text here. + + ' +PDF_RECEIPT_LOGO_PATH: '' +PDF_RECEIPT_TAX_ID: 00-0000000 +PDF_RECEIPT_TAX_ID_LABEL: fake Tax ID +PDF_RECEIPT_TERMS_AND_CONDITIONS: 'Enter your receipt terms and conditions here. + + ' +PLATFORM_DESCRIPTION: Your Platform Description Here +PLATFORM_FACEBOOK_ACCOUNT: http://www.facebook.com/YourPlatformFacebookAccount +PLATFORM_NAME: Your Platform Name Here +PLATFORM_TWITTER_ACCOUNT: '@YourPlatformTwitterAccount' +POLICY_CHANGE_GRADES_ROUTING_KEY: edx.lms.core.default +PRESS_EMAIL: press@example.com +PROCTORING_BACKENDS: + DEFAULT: 'null' + 'null': {} + proctortrack: + base_url: hello + client_id: '[encrypted]' + client_secret: '[encrypted]' + help_center_article_url: hello + integration_specific_email: hello +PROCTORING_SETTINGS: + ALLOW_CALLBACK_SIMULATION: true + LINK_URLS: + contact_us: hello + course_authoring_faq: hello + faq: hello + online_proctoring_rules: hello + tech_requirements: hello + SOFTWARE_SECURE_CLIENT_TIMEOUT: 5 + USE_ONBOARDING_PROFILE_API: true +PROCTORING_USER_OBFUSCATION_KEY: test_proctoring_user_obfuscation_key +PROFILE_IMAGE_BACKEND: + class: storages.backends.s3boto3.S3Boto3Storage + options: + bucket_name: profile-images + custom_domain: 12345.cloudfront.net + default_acl: public-read + location: LINTING + object_parameters: + CacheControl: max-age-31536000 +PROFILE_IMAGE_MAX_BYTES: 1048576 +PROFILE_IMAGE_MIN_BYTES: 100 +PROFILE_IMAGE_SECRET_KEY: secret +PROFILE_IMAGE_SIZES_MAP: + full: 500 + large: 120 + medium: 50 + small: 30 +PROFILE_MICROFRONTEND_URL: null +PROGRAM_CERTIFICATES_ROUTING_KEY: edx.lms.core.default +PROGRAM_CONSOLE_MICROFRONTEND_URL: https://program-console-deploy_host +RECALCULATE_GRADES_ROUTING_KEY: edx.lms.core.default +REGISTRATION_EXTRA_FIELDS: + city: hidden + confirm_email: hidden + country: required + gender: optional + goals: optional + honor_code: required + level_of_education: optional + mailing_address: hidden + terms_of_service: hidden + year_of_birth: optional +REGISTRATION_RATELIMIT: hello +REGISTRATION_VALIDATION_RATELIMIT: hello +REST_FRAMEWORK: + NUM_PROXIES: 1 +RETIRED_EMAIL_DOMAIN: retired.invalid +RETIRED_EMAIL_PREFIX: retired__user_ +RETIRED_USERNAME_PREFIX: retired__user_ +RETIRED_USER_SALTS: +- OVERRIDE ME WITH A RANDOM VALUE +- ROTATE SALTS BY APPENDING NEW VALUES +RETIREMENT_SERVICE_WORKER_USERNAME: OVERRIDE THIS WITH A VALID LMS USERNAME +RETIREMENT_STATES: +- PENDING +- RETIRING_FORUMS +- FORUMS_COMPLETE +- RETIRING_EMAIL_LISTS +- EMAIL_LISTS_COMPLETE +- RETIRING_ENROLLMENTS +- ENROLLMENTS_COMPLETE +- RETIRING_NOTES +- NOTES_COMPLETE +- RETIRING_LMS_MISC +- LMS_MISC_COMPLETE +- RETIRING_LMS +- LMS_COMPLETE +- ADDING_TO_PARTNER_QUEUE +- PARTNER_QUEUE_COMPLETE +- ERRORED +- ABORTED +- COMPLETE +SAFE_SESSIONS_DEBUG_PUBLIC_KEY: hello +SEARCH_COURSEWARE_CONTENT_LOG_PARAMS: true +SECRET_KEY: test_secret_key +SECURITY_PAGE_URL: hello +SEGMENT_KEY: null +SEND_ACTIVATION_EMAIL_URL: 'https://courses.example.edx.org/api/send_account_activation_email' +SEND_CERTIFICATE_CREATED_SIGNAL: true +SEND_CERTIFICATE_REVOKED_SIGNAL: true +SERVER_EMAIL: devops@example.com +SESSION_COOKIE_DOMAIN: .sandbox.localhost +SESSION_COOKIE_NAME: sessionid +SESSION_COOKIE_SECURE: true +SESSION_SAVE_EVERY_REQUEST: true +SGA_STORAGE_SETTINGS: + STORAGE_CLASS: storages.backends.s3boto3.S3Boto3Storage + STORAGE_KWARGS: + default_acl: private +SHARED_COOKIE_DOMAIN: hello +SHOW_ACCOUNT_ACTIVATION_CTA: true +SHOW_SKILL_VERIFICATION_PROBABILITY: 9.7 +SINGLE_LEARNER_COURSE_REGRADE_ROUTING_KEY: hello +SITE_NAME: deploy_host +SKILLS_MICROFRONTEND_URL: hello +SNOWFLAKE_SERVICE_USER: ENTERPRISE_SERVICE_USER +SNOWFLAKE_SERVICE_USER_PASSWORD: null +SOCIAL_AUTH_OAUTH_SECRETS: '' +SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '' +SOCIAL_AUTH_SAML_SP_PRIVATE_KEY_DICT: + one: '[encrypted]' + another: '[encrypted]' +SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: '' +SOCIAL_AUTH_SAML_SP_PUBLIC_CERT_DICT: + one: hello + another: hello +SOCIAL_MEDIA_FOOTER_URLS: + facebook: hello + instagram: hello + linkedin: hello + meetup: hello + reddit: hello + tumblr: hello + twitter: hello + youtube: hello +SOCIAL_SHARING_SETTINGS: + CERTIFICATE_FACEBOOK: true + CERTIFICATE_FACEBOOK_TEXT: hello + CERTIFICATE_TWITTER: true + CERTIFICATE_TWITTER_TEXT: hello + CUSTOM_COURSE_URLS: true + DASHBOARD_FACEBOOK: true + DASHBOARD_TWITTER: true +SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS: 5 +SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY: hello +STATICFILES_STORAGE_KWARGS: + openedx.core.storage.ProductionS3Storage: + bucket_name: hello + default_acl: hello +STATIC_ROOT_BASE: /tmp/static +STATIC_URL_BASE: /static +STUDIO_NAME: Studio +STUDIO_SHORT_NAME: Studio +SUMMARY_HOOK_HOST: hello +SUMMARY_HOOK_JS_PATH: hello +SUMMARY_HOOK_MIN_SIZE: 5 +SUPPORT_HOW_TO_UNENROLL_LINK: hello +SUPPORT_SITE_LINK: '' +SURVEYMONKEY_ACCESS_TOKEN: '[encrypted]' +SURVEY_REPORT_ENABLE: true +SURVEY_REPORT_ENDPOINT: http://localhost:0 +SWIFT_AUTH_URL: null +SWIFT_AUTH_VERSION: null +SWIFT_KEY: null +SWIFT_REGION_NAME: null +SWIFT_TEMP_URL_DURATION: 1800 +SWIFT_TEMP_URL_KEY: null +SWIFT_TENANT_ID: null +SWIFT_TENANT_NAME: null +SWIFT_USERNAME: null +SWIFT_USE_TEMP_URLS: true +SYSLOG_SERVER: '' +SYSTEM_WIDE_ROLE_CLASSES: [] +TAXONOMY_API_BASE_URL: hello +TAXONOMY_API_SKILL_PAGE_SIZE: 5 +TECH_SUPPORT_EMAIL: technical@example.com +TIME_ZONE: America/New_York +TOKEN_SIGNING: + JWT_ISSUER: https://edx-exams.localhost + JWT_PUBLIC_SIGNING_JWK_SET: "{\n \"keys\": [\n {\n \"kty\": \"RSA\",\n\ + \ \"kid\": \"exam_token_key\",\n \"e\": \"AQAB\",\n \"n\"\ + : \"test_jwt\"\n }\n ]\n}\n" +TRACKING_SEGMENTIO_WEBHOOK_SECRET: '' +UNIVERSITY_EMAIL: university@example.com +UNUSUAL_COOKIE_HEADER_PUBLIC_KEY: hello +USERNAME_REPLACEMENT_WORKER: OVERRIDE THIS WITH A VALID USERNAME +VEDA_FERNET_KEYS: +- '[encrypted]' +VERIFY_STUDENT: + DAYS_GOOD_FOR: 730 + EXPIRING_SOON_WINDOW: 28 + PERSONA: + API_KEY: '[encrypted]' + API_ROOT: hello + INQUIRY_TEMPLATE_ID: hello + WEBHOOK_SECRET: '[encrypted]' + SOFTWARE_SECURE: + API_ACCESS_KEY: '[encrypted]' + API_SECRET_KEY: '[encrypted]' + API_URL: hello + AWS_ACCESS_KEY: '[encrypted]' + AWS_SECRET_KEY: '[encrypted]' + CERT_VERIFICATION_PATH: hello + FACE_IMAGE_AES_KEY: '[encrypted]' + RSA_PUBLIC_KEY: '[encrypted]' + STORAGE_CLASS: storages.backends.s3boto3.S3Boto3Storage + STORAGE_KWARGS: + bucket_name: hello + custom_domain: null + default_acl: hello + querystring_auth: true + querystring_expire: 5 + USE_DJANGO_MAIL: true +VIDEO_CDN_URL: + CN: hello + EXAMPLE_COUNTRY_CODE: http://example.com/edx/video?s3_url= + default: hello +VIDEO_IMAGE_MAX_AGE: 31536000 +VIDEO_IMAGE_SETTINGS: + DIRECTORY_PREFIX: video-images/ + STORAGE_CLASS: storages.backends.s3boto3.S3Boto3Storage + STORAGE_KWARGS: + bucket_name: video-images + custom_domain: 121212.cloudfront.net + default_acl: public-read + location: LINTING + object_parameters: + CacheControl: max-age-31536000 + VIDEO_IMAGE_MAX_BYTES: 2097152 + VIDEO_IMAGE_MIN_BYTES: 2048 +VIDEO_TRANSCRIPTS_MAX_AGE: 31536000 +VIDEO_TRANSCRIPTS_SETTINGS: + DIRECTORY_PREFIX: video-transcripts/ + STORAGE_CLASS: storages.backends.s3boto3.S3Boto3Storage + STORAGE_KWARGS: + bucket_name: video-transcripts + custom_domain: 123123.cloudfront.net + default_acl: public-read + location: LINTING + object_parameters: + CacheControl: max-age-31536000 + VIDEO_TRANSCRIPTS_MAX_BYTES: 3145728 +VIDEO_UPLOAD_PIPELINE: + BUCKET: uploads + CONCURRENT_UPLOAD_LIMIT: 4 + ROOT_PATH: video-upload-pipeline/unprocessed +WIKI_ENABLED: true +WRITABLE_GRADEBOOK_URL: null +XBLOCK_EXTRA_MIXINS: +- hello +XBLOCK_FS_STORAGE_BUCKET: storage +XBLOCK_FS_STORAGE_PREFIX: sandbox-edx/ +XBLOCK_HANDLER_TOKEN_KEYS: +- '[encrypted]' +XBLOCK_SETTINGS: + AcclaimBadgeXBlock: + ORG: + api_key: '[encrypted]' + id: hello + ScormXBlock: + S3_BUCKET_NAME: scorm + STORAGE_FUNC: openedxscorm.storage.s3 +XQUEUE_INTERFACE: + basic_auth: + - user + - pass + django_auth: + password: pass + username: user + url: http://localhost:18040 +X_FRAME_OPTIONS: DENY +YOUTUBE_API_KEY: test_youtube_api_key +ZENDESK_API_KEY: '' +ZENDESK_CUSTOM_FIELDS: + course_id: 5 + enrollment_mode: 5 + enterprise_customer_name: 5 + referrer: 5 +ZENDESK_GROUP_ID_MAPPING: + Financial Assistance: '9999999999' +ZENDESK_OAUTH_ACCESS_TOKEN: test_zendesk_oauth +ZENDESK_URL: https://12345.zendesk.com +ZENDESK_USER: daemon@example.com + diff --git a/lms/envs/openstack.py b/lms/envs/openstack.py deleted file mode 100644 index d19fdb9c44..0000000000 --- a/lms/envs/openstack.py +++ /dev/null @@ -1,32 +0,0 @@ -""" -Settings for OpenStack deployments. -""" - -from .production import * # pylint: disable=wildcard-import, unused-wildcard-import - -SWIFT_AUTH_URL = AUTH_TOKENS.get('SWIFT_AUTH_URL') -SWIFT_AUTH_VERSION = AUTH_TOKENS.get('SWIFT_AUTH_VERSION', 1) -SWIFT_USERNAME = AUTH_TOKENS.get('SWIFT_USERNAME') -SWIFT_KEY = AUTH_TOKENS.get('SWIFT_KEY') -SWIFT_TENANT_NAME = AUTH_TOKENS.get('SWIFT_TENANT_NAME') -SWIFT_TENANT_ID = AUTH_TOKENS.get('SWIFT_TENANT_ID') -SWIFT_CONTAINER_NAME = FILE_UPLOAD_STORAGE_BUCKET_NAME -SWIFT_NAME_PREFIX = FILE_UPLOAD_STORAGE_PREFIX -SWIFT_USE_TEMP_URLS = AUTH_TOKENS.get('SWIFT_USE_TEMP_URLS', False) -SWIFT_TEMP_URL_KEY = AUTH_TOKENS.get('SWIFT_TEMP_URL_KEY') -SWIFT_TEMP_URL_DURATION = AUTH_TOKENS.get('SWIFT_TEMP_URL_DURATION', 1800) # seconds -SWIFT_CONTENT_TYPE_FROM_FD = AUTH_TOKENS.get('SWIFT_CONTENT_TYPE_FROM_FD', True) -SWIFT_CONTENT_LENGTH_FROM_FD = AUTH_TOKENS.get('SWIFT_CONTENT_LENGTH_FROM_FD', False) -SWIFT_LAZY_CONNECT = AUTH_TOKENS.get('SWIFT_LAZY_CONNECT', True) - -if AUTH_TOKENS.get('SWIFT_REGION_NAME'): - SWIFT_EXTRA_OPTIONS = {'region_name': AUTH_TOKENS['SWIFT_REGION_NAME']} - -if AUTH_TOKENS.get('DEFAULT_FILE_STORAGE'): - DEFAULT_FILE_STORAGE = AUTH_TOKENS.get('DEFAULT_FILE_STORAGE') -elif SWIFT_AUTH_URL and SWIFT_USERNAME and SWIFT_KEY: - DEFAULT_FILE_STORAGE = 'swift.storage.SwiftStorage' -else: - DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' - -ORA2_FILEUPLOAD_BACKEND = "django" diff --git a/lms/envs/production.py b/lms/envs/production.py index 6dc6be6341..2587e38836 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -1,9 +1,8 @@ """ -This is the default template for our main set of AWS servers. +Override common.py with key-value pairs from YAML (plus some extra defaults & post-processing). -Common traits: -* Use memcached, and cache-backed sessions -* Use a MySQL 5.1 database +This file is in the process of being restructured. Please see: +https://github.com/openedx/edx-platform/blob/master/docs/decisions/0022-settings-simplification.rst """ # We intentionally define lots of variables that aren't used, and @@ -17,19 +16,17 @@ Common traits: import codecs -import copy import datetime import os import yaml -import django from django.core.exceptions import ImproperlyConfigured from edx_django_utils.plugins import add_plugins from openedx_events.event_bus import merge_producer_configs from path import Path as path from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType -from openedx.core.lib.derived import derive_settings +from openedx.core.lib.derived import Derived, derive_settings from openedx.core.lib.logsettings import get_logger_config from xmodule.modulestore.modulestore_settings import convert_module_store_setting_if_needed # lint-amnesty, pylint: disable=wrong-import-order @@ -44,55 +41,169 @@ def get_env_setting(setting): error_msg = "Set the %s env variable" % setting raise ImproperlyConfigured(error_msg) # lint-amnesty, pylint: disable=raise-missing-from -################################ ALWAYS THE SAME ############################## + +####################################################################################################################### +#### PRODUCTION DEFAULTS +#### +#### Configure some defaults (beyond what has already been configured in common.py) before loading the YAML file. +#### DO NOT ADD NEW DEFAULTS HERE! Put any new setting defaults in common.py instead, along with a setting annotation. +#### TODO: Move all these defaults into common.py. +#### DEBUG = False -DEFAULT_TEMPLATE_ENGINE['OPTIONS']['debug'] = False -SESSION_ENGINE = 'django.contrib.sessions.backends.cache' - -# IMPORTANT: With this enabled, the server must always be behind a proxy that -# strips the header HTTP_X_FORWARDED_PROTO from client requests. Otherwise, -# a user can fool our server into thinking it was an https connection. -# See -# https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header -# for other warnings. +# IMPORTANT: With this enabled, the server must always be behind a proxy that strips the header HTTP_X_FORWARDED_PROTO +# from client requests. Otherwise, a user can fool our server into thinking it was an https connection. See +# https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header for other warnings. SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') -################################ END ALWAYS THE SAME ############################## -# A file path to a YAML file from which to load all the configuration for the edx platform +# TODO: We believe these were part of the DEPR'd sysadmin dashboard, and can likely be removed. +SSL_AUTH_EMAIL_DOMAIN = "MIT.EDU" +SSL_AUTH_DN_FORMAT_STRING = ( + "/C=US/ST=Massachusetts/O=Massachusetts Institute of Technology/OU=Client CA v1/CN={0}/emailAddress={1}" +) + +DEFAULT_TEMPLATE_ENGINE['OPTIONS']['debug'] = False +SESSION_ENGINE = 'django.contrib.sessions.backends.cache' +CELERY_RESULT_BACKEND = 'django-cache' +BROKER_HEARTBEAT = 60.0 +BROKER_HEARTBEAT_CHECKRATE = 2 +STATIC_ROOT_BASE = None +STATIC_URL_BASE = None +EMAIL_HOST = 'localhost' +EMAIL_PORT = 25 +EMAIL_USE_TLS = False +SESSION_COOKIE_DOMAIN = None +SESSION_COOKIE_HTTPONLY = True +AWS_SES_REGION_NAME = 'us-east-1' +AWS_SES_REGION_ENDPOINT = 'email.us-east-1.amazonaws.com' +REGISTRATION_EMAIL_PATTERNS_ALLOWED = None +LMS_ROOT_URL = None +CMS_BASE = 'studio.edx.org' +CELERY_EVENT_QUEUE_TTL = None +COMPREHENSIVE_THEME_LOCALE_PATHS = [] +PREPEND_LOCALE_PATHS = [] +COURSE_LISTINGS = {} +COMMENTS_SERVICE_URL = '' +COMMENTS_SERVICE_KEY = '' +CERT_QUEUE = 'test-pull' +PYTHON_LIB_FILENAME = 'python_lib.zip' +VIDEO_CDN_URL = {} +HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS = {} +AWS_STORAGE_BUCKET_NAME = 'edxuploads' +AWS_QUERYSTRING_AUTH = True +AWS_S3_CUSTOM_DOMAIN = 'edxuploads.s3.amazonaws.com' +MONGODB_LOG = {} +ZENDESK_USER = None +ZENDESK_API_KEY = None +EDX_API_KEY = None +CELERY_BROKER_TRANSPORT = "" +CELERY_BROKER_HOSTNAME = "" +CELERY_BROKER_VHOST = "" +CELERY_BROKER_USER = "" +CELERY_BROKER_PASSWORD = "" +BROKER_USE_SSL = False +SESSION_INACTIVITY_TIMEOUT_IN_SECONDS = None +ENABLE_REQUIRE_THIRD_PARTY_AUTH = False +GOOGLE_ANALYTICS_TRACKING_ID = None +GOOGLE_ANALYTICS_LINKEDIN = None +GOOGLE_SITE_VERIFICATION_ID = None +BRANCH_IO_KEY = None +REGISTRATION_CODE_LENGTH = 8 +FACEBOOK_API_VERSION = None +FACEBOOK_APP_SECRET = None +FACEBOOK_APP_ID = None +API_ACCESS_MANAGER_EMAIL = None +API_ACCESS_FROM_EMAIL = None +CHAT_COMPLETION_API = '' +CHAT_COMPLETION_API_KEY = '' +OPENAPI_CACHE_TIMEOUT = 60 * 60 +MAINTENANCE_BANNER_TEXT = None +DASHBOARD_COURSE_LIMIT = None + +# Derived defaults (alphabetical) +ACTIVATION_EMAIL_SUPPORT_LINK = Derived(lambda settings: settings.SUPPORT_SITE_LINK) +BULK_EMAIL_ROUTING_KEY = Derived(lambda settings: settings.HIGH_PRIORITY_QUEUE) +BULK_EMAIL_ROUTING_KEY_SMALL_JOBS = Derived(lambda settings: settings.DEFAULT_PRIORITY_QUEUE) +CC_MERCHANT_NAME = Derived(lambda settings: settings.PLATFORM_NAME) +CREDENTIALS_GENERATION_ROUTING_KEY = Derived(lambda settings: settings.DEFAULT_PRIORITY_QUEUE) +CSRF_TRUSTED_ORIGINS = Derived(lambda settings: settings.CSRF_TRUSTED_ORIGINS) +DEFAULT_ENTERPRISE_API_URL = Derived( + lambda settings: ( + None if settings.LMS_INTERNAL_ROOT_URL is None + else settings.LMS_INTERNAL_ROOT_URL + '/enterprise/api/v1/' + ) +) +DEFAULT_ENTERPRISE_CONSENT_API_URL = Derived( + lambda settings: ( + None if settings.LMS_INTERNAL_ROOT_URL is None + else settings.LMS_INTERNAL_ROOT_URL + '/consent/api/v1/' + ) +) +ENTERPRISE_API_URL = DEFAULT_ENTERPRISE_API_URL +ENTERPRISE_CONSENT_API_URL = DEFAULT_ENTERPRISE_CONSENT_API_URL +ENTERPRISE_ENROLLMENT_API_URL = Derived( + lambda settings: (settings.LMS_INTERNAL_ROOT_URL or '') + settings.LMS_ENROLLMENT_API_PATH +) +ENTERPRISE_PUBLIC_ENROLLMENT_API_URL = Derived( + lambda settings: (settings.LMS_ROOT_URL or '') + settings.LMS_ENROLLMENT_API_PATH +) +EMAIL_FILE_PATH = Derived(lambda settings: settings.DATA_DIR / "emails" / "lms") +ENTITLEMENTS_EXPIRATION_ROUTING_KEY = Derived(lambda settings: settings.DEFAULT_PRIORITY_QUEUE) +GRADES_DOWNLOAD_ROUTING_KEY = Derived(lambda settings: settings.HIGH_MEM_QUEUE) +ID_VERIFICATION_SUPPORT_LINK = Derived(lambda settings: settings.SUPPORT_SITE_LINK) +LMS_INTERNAL_ROOT_URL = Derived(lambda settings: settings.LMS_ROOT_URL) +LOGIN_ISSUE_SUPPORT_LINK = Derived(lambda settings: settings.SUPPORT_SITE_LINK) +PASSWORD_RESET_SUPPORT_LINK = Derived(lambda settings: settings.SUPPORT_SITE_LINK) +PROGRAM_CERTIFICATES_ROUTING_KEY = Derived(lambda settings: settings.DEFAULT_PRIORITY_QUEUE) +SHARED_COOKIE_DOMAIN = Derived(lambda settings: settings.SESSION_COOKIE_DOMAIN) +SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY = Derived(lambda settings: settings.HIGH_PRIORITY_QUEUE) + + +####################################################################################################################### +#### YAML LOADING +#### + +# A file path to a YAML file from which to load configuration overrides for LMS. CONFIG_FILE = get_env_setting('LMS_CFG') with codecs.open(CONFIG_FILE, encoding='utf-8') as f: - __config__ = yaml.safe_load(f) - # ENV_TOKENS and AUTH_TOKENS are included for reverse compatibility. - # Removing them may break plugins that rely on them. - ENV_TOKENS = __config__ - AUTH_TOKENS = __config__ + # _YAML_TOKENS starts out with the exact contents of the LMS_CFG YAML file. + # Please avoid adding new references to _YAML_TOKENS. Such references make our settings logic more complex. + # Instead, just reference the Django settings, which we define in the next step... + _YAML_TOKENS = yaml.safe_load(f) - # Add the key/values from config into the global namespace of this module. - # But don't override the FEATURES dict because we do that in an additive way. - __config_copy__ = copy.deepcopy(__config__) + # Update the global namespace of this module with the key-value pairs from _YAML_TOKENS. + # In other words: For (almost) every YAML key-value pair, define/update a Django setting with that name and value. + vars().update({ - KEYS_WITH_MERGED_VALUES = [ - 'FEATURES', - 'TRACKING_BACKENDS', - 'EVENT_TRACKING_BACKENDS', - 'JWT_AUTH', - 'CELERY_QUEUES', - 'MKTG_URL_LINK_MAP', - 'MKTG_URL_OVERRIDES', - 'REST_FRAMEWORK', - 'EVENT_BUS_PRODUCER_CONFIG', - ] - for key in KEYS_WITH_MERGED_VALUES: - if key in __config_copy__: - del __config_copy__[key] + # Note: If `value` is a mutable object (e.g., a dict), then it will be aliased between the global namespace and + # _YAML_TOKENS. In other words, updates to `value` will manifest in _YAML_TOKENS as well. This is intentional, + # in order to maintain backwards compatibility with old Django plugins which use _YAML_TOKENS. + key: value + for key, value in _YAML_TOKENS.items() - vars().update(__config_copy__) + # Do NOT define/update Django settings for these particular special keys. + # We handle each of these with its special logic (below, in this same module). + # For example, we need to *update* the default FEATURES dict rather than wholesale *override* it. + if key not in [ + 'FEATURES', + 'TRACKING_BACKENDS', + 'EVENT_TRACKING_BACKENDS', + 'JWT_AUTH', + 'CELERY_QUEUES', + 'MKTG_URL_LINK_MAP', + 'REST_FRAMEWORK', + 'EVENT_BUS_PRODUCER_CONFIG', + ] + }) +####################################################################################################################### +#### LOAD THE EDX-PLATFORM GIT REVISION +#### + try: # A file path to a YAML file from which to load all the code revisions currently deployed REVISION_CONFIG_FILE = get_env_setting('REVISION_CFG') @@ -105,26 +216,23 @@ except Exception: # pylint: disable=broad-except # Do NOT calculate this dynamically at startup with git because it's *slow*. EDX_PLATFORM_REVISION = REVISION_CONFIG.get('EDX_PLATFORM_REVISION', EDX_PLATFORM_REVISION) -###################################### CELERY ################################ + +####################################################################################################################### +#### POST-PROCESSING OF YAML +#### +#### This is where we do a bunch of logic to post-process the results of the YAML, including: conditionally setting +#### updates, merging dicts+lists which we did not override, and in some cases simply ignoring the YAML value in favor +#### of a specific production value. # Don't use a connection pool, since connections are dropped by ELB. BROKER_POOL_LIMIT = 0 BROKER_CONNECTION_TIMEOUT = 1 -# Allow env to configure celery result backend with default set to django-cache -CELERY_RESULT_BACKEND = ENV_TOKENS.get('CELERY_RESULT_BACKEND', 'django-cache') - -# When the broker is behind an ELB, use a heartbeat to refresh the -# connection and to detect if it has been dropped. -BROKER_HEARTBEAT = ENV_TOKENS.get('BROKER_HEARTBEAT', 60.0) -BROKER_HEARTBEAT_CHECKRATE = ENV_TOKENS.get('BROKER_HEARTBEAT_CHECKRATE', 2) - # Each worker should only fetch one message at a time CELERYD_PREFETCH_MULTIPLIER = 1 # STATIC_ROOT specifies the directory where static files are # collected -STATIC_ROOT_BASE = ENV_TOKENS.get('STATIC_ROOT_BASE', None) if STATIC_ROOT_BASE: STATIC_ROOT = path(STATIC_ROOT_BASE) WEBPACK_LOADER['DEFAULT']['STATS_FILE'] = STATIC_ROOT / "webpack-stats.json" @@ -132,84 +240,25 @@ if STATIC_ROOT_BASE: # STATIC_URL_BASE specifies the base url to use for static files -STATIC_URL_BASE = ENV_TOKENS.get('STATIC_URL_BASE', None) if STATIC_URL_BASE: STATIC_URL = STATIC_URL_BASE if not STATIC_URL.endswith("/"): STATIC_URL += "/" -# Allow overriding build profile used by RequireJS with one -# contained on a custom theme -REQUIRE_BUILD_PROFILE = ENV_TOKENS.get('REQUIRE_BUILD_PROFILE', REQUIRE_BUILD_PROFILE) +DATA_DIR = path(DATA_DIR) -# The following variables use (or) instead of the default value inside (get). This is to enforce using the Lazy Text -# values when the variable is an empty string. Therefore, setting these variable as empty text in related -# json files will make the system reads their values from django translation files -PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME') or PLATFORM_NAME -PLATFORM_DESCRIPTION = ENV_TOKENS.get('PLATFORM_DESCRIPTION') or PLATFORM_DESCRIPTION - -DATA_DIR = path(ENV_TOKENS.get('DATA_DIR', DATA_DIR)) -CC_MERCHANT_NAME = ENV_TOKENS.get('CC_MERCHANT_NAME', PLATFORM_NAME) -EMAIL_FILE_PATH = ENV_TOKENS.get('EMAIL_FILE_PATH', DATA_DIR / "emails" / "lms") -EMAIL_HOST = ENV_TOKENS.get('EMAIL_HOST', 'localhost') # django default is localhost -EMAIL_PORT = ENV_TOKENS.get('EMAIL_PORT', 25) # django default is 25 -EMAIL_USE_TLS = ENV_TOKENS.get('EMAIL_USE_TLS', False) # django default is False -SITE_NAME = ENV_TOKENS.get('SITE_NAME', SITE_NAME) -SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN') -SESSION_COOKIE_HTTPONLY = ENV_TOKENS.get('SESSION_COOKIE_HTTPONLY', True) - -DCS_SESSION_COOKIE_SAMESITE = ENV_TOKENS.get('DCS_SESSION_COOKIE_SAMESITE', DCS_SESSION_COOKIE_SAMESITE) -DCS_SESSION_COOKIE_SAMESITE_FORCE_ALL = ENV_TOKENS.get('DCS_SESSION_COOKIE_SAMESITE_FORCE_ALL', DCS_SESSION_COOKIE_SAMESITE_FORCE_ALL) # lint-amnesty, pylint: disable=line-too-long - -# As django-cookies-samesite package is set to be removed from base requirements when we upgrade to Django 3.2, -# we should follow the settings name provided by Django. -# https://docs.djangoproject.com/en/3.2/ref/settings/#session-cookie-samesite +# TODO: This was for backwards compatibility back when installed django-cookie-samesite (not since 2022). +# The DCS_ version of the setting can be DEPR'd at this point. SESSION_COOKIE_SAMESITE = DCS_SESSION_COOKIE_SAMESITE -AWS_SES_REGION_NAME = ENV_TOKENS.get('AWS_SES_REGION_NAME', 'us-east-1') -AWS_SES_REGION_ENDPOINT = ENV_TOKENS.get('AWS_SES_REGION_ENDPOINT', 'email.us-east-1.amazonaws.com') - -REGISTRATION_EMAIL_PATTERNS_ALLOWED = ENV_TOKENS.get('REGISTRATION_EMAIL_PATTERNS_ALLOWED') - -LMS_ROOT_URL = ENV_TOKENS.get('LMS_ROOT_URL') -LMS_INTERNAL_ROOT_URL = ENV_TOKENS.get('LMS_INTERNAL_ROOT_URL', LMS_ROOT_URL) - -# List of logout URIs for each IDA that the learner should be logged out of when they logout of the LMS. Only applies to -# IDA for which the social auth flow uses DOT (Django OAuth Toolkit). -IDA_LOGOUT_URI_LIST = ENV_TOKENS.get('IDA_LOGOUT_URI_LIST', []) - -ENV_FEATURES = ENV_TOKENS.get('FEATURES', {}) -for feature, value in ENV_FEATURES.items(): +for feature, value in _YAML_TOKENS.get('FEATURES', {}).items(): FEATURES[feature] = value -CMS_BASE = ENV_TOKENS.get('CMS_BASE', 'studio.edx.org') - ALLOWED_HOSTS = [ - # TODO: bbeggs remove this before prod, temp fix to get load testing running "*", - ENV_TOKENS.get('LMS_BASE'), - FEATURES['PREVIEW_LMS_BASE'], + _YAML_TOKENS.get('LMS_BASE'), ] -# Sometimes, OAuth2 clients want the user to redirect back to their site after logout. But to determine if the given -# redirect URL/path is safe for redirection, the following variable is used by edX. -LOGIN_REDIRECT_WHITELIST = ENV_TOKENS.get( - 'LOGIN_REDIRECT_WHITELIST', - LOGIN_REDIRECT_WHITELIST -) - -# allow for environments to specify what cookie name our login subsystem should use -# this is to fix a bug regarding simultaneous logins between edx.org and edge.edx.org which can -# happen with some browsers (e.g. Firefox) -if ENV_TOKENS.get('SESSION_COOKIE_NAME', None): - # NOTE, there's a bug in Django (http://bugs.python.org/issue18012) which necessitates this being a str() - SESSION_COOKIE_NAME = str(ENV_TOKENS.get('SESSION_COOKIE_NAME')) - -# This is the domain that is used to set shared cookies between various sub-domains. -# By default, it's set to the same thing as the SESSION_COOKIE_DOMAIN, but we want to make it overrideable. -SHARED_COOKIE_DOMAIN = ENV_TOKENS.get('SHARED_COOKIE_DOMAIN', SESSION_COOKIE_DOMAIN) - -CACHES = ENV_TOKENS.get('CACHES', CACHES) # Cache used for location mapping -- called many times with the same key/value # in a given request. if 'loc_cache' not in CACHES: @@ -225,234 +274,62 @@ if 'staticfiles' in CACHES: # we need to run asset collection twice, once for local disk and once for S3. # Once we have migrated to service assets off S3, then we can convert this back to # managed by the yaml file contents -STATICFILES_STORAGE = os.environ.get('STATICFILES_STORAGE', ENV_TOKENS.get('STATICFILES_STORAGE', STATICFILES_STORAGE)) -# Load all AWS_ prefixed variables to allow an S3Boto3Storage to be configured -_locals = locals() -for key, value in ENV_TOKENS.items(): - if key.startswith('AWS_'): - _locals[key] = value - -# Currency -PAID_COURSE_REGISTRATION_CURRENCY = ENV_TOKENS.get('PAID_COURSE_REGISTRATION_CURRENCY', - PAID_COURSE_REGISTRATION_CURRENCY) - -# We want Bulk Email running on the high-priority queue, so we define the -# routing key that points to it. At the moment, the name is the same. -# We have to reset the value here, since we have changed the value of the queue name. -BULK_EMAIL_ROUTING_KEY = ENV_TOKENS.get('BULK_EMAIL_ROUTING_KEY', HIGH_PRIORITY_QUEUE) - -# We can run smaller jobs on the low priority queue. See note above for why -# we have to reset the value here. -BULK_EMAIL_ROUTING_KEY_SMALL_JOBS = ENV_TOKENS.get('BULK_EMAIL_ROUTING_KEY_SMALL_JOBS', DEFAULT_PRIORITY_QUEUE) - -# Queue to use for expiring old entitlements -ENTITLEMENTS_EXPIRATION_ROUTING_KEY = ENV_TOKENS.get('ENTITLEMENTS_EXPIRATION_ROUTING_KEY', DEFAULT_PRIORITY_QUEUE) - -# Message expiry time in seconds -CELERY_EVENT_QUEUE_TTL = ENV_TOKENS.get('CELERY_EVENT_QUEUE_TTL', None) - -# Allow CELERY_QUEUES to be overwritten by ENV_TOKENS, -ENV_CELERY_QUEUES = ENV_TOKENS.get('CELERY_QUEUES', None) -if ENV_CELERY_QUEUES: - CELERY_QUEUES = {queue: {} for queue in ENV_CELERY_QUEUES} +# Build a CELERY_QUEUES dict the way that celery expects, based on a couple lists of queue names from the YAML. +_YAML_CELERY_QUEUES = _YAML_TOKENS.get('CELERY_QUEUES', None) +if _YAML_CELERY_QUEUES: + CELERY_QUEUES = {queue: {} for queue in _YAML_CELERY_QUEUES} # Then add alternate environment queues -ALTERNATE_QUEUE_ENVS = ENV_TOKENS.get('ALTERNATE_WORKER_QUEUES', '').split() +_YAML_ALTERNATE_WORKER_QUEUES = _YAML_TOKENS.get('ALTERNATE_WORKER_QUEUES', '').split() ALTERNATE_QUEUES = [ DEFAULT_PRIORITY_QUEUE.replace(QUEUE_VARIANT, alternate + '.') - for alternate in ALTERNATE_QUEUE_ENVS + for alternate in _YAML_ALTERNATE_WORKER_QUEUES ] + CELERY_QUEUES.update( { alternate: {} for alternate in ALTERNATE_QUEUES - if alternate not in list(CELERY_QUEUES.keys()) + if alternate not in CELERY_QUEUES.keys() } ) -# following setting is for backward compatibility -if ENV_TOKENS.get('COMPREHENSIVE_THEME_DIR', None): - COMPREHENSIVE_THEME_DIR = ENV_TOKENS.get('COMPREHENSIVE_THEME_DIR') - - -# COMPREHENSIVE_THEME_LOCALE_PATHS contain the paths to themes locale directories e.g. -# "COMPREHENSIVE_THEME_LOCALE_PATHS" : [ -# "/edx/src/edx-themes/conf/locale" -# ], -COMPREHENSIVE_THEME_LOCALE_PATHS = ENV_TOKENS.get('COMPREHENSIVE_THEME_LOCALE_PATHS', []) - - -# PREPEND_LOCALE_PATHS contain the paths to locale directories to load first e.g. -# "PREPEND_LOCALE_PATHS" : [ -# "/edx/my-locale" -# ], -PREPEND_LOCALE_PATHS = ENV_TOKENS.get('PREPEND_LOCALE_PATHS', []) - - -MKTG_URL_LINK_MAP.update(ENV_TOKENS.get('MKTG_URL_LINK_MAP', {})) -ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS = ENV_TOKENS.get( - 'ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS', - ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS -) -# Marketing link overrides -MKTG_URL_OVERRIDES.update(ENV_TOKENS.get('MKTG_URL_OVERRIDES', MKTG_URL_OVERRIDES)) - -# Intentional defaults. -ID_VERIFICATION_SUPPORT_LINK = ENV_TOKENS.get('ID_VERIFICATION_SUPPORT_LINK', SUPPORT_SITE_LINK) -PASSWORD_RESET_SUPPORT_LINK = ENV_TOKENS.get('PASSWORD_RESET_SUPPORT_LINK', SUPPORT_SITE_LINK) -ACTIVATION_EMAIL_SUPPORT_LINK = ENV_TOKENS.get('ACTIVATION_EMAIL_SUPPORT_LINK', SUPPORT_SITE_LINK) -LOGIN_ISSUE_SUPPORT_LINK = ENV_TOKENS.get('LOGIN_ISSUE_SUPPORT_LINK', SUPPORT_SITE_LINK) +MKTG_URL_LINK_MAP.update(_YAML_TOKENS.get('MKTG_URL_LINK_MAP', {})) # Timezone overrides -TIME_ZONE = ENV_TOKENS.get('CELERY_TIMEZONE', CELERY_TIMEZONE) +TIME_ZONE = CELERY_TIMEZONE # Translation overrides LANGUAGE_DICT = dict(LANGUAGES) -LANGUAGE_COOKIE_NAME = ENV_TOKENS.get('LANGUAGE_COOKIE', None) or ENV_TOKENS.get( - 'LANGUAGE_COOKIE_NAME', LANGUAGE_COOKIE_NAME) +LANGUAGE_COOKIE_NAME = _YAML_TOKENS.get('LANGUAGE_COOKIE') or LANGUAGE_COOKIE_NAME # Additional installed apps -for app in ENV_TOKENS.get('ADDL_INSTALLED_APPS', []): +for app in _YAML_TOKENS.get('ADDL_INSTALLED_APPS', []): INSTALLED_APPS.append(app) - -local_loglevel = ENV_TOKENS.get('LOCAL_LOGLEVEL', 'INFO') -LOG_DIR = ENV_TOKENS.get('LOG_DIR', LOG_DIR) - -LOGGING = get_logger_config(LOG_DIR, - logging_env=ENV_TOKENS.get('LOGGING_ENV', LOGGING_ENV), - local_loglevel=local_loglevel, - service_variant=SERVICE_VARIANT) - -COURSE_LISTINGS = ENV_TOKENS.get('COURSE_LISTINGS', {}) -COMMENTS_SERVICE_URL = ENV_TOKENS.get("COMMENTS_SERVICE_URL", '') -COMMENTS_SERVICE_KEY = ENV_TOKENS.get("COMMENTS_SERVICE_KEY", '') -CERT_QUEUE = ENV_TOKENS.get("CERT_QUEUE", 'test-pull') - -# Python lib settings -PYTHON_LIB_FILENAME = ENV_TOKENS.get('PYTHON_LIB_FILENAME', 'python_lib.zip') - -# Code jail settings -for name, value in ENV_TOKENS.get("CODE_JAIL", {}).items(): - oldvalue = CODE_JAIL.get(name) - if isinstance(oldvalue, dict): - for subname, subvalue in value.items(): - oldvalue[subname] = subvalue - else: - CODE_JAIL[name] = value - -COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", []) - -# Event Tracking -if "TRACKING_IGNORE_URL_PATTERNS" in ENV_TOKENS: - TRACKING_IGNORE_URL_PATTERNS = ENV_TOKENS.get("TRACKING_IGNORE_URL_PATTERNS") - -# SSL external authentication settings -SSL_AUTH_EMAIL_DOMAIN = ENV_TOKENS.get("SSL_AUTH_EMAIL_DOMAIN", "MIT.EDU") -SSL_AUTH_DN_FORMAT_STRING = ENV_TOKENS.get( - "SSL_AUTH_DN_FORMAT_STRING", - "/C=US/ST=Massachusetts/O=Massachusetts Institute of Technology/OU=Client CA v1/CN={0}/emailAddress={1}" +LOGGING = get_logger_config( + LOG_DIR, + logging_env=LOGGING_ENV, + local_loglevel=LOCAL_LOGLEVEL, + service_variant=SERVICE_VARIANT, ) -# Video Caching. Pairing country codes with CDN URLs. -# Example: {'CN': 'http://api.xuetangx.com/edx/video?s3_url='} -VIDEO_CDN_URL = ENV_TOKENS.get('VIDEO_CDN_URL', {}) +CSRF_TRUSTED_ORIGINS = _YAML_TOKENS.get('CSRF_TRUSTED_ORIGINS_WITH_SCHEME', []) -# Determines whether the CSRF token can be transported on -# unencrypted channels. It is set to False here for backward compatibility, -# but it is highly recommended that this is True for environments accessed -# by end users. -CSRF_COOKIE_SECURE = ENV_TOKENS.get('CSRF_COOKIE_SECURE', False) - -# Determines which origins are trusted for unsafe requests eg. POST requests. -CSRF_TRUSTED_ORIGINS = ENV_TOKENS.get('CSRF_TRUSTED_ORIGINS', []) -# values are already updated above with default CSRF_TRUSTED_ORIGINS values but in -# case of new django version these values will override. -if django.VERSION[0] >= 4: # for greater than django 3.2 use schemes. - CSRF_TRUSTED_ORIGINS = ENV_TOKENS.get('CSRF_TRUSTED_ORIGINS_WITH_SCHEME', []) - -############# CORS headers for cross-domain requests ################# - -if FEATURES.get('ENABLE_CORS_HEADERS') or FEATURES.get('ENABLE_CROSS_DOMAIN_CSRF_COOKIE'): +if FEATURES['ENABLE_CORS_HEADERS'] or FEATURES.get('ENABLE_CROSS_DOMAIN_CSRF_COOKIE'): CORS_ALLOW_CREDENTIALS = True - CORS_ORIGIN_WHITELIST = ENV_TOKENS.get('CORS_ORIGIN_WHITELIST', ()) - - CORS_ORIGIN_ALLOW_ALL = ENV_TOKENS.get('CORS_ORIGIN_ALLOW_ALL', False) - CORS_ALLOW_INSECURE = ENV_TOKENS.get('CORS_ALLOW_INSECURE', False) - - # If setting a cross-domain cookie, it's really important to choose - # a name for the cookie that is DIFFERENT than the cookies used - # by each subdomain. For example, suppose the applications - # at these subdomains are configured to use the following cookie names: - # - # 1) foo.example.com --> "csrftoken" - # 2) baz.example.com --> "csrftoken" - # 3) bar.example.com --> "csrftoken" - # - # For the cross-domain version of the CSRF cookie, you need to choose - # a name DIFFERENT than "csrftoken"; otherwise, the new token configured - # for ".example.com" could conflict with the other cookies, - # non-deterministically causing 403 responses. - # - # Because of the way Django stores cookies, the cookie name MUST - # be a `str`, not unicode. Otherwise there will `TypeError`s will be raised - # when Django tries to call the unicode `translate()` method with the wrong - # number of parameters. - CROSS_DOMAIN_CSRF_COOKIE_NAME = str(ENV_TOKENS.get('CROSS_DOMAIN_CSRF_COOKIE_NAME')) - - # When setting the domain for the "cross-domain" version of the CSRF - # cookie, you should choose something like: ".example.com" - # (note the leading dot), where both the referer and the host - # are subdomains of "example.com". - # - # Browser security rules require that - # the cookie domain matches the domain of the server; otherwise - # the cookie won't get set. And once the cookie gets set, the client - # needs to be on a domain that matches the cookie domain, otherwise - # the client won't be able to read the cookie. - CROSS_DOMAIN_CSRF_COOKIE_DOMAIN = ENV_TOKENS.get('CROSS_DOMAIN_CSRF_COOKIE_DOMAIN') - - -# Field overrides. To use the IDDE feature, add -# 'courseware.student_field_overrides.IndividualStudentOverrideProvider'. -FIELD_OVERRIDE_PROVIDERS = tuple(ENV_TOKENS.get('FIELD_OVERRIDE_PROVIDERS', [])) - -############### XBlock filesystem field config ########## -if 'DJFS' in AUTH_TOKENS and AUTH_TOKENS['DJFS'] is not None: - DJFS = AUTH_TOKENS['DJFS'] - -############### Module Store Items ########## -HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS = ENV_TOKENS.get('HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS', {}) -# PREVIEW DOMAIN must be present in HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS for the preview to show draft changes -if 'PREVIEW_LMS_BASE' in FEATURES and FEATURES['PREVIEW_LMS_BASE'] != '': - PREVIEW_DOMAIN = FEATURES['PREVIEW_LMS_BASE'].split(':')[0] - # update dictionary with preview domain regex - HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS.update({ - PREVIEW_DOMAIN: 'draft-preferred' - }) - -MODULESTORE_FIELD_OVERRIDE_PROVIDERS = ENV_TOKENS.get( - 'MODULESTORE_FIELD_OVERRIDE_PROVIDERS', - MODULESTORE_FIELD_OVERRIDE_PROVIDERS -) - -XBLOCK_FIELD_DATA_WRAPPERS = ENV_TOKENS.get( - 'XBLOCK_FIELD_DATA_WRAPPERS', - XBLOCK_FIELD_DATA_WRAPPERS -) + CORS_ORIGIN_WHITELIST = _YAML_TOKENS.get('CORS_ORIGIN_WHITELIST', ()) + CORS_ORIGIN_ALLOW_ALL = _YAML_TOKENS.get('CORS_ORIGIN_ALLOW_ALL', False) + CORS_ALLOW_INSECURE = _YAML_TOKENS.get('CORS_ALLOW_INSECURE', False) + CROSS_DOMAIN_CSRF_COOKIE_DOMAIN = _YAML_TOKENS.get('CROSS_DOMAIN_CSRF_COOKIE_DOMAIN') ############### Mixed Related(Secure/Not-Secure) Items ########## -LMS_SEGMENT_KEY = AUTH_TOKENS.get('SEGMENT_KEY') +LMS_SEGMENT_KEY = _YAML_TOKENS.get('SEGMENT_KEY') -SECRET_KEY = AUTH_TOKENS['SECRET_KEY'] - -AWS_ACCESS_KEY_ID = AUTH_TOKENS.get("AWS_ACCESS_KEY_ID", AWS_ACCESS_KEY_ID) if AWS_ACCESS_KEY_ID == "": AWS_ACCESS_KEY_ID = None - -AWS_SECRET_ACCESS_KEY = AUTH_TOKENS.get("AWS_SECRET_ACCESS_KEY", AWS_SECRET_ACCESS_KEY) if AWS_SECRET_ACCESS_KEY == "": AWS_SECRET_ACCESS_KEY = None @@ -461,24 +338,10 @@ if AWS_SECRET_ACCESS_KEY == "": # same with upcoming version setting it to `public-read`. AWS_DEFAULT_ACL = 'public-read' AWS_BUCKET_ACL = AWS_DEFAULT_ACL -AWS_STORAGE_BUCKET_NAME = AUTH_TOKENS.get('AWS_STORAGE_BUCKET_NAME', 'edxuploads') -# Disabling querystring auth instructs Boto to exclude the querystring parameters (e.g. signature, access key) it -# normally appends to every returned URL. -AWS_QUERYSTRING_AUTH = AUTH_TOKENS.get('AWS_QUERYSTRING_AUTH', True) -AWS_S3_CUSTOM_DOMAIN = AUTH_TOKENS.get('AWS_S3_CUSTOM_DOMAIN', 'edxuploads.s3.amazonaws.com') - -if AUTH_TOKENS.get('DEFAULT_FILE_STORAGE'): - DEFAULT_FILE_STORAGE = AUTH_TOKENS.get('DEFAULT_FILE_STORAGE') -elif AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY: +# Change to S3Boto3 if we haven't specified another default storage AND we have specified AWS creds. +if (not _YAML_TOKENS.get('DEFAULT_FILE_STORAGE')) and AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY: DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' -else: - DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' - - -# If there is a database called 'read_replica', you can use the use_read_replica_if_available -# function in util/query.py, which is useful for very large database reads -DATABASES = AUTH_TOKENS.get('DATABASES', DATABASES) # The normal database user does not have enough permissions to run migrations. # Migrations are run with separate credentials, given as DB_MIGRATION_* @@ -494,154 +357,35 @@ for name, database in DATABASES.items(): 'PORT': os.environ.get('DB_MIGRATION_PORT', database['PORT']), }) -XQUEUE_INTERFACE = AUTH_TOKENS.get('XQUEUE_INTERFACE', XQUEUE_INTERFACE) - # Get the MODULESTORE from auth.json, but if it doesn't exist, # use the one from common.py -MODULESTORE = convert_module_store_setting_if_needed(AUTH_TOKENS.get('MODULESTORE', MODULESTORE)) - -# After conversion above, the modulestore will have a "stores" list with all defined stores, for all stores, add the -# fs_root entry to derived collection so that if it's a callable it can be resolved. We need to do this because the -# `derived_collection_entry` takes an exact index value but the config file might have overridden the number of stores -# and so we can't be sure that the 2 we define in common.py will be there when we try to derive settings. This could -# lead to exceptions being thrown when the `derive_settings` call later in this file tries to update settings. We call -# the derived_collection_entry function here to ensure that we update the fs_root for any callables that remain after -# we've updated the MODULESTORE setting from our config file. -for idx, store in enumerate(MODULESTORE['default']['OPTIONS']['stores']): - if 'OPTIONS' in store and 'fs_root' in store["OPTIONS"]: - derived_collection_entry('MODULESTORE', 'default', 'OPTIONS', 'stores', idx, 'OPTIONS', 'fs_root') - -MONGODB_LOG = AUTH_TOKENS.get('MONGODB_LOG', {}) - -EMAIL_HOST_USER = AUTH_TOKENS.get('EMAIL_HOST_USER', '') # django default is '' -EMAIL_HOST_PASSWORD = AUTH_TOKENS.get('EMAIL_HOST_PASSWORD', '') # django default is '' - -# Analytics API -ANALYTICS_API_KEY = AUTH_TOKENS.get("ANALYTICS_API_KEY", ANALYTICS_API_KEY) -ANALYTICS_API_URL = ENV_TOKENS.get("ANALYTICS_API_URL", ANALYTICS_API_URL) - -# Zendesk -ZENDESK_USER = AUTH_TOKENS.get("ZENDESK_USER") -ZENDESK_API_KEY = AUTH_TOKENS.get("ZENDESK_API_KEY") - -# API Key for inbound requests from Notifier service -EDX_API_KEY = AUTH_TOKENS.get("EDX_API_KEY") - -# Celery Broker -CELERY_BROKER_TRANSPORT = ENV_TOKENS.get("CELERY_BROKER_TRANSPORT", "") -CELERY_BROKER_HOSTNAME = ENV_TOKENS.get("CELERY_BROKER_HOSTNAME", "") -CELERY_BROKER_VHOST = ENV_TOKENS.get("CELERY_BROKER_VHOST", "") -CELERY_BROKER_USER = AUTH_TOKENS.get("CELERY_BROKER_USER", "") -CELERY_BROKER_PASSWORD = AUTH_TOKENS.get("CELERY_BROKER_PASSWORD", "") +MODULESTORE = convert_module_store_setting_if_needed(MODULESTORE) BROKER_URL = "{}://{}:{}@{}/{}".format(CELERY_BROKER_TRANSPORT, CELERY_BROKER_USER, CELERY_BROKER_PASSWORD, CELERY_BROKER_HOSTNAME, CELERY_BROKER_VHOST) -BROKER_USE_SSL = ENV_TOKENS.get('CELERY_BROKER_USE_SSL', False) - try: BROKER_TRANSPORT_OPTIONS = { 'fanout_patterns': True, 'fanout_prefix': True, - **ENV_TOKENS.get('CELERY_BROKER_TRANSPORT_OPTIONS', {}) + **_YAML_TOKENS.get('CELERY_BROKER_TRANSPORT_OPTIONS', {}) } except TypeError as exc: raise ImproperlyConfigured('CELERY_BROKER_TRANSPORT_OPTIONS must be a dict') from exc -# Block Structures - -# upload limits -STUDENT_FILEUPLOAD_MAX_SIZE = ENV_TOKENS.get("STUDENT_FILEUPLOAD_MAX_SIZE", STUDENT_FILEUPLOAD_MAX_SIZE) - # Event tracking -TRACKING_BACKENDS.update(AUTH_TOKENS.get("TRACKING_BACKENDS", {})) -EVENT_TRACKING_BACKENDS['tracking_logs']['OPTIONS']['backends'].update(AUTH_TOKENS.get("EVENT_TRACKING_BACKENDS", {})) +TRACKING_BACKENDS.update(_YAML_TOKENS.get("TRACKING_BACKENDS", {})) +EVENT_TRACKING_BACKENDS['tracking_logs']['OPTIONS']['backends'].update( + _YAML_TOKENS.get("EVENT_TRACKING_BACKENDS", {}) +) EVENT_TRACKING_BACKENDS['segmentio']['OPTIONS']['processors'][0]['OPTIONS']['whitelist'].extend( - AUTH_TOKENS.get("EVENT_TRACKING_SEGMENTIO_EMIT_WHITELIST", [])) -TRACKING_SEGMENTIO_WEBHOOK_SECRET = AUTH_TOKENS.get( - "TRACKING_SEGMENTIO_WEBHOOK_SECRET", - TRACKING_SEGMENTIO_WEBHOOK_SECRET + EVENT_TRACKING_SEGMENTIO_EMIT_WHITELIST ) -TRACKING_SEGMENTIO_ALLOWED_TYPES = ENV_TOKENS.get("TRACKING_SEGMENTIO_ALLOWED_TYPES", TRACKING_SEGMENTIO_ALLOWED_TYPES) -TRACKING_SEGMENTIO_DISALLOWED_SUBSTRING_NAMES = ENV_TOKENS.get( - "TRACKING_SEGMENTIO_DISALLOWED_SUBSTRING_NAMES", - TRACKING_SEGMENTIO_DISALLOWED_SUBSTRING_NAMES -) -TRACKING_SEGMENTIO_SOURCE_MAP = ENV_TOKENS.get("TRACKING_SEGMENTIO_SOURCE_MAP", TRACKING_SEGMENTIO_SOURCE_MAP) - -# Heartbeat -HEARTBEAT_CELERY_ROUTING_KEY = ENV_TOKENS.get('HEARTBEAT_CELERY_ROUTING_KEY', HEARTBEAT_CELERY_ROUTING_KEY) - -# Student identity verification settings -VERIFY_STUDENT = AUTH_TOKENS.get("VERIFY_STUDENT", VERIFY_STUDENT) -DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH = ENV_TOKENS.get( - "DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH", - DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH -) - -# Grades download -GRADES_DOWNLOAD_ROUTING_KEY = ENV_TOKENS.get('GRADES_DOWNLOAD_ROUTING_KEY', HIGH_MEM_QUEUE) - -GRADES_DOWNLOAD = ENV_TOKENS.get("GRADES_DOWNLOAD", GRADES_DOWNLOAD) - -# Rate limit for regrading tasks that a grading policy change can kick off - -# financial reports -FINANCIAL_REPORTS = ENV_TOKENS.get("FINANCIAL_REPORTS", FINANCIAL_REPORTS) - -##### ORA2 ###### -# Prefix for uploads of example-based assessment AI classifiers -# This can be used to separate uploads for different environments -# within the same S3 bucket. -ORA2_FILE_PREFIX = ENV_TOKENS.get("ORA2_FILE_PREFIX", ORA2_FILE_PREFIX) - -##### ACCOUNT LOCKOUT DEFAULT PARAMETERS ##### -MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED = ENV_TOKENS.get( - "MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED", MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED -) - -MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = ENV_TOKENS.get( - "MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS", MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS -) - -##### LOGISTRATION RATE LIMIT SETTINGS ##### -LOGISTRATION_RATELIMIT_RATE = ENV_TOKENS.get('LOGISTRATION_RATELIMIT_RATE', LOGISTRATION_RATELIMIT_RATE) -LOGISTRATION_API_RATELIMIT = ENV_TOKENS.get('LOGISTRATION_API_RATELIMIT', LOGISTRATION_API_RATELIMIT) -LOGIN_AND_REGISTER_FORM_RATELIMIT = ENV_TOKENS.get( - 'LOGIN_AND_REGISTER_FORM_RATELIMIT', LOGIN_AND_REGISTER_FORM_RATELIMIT -) -RESET_PASSWORD_TOKEN_VALIDATE_API_RATELIMIT = ENV_TOKENS.get( - 'RESET_PASSWORD_TOKEN_VALIDATE_API_RATELIMIT', RESET_PASSWORD_TOKEN_VALIDATE_API_RATELIMIT -) -RESET_PASSWORD_API_RATELIMIT = ENV_TOKENS.get('RESET_PASSWORD_API_RATELIMIT', RESET_PASSWORD_API_RATELIMIT) - -##### REGISTRATION RATE LIMIT SETTINGS ##### -REGISTRATION_VALIDATION_RATELIMIT = ENV_TOKENS.get( - 'REGISTRATION_VALIDATION_RATELIMIT', REGISTRATION_VALIDATION_RATELIMIT -) - -REGISTRATION_RATELIMIT = ENV_TOKENS.get('REGISTRATION_RATELIMIT', REGISTRATION_RATELIMIT) - -#### PASSWORD POLICY SETTINGS ##### -AUTH_PASSWORD_VALIDATORS = ENV_TOKENS.get("AUTH_PASSWORD_VALIDATORS", AUTH_PASSWORD_VALIDATORS) - -### INACTIVITY SETTINGS #### -SESSION_INACTIVITY_TIMEOUT_IN_SECONDS = AUTH_TOKENS.get("SESSION_INACTIVITY_TIMEOUT_IN_SECONDS") - -##### LMS DEADLINE DISPLAY TIME_ZONE ####### -TIME_ZONE_DISPLAYED_FOR_DEADLINES = ENV_TOKENS.get("TIME_ZONE_DISPLAYED_FOR_DEADLINES", - TIME_ZONE_DISPLAYED_FOR_DEADLINES) - -#### PROCTORED EXAM SETTINGS #### -PROCTORED_EXAM_VIEWABLE_PAST_DUE = ENV_TOKENS.get('PROCTORED_EXAM_VIEWABLE_PAST_DUE', False) - -##### Third-party auth options ################################################ -ENABLE_REQUIRE_THIRD_PARTY_AUTH = ENV_TOKENS.get('ENABLE_REQUIRE_THIRD_PARTY_AUTH', False) if FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): - tmp_backends = ENV_TOKENS.get('THIRD_PARTY_AUTH_BACKENDS', [ + AUTHENTICATION_BACKENDS = _YAML_TOKENS.get('THIRD_PARTY_AUTH_BACKENDS', [ 'social_core.backends.google.GoogleOAuth2', 'social_core.backends.linkedin.LinkedinOAuth2', 'social_core.backends.facebook.FacebookOAuth2', @@ -650,136 +394,66 @@ if FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): 'common.djangoapps.third_party_auth.identityserver3.IdentityServer3', 'common.djangoapps.third_party_auth.saml.SAMLAuthBackend', 'common.djangoapps.third_party_auth.lti.LTIAuthBackend', - ]) - - AUTHENTICATION_BACKENDS = list(tmp_backends) + list(AUTHENTICATION_BACKENDS) - del tmp_backends + ]) + list(AUTHENTICATION_BACKENDS) # The reduced session expiry time during the third party login pipeline. (Value in seconds) - SOCIAL_AUTH_PIPELINE_TIMEOUT = ENV_TOKENS.get('SOCIAL_AUTH_PIPELINE_TIMEOUT', 600) + SOCIAL_AUTH_PIPELINE_TIMEOUT = _YAML_TOKENS.get('SOCIAL_AUTH_PIPELINE_TIMEOUT', 600) - # Most provider configuration is done via ConfigurationModels but for a few sensitive values - # we allow configuration via AUTH_TOKENS instead (optionally). - # The SAML private/public key values do not need the delimiter lines (such as - # "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----" etc.) but they may be included - # if you want (though it's easier to format the key values as JSON without the delimiters). - SOCIAL_AUTH_SAML_SP_PRIVATE_KEY = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PRIVATE_KEY', '') - SOCIAL_AUTH_SAML_SP_PUBLIC_CERT = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PUBLIC_CERT', '') - SOCIAL_AUTH_SAML_SP_PRIVATE_KEY_DICT = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PRIVATE_KEY_DICT', {}) - SOCIAL_AUTH_SAML_SP_PUBLIC_CERT_DICT = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PUBLIC_CERT_DICT', {}) - SOCIAL_AUTH_OAUTH_SECRETS = AUTH_TOKENS.get('SOCIAL_AUTH_OAUTH_SECRETS', {}) - SOCIAL_AUTH_LTI_CONSUMER_SECRETS = AUTH_TOKENS.get('SOCIAL_AUTH_LTI_CONSUMER_SECRETS', {}) + # TODO: Would it be safe to just set this default in common.py, even if ENABLE_THIRD_PARTY_AUTH is False? + SOCIAL_AUTH_LTI_CONSUMER_SECRETS = _YAML_TOKENS.get('SOCIAL_AUTH_LTI_CONSUMER_SECRETS', {}) # third_party_auth config moved to ConfigurationModels. This is for data migration only: - THIRD_PARTY_AUTH_OLD_CONFIG = AUTH_TOKENS.get('THIRD_PARTY_AUTH', None) + THIRD_PARTY_AUTH_OLD_CONFIG = _YAML_TOKENS.get('THIRD_PARTY_AUTH', None) - if ENV_TOKENS.get('THIRD_PARTY_AUTH_SAML_FETCH_PERIOD_HOURS', 24) is not None: + # TODO: This logic is somewhat insane. We're not sure if it's intentional or not. We've left it + # as-is for strict backwards compatibility, but it's worth revisiting. + if hours := _YAML_TOKENS.get('THIRD_PARTY_AUTH_SAML_FETCH_PERIOD_HOURS', 24): + # If we didn't override the value in YAML, OR we overrode it to a truthy value, + # then update CELERYBEAT_SCHEDULE. CELERYBEAT_SCHEDULE['refresh-saml-metadata'] = { 'task': 'common.djangoapps.third_party_auth.fetch_saml_metadata', - 'schedule': datetime.timedelta(hours=ENV_TOKENS.get('THIRD_PARTY_AUTH_SAML_FETCH_PERIOD_HOURS', 24)), + 'schedule': datetime.timedelta(hours=hours), } # The following can be used to integrate a custom login form with third_party_auth. # It should be a dict where the key is a word passed via ?auth_entry=, and the value is a # dict with an arbitrary 'secret_key' and a 'url'. - THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS = AUTH_TOKENS.get('THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS', {}) + THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS = _YAML_TOKENS.get('THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS', {}) ##### OAUTH2 Provider ############## -if FEATURES.get('ENABLE_OAUTH2_PROVIDER'): - OAUTH_ENFORCE_SECURE = ENV_TOKENS.get('OAUTH_ENFORCE_SECURE', True) - OAUTH_ENFORCE_CLIENT_SECURE = ENV_TOKENS.get('OAUTH_ENFORCE_CLIENT_SECURE', True) +if FEATURES['ENABLE_OAUTH2_PROVIDER']: + OAUTH_ENFORCE_SECURE = True + OAUTH_ENFORCE_CLIENT_SECURE = True # Defaults for the following are defined in lms.envs.common - OAUTH_EXPIRE_DELTA = datetime.timedelta( - days=ENV_TOKENS.get('OAUTH_EXPIRE_CONFIDENTIAL_CLIENT_DAYS', OAUTH_EXPIRE_CONFIDENTIAL_CLIENT_DAYS) - ) - OAUTH_EXPIRE_DELTA_PUBLIC = datetime.timedelta( - days=ENV_TOKENS.get('OAUTH_EXPIRE_PUBLIC_CLIENT_DAYS', OAUTH_EXPIRE_PUBLIC_CLIENT_DAYS) - ) + OAUTH_EXPIRE_DELTA = datetime.timedelta(days=OAUTH_EXPIRE_CONFIDENTIAL_CLIENT_DAYS) + OAUTH_EXPIRE_DELTA_PUBLIC = datetime.timedelta(days=OAUTH_EXPIRE_PUBLIC_CLIENT_DAYS) - -##### GOOGLE ANALYTICS IDS ##### -GOOGLE_ANALYTICS_ACCOUNT = AUTH_TOKENS.get('GOOGLE_ANALYTICS_ACCOUNT') -GOOGLE_ANALYTICS_TRACKING_ID = AUTH_TOKENS.get('GOOGLE_ANALYTICS_TRACKING_ID') -GOOGLE_ANALYTICS_LINKEDIN = AUTH_TOKENS.get('GOOGLE_ANALYTICS_LINKEDIN') -GOOGLE_SITE_VERIFICATION_ID = ENV_TOKENS.get('GOOGLE_SITE_VERIFICATION_ID') -GOOGLE_ANALYTICS_4_ID = AUTH_TOKENS.get('GOOGLE_ANALYTICS_4_ID') - -##### BRANCH.IO KEY ##### -BRANCH_IO_KEY = AUTH_TOKENS.get('BRANCH_IO_KEY') - -#### Course Registration Code length #### -REGISTRATION_CODE_LENGTH = ENV_TOKENS.get('REGISTRATION_CODE_LENGTH', 8) - -# Which access.py permission names to check; -# We default this to the legacy permission 'see_exists'. -COURSE_CATALOG_VISIBILITY_PERMISSION = ENV_TOKENS.get( - 'COURSE_CATALOG_VISIBILITY_PERMISSION', - COURSE_CATALOG_VISIBILITY_PERMISSION -) -COURSE_ABOUT_VISIBILITY_PERMISSION = ENV_TOKENS.get( - 'COURSE_ABOUT_VISIBILITY_PERMISSION', - COURSE_ABOUT_VISIBILITY_PERMISSION -) - -DEFAULT_COURSE_VISIBILITY_IN_CATALOG = ENV_TOKENS.get( - 'DEFAULT_COURSE_VISIBILITY_IN_CATALOG', - DEFAULT_COURSE_VISIBILITY_IN_CATALOG -) - -DEFAULT_MOBILE_AVAILABLE = ENV_TOKENS.get( - 'DEFAULT_MOBILE_AVAILABLE', - DEFAULT_MOBILE_AVAILABLE -) - -# Enrollment API Cache Timeout -ENROLLMENT_COURSE_DETAILS_CACHE_TIMEOUT = ENV_TOKENS.get('ENROLLMENT_COURSE_DETAILS_CACHE_TIMEOUT', 60) - -# Ecommerce Orders API Cache Timeout -ECOMMERCE_ORDERS_API_CACHE_TIMEOUT = ENV_TOKENS.get('ECOMMERCE_ORDERS_API_CACHE_TIMEOUT', 3600) - -if FEATURES.get('ENABLE_COURSEWARE_SEARCH') or \ - FEATURES.get('ENABLE_DASHBOARD_SEARCH') or \ - FEATURES.get('ENABLE_COURSE_DISCOVERY') or \ - FEATURES.get('ENABLE_TEAMS'): +if ( + FEATURES['ENABLE_COURSEWARE_SEARCH'] or + FEATURES['ENABLE_DASHBOARD_SEARCH'] or + FEATURES['ENABLE_COURSE_DISCOVERY'] or + FEATURES['ENABLE_TEAMS'] + ): # Use ElasticSearch as the search engine herein SEARCH_ENGINE = "search.elastic.ElasticSearchEngine" - SEARCH_FILTER_GENERATOR = ENV_TOKENS.get('SEARCH_FILTER_GENERATOR', SEARCH_FILTER_GENERATOR) - -SEARCH_SKIP_INVITATION_ONLY_FILTERING = ENV_TOKENS.get( - 'SEARCH_SKIP_INVITATION_ONLY_FILTERING', - SEARCH_SKIP_INVITATION_ONLY_FILTERING, -) -SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING = ENV_TOKENS.get( - 'SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING', - SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING, -) - -SEARCH_COURSEWARE_CONTENT_LOG_PARAMS = ENV_TOKENS.get( - 'SEARCH_COURSEWARE_CONTENT_LOG_PARAMS', - SEARCH_COURSEWARE_CONTENT_LOG_PARAMS, -) # TODO: Once we have successfully upgraded to ES7, switch this back to ELASTIC_SEARCH_CONFIG. -ELASTIC_SEARCH_CONFIG = ENV_TOKENS.get('ELASTIC_SEARCH_CONFIG_ES7', [{}]) +ELASTIC_SEARCH_CONFIG = _YAML_TOKENS.get('ELASTIC_SEARCH_CONFIG_ES7', [{}]) -# Facebook app -FACEBOOK_API_VERSION = AUTH_TOKENS.get("FACEBOOK_API_VERSION") -FACEBOOK_APP_SECRET = AUTH_TOKENS.get("FACEBOOK_APP_SECRET") -FACEBOOK_APP_ID = AUTH_TOKENS.get("FACEBOOK_APP_ID") - -XBLOCK_SETTINGS = ENV_TOKENS.get('XBLOCK_SETTINGS', {}) -XBLOCK_SETTINGS.setdefault("VideoBlock", {})["licensing_enabled"] = FEATURES.get("LICENSING", False) -XBLOCK_SETTINGS.setdefault("VideoBlock", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.get('YOUTUBE_API_KEY', YOUTUBE_API_KEY) +XBLOCK_SETTINGS.setdefault("VideoBlock", {})["licensing_enabled"] = FEATURES["LICENSING"] +XBLOCK_SETTINGS.setdefault("VideoBlock", {})['YOUTUBE_API_KEY'] = YOUTUBE_API_KEY ##### Custom Courses for EdX ##### -if FEATURES.get('CUSTOM_COURSES_EDX'): +if FEATURES['CUSTOM_COURSES_EDX']: INSTALLED_APPS += ['lms.djangoapps.ccx', 'openedx.core.djangoapps.ccxcon.apps.CCXConnectorConfig'] MODULESTORE_FIELD_OVERRIDE_PROVIDERS += ( 'lms.djangoapps.ccx.overrides.CustomCoursesForEdxOverrideProvider', ) +FIELD_OVERRIDE_PROVIDERS = tuple(FIELD_OVERRIDE_PROVIDERS) + ##### Individual Due Date Extensions ##### -if FEATURES.get('INDIVIDUAL_DUE_DATES'): +if FEATURES['INDIVIDUAL_DUE_DATES']: FIELD_OVERRIDE_PROVIDERS += ( 'lms.djangoapps.courseware.student_field_overrides.IndividualStudentOverrideProvider', ) @@ -800,225 +474,71 @@ MODULESTORE_FIELD_OVERRIDE_PROVIDERS += ( # PROFILE IMAGE CONFIG PROFILE_IMAGE_DEFAULT_FILENAME = 'images/profiles/default' -PROFILE_IMAGE_SIZES_MAP = ENV_TOKENS.get( - 'PROFILE_IMAGE_SIZES_MAP', - PROFILE_IMAGE_SIZES_MAP -) ##### Credit Provider Integration ##### -CREDIT_PROVIDER_SECRET_KEYS = AUTH_TOKENS.get("CREDIT_PROVIDER_SECRET_KEYS", {}) - ##################### LTI Provider ##################### -if FEATURES.get('ENABLE_LTI_PROVIDER'): +if FEATURES['ENABLE_LTI_PROVIDER']: INSTALLED_APPS.append('lms.djangoapps.lti_provider.apps.LtiProviderConfig') AUTHENTICATION_BACKENDS.append('lms.djangoapps.lti_provider.users.LtiBackend') -LTI_USER_EMAIL_DOMAIN = ENV_TOKENS.get('LTI_USER_EMAIL_DOMAIN', 'lti.example.com') - -# For more info on this, see the notes in common.py -LTI_AGGREGATE_SCORE_PASSBACK_DELAY = ENV_TOKENS.get( - 'LTI_AGGREGATE_SCORE_PASSBACK_DELAY', LTI_AGGREGATE_SCORE_PASSBACK_DELAY -) - ##################### Credit Provider help link #################### #### JWT configuration #### -JWT_AUTH.update(ENV_TOKENS.get('JWT_AUTH', {})) -JWT_AUTH.update(AUTH_TOKENS.get('JWT_AUTH', {})) +JWT_AUTH.update(_YAML_TOKENS.get('JWT_AUTH', {})) -# Offset for pk of courseware.StudentModuleHistoryExtended -STUDENTMODULEHISTORYEXTENDED_OFFSET = ENV_TOKENS.get( - 'STUDENTMODULEHISTORYEXTENDED_OFFSET', STUDENTMODULEHISTORYEXTENDED_OFFSET -) - -################################ Settings for Credentials Service ################################ - -CREDENTIALS_GENERATION_ROUTING_KEY = ENV_TOKENS.get('CREDENTIALS_GENERATION_ROUTING_KEY', DEFAULT_PRIORITY_QUEUE) - -# Queue to use for award program certificates -PROGRAM_CERTIFICATES_ROUTING_KEY = ENV_TOKENS.get('PROGRAM_CERTIFICATES_ROUTING_KEY', DEFAULT_PRIORITY_QUEUE) -SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY = ENV_TOKENS.get( - 'SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY', - HIGH_PRIORITY_QUEUE -) - -API_ACCESS_MANAGER_EMAIL = ENV_TOKENS.get('API_ACCESS_MANAGER_EMAIL') -API_ACCESS_FROM_EMAIL = ENV_TOKENS.get('API_ACCESS_FROM_EMAIL') - -############## OPEN EDX ENTERPRISE SERVICE CONFIGURATION ###################### -# The Open edX Enterprise service is currently hosted via the LMS container/process. -# However, for all intents and purposes this service is treated as a standalone IDA. -# These configuration settings are specific to the Enterprise service and you should -# not find references to them within the edx-platform project. - -# Publicly-accessible enrollment URL, for use on the client side. -ENTERPRISE_PUBLIC_ENROLLMENT_API_URL = ENV_TOKENS.get( - 'ENTERPRISE_PUBLIC_ENROLLMENT_API_URL', - (LMS_ROOT_URL or '') + LMS_ENROLLMENT_API_PATH -) - -# Enrollment URL used on the server-side. -ENTERPRISE_ENROLLMENT_API_URL = ENV_TOKENS.get( - 'ENTERPRISE_ENROLLMENT_API_URL', - (LMS_INTERNAL_ROOT_URL or '') + LMS_ENROLLMENT_API_PATH -) - -# Enterprise logo image size limit in KB's -ENTERPRISE_CUSTOMER_LOGO_IMAGE_SIZE = ENV_TOKENS.get( - 'ENTERPRISE_CUSTOMER_LOGO_IMAGE_SIZE', - ENTERPRISE_CUSTOMER_LOGO_IMAGE_SIZE -) - -# Course enrollment modes to be hidden in the Enterprise enrollment page -# if the "Hide audit track" flag is enabled for an EnterpriseCustomer -ENTERPRISE_COURSE_ENROLLMENT_AUDIT_MODES = ENV_TOKENS.get( - 'ENTERPRISE_COURSE_ENROLLMENT_AUDIT_MODES', - ENTERPRISE_COURSE_ENROLLMENT_AUDIT_MODES -) - -# A support URL used on Enterprise landing pages for when a warning -# message goes off. -ENTERPRISE_SUPPORT_URL = ENV_TOKENS.get( - 'ENTERPRISE_SUPPORT_URL', - ENTERPRISE_SUPPORT_URL -) - -# A default dictionary to be used for filtering out enterprise customer catalog. -ENTERPRISE_CUSTOMER_CATALOG_DEFAULT_CONTENT_FILTER = ENV_TOKENS.get( - 'ENTERPRISE_CUSTOMER_CATALOG_DEFAULT_CONTENT_FILTER', - ENTERPRISE_CUSTOMER_CATALOG_DEFAULT_CONTENT_FILTER -) -INTEGRATED_CHANNELS_API_CHUNK_TRANSMISSION_LIMIT = ENV_TOKENS.get( - 'INTEGRATED_CHANNELS_API_CHUNK_TRANSMISSION_LIMIT', - INTEGRATED_CHANNELS_API_CHUNK_TRANSMISSION_LIMIT -) - -############## ENTERPRISE SERVICE API CLIENT CONFIGURATION ###################### -# The LMS communicates with the Enterprise service via the requests.Session() client -# The below environmental settings are utilized by the LMS when interacting with -# the service, and override the default parameters which are defined in common.py - -DEFAULT_ENTERPRISE_API_URL = None -if LMS_INTERNAL_ROOT_URL is not None: - DEFAULT_ENTERPRISE_API_URL = LMS_INTERNAL_ROOT_URL + '/enterprise/api/v1/' -ENTERPRISE_API_URL = ENV_TOKENS.get('ENTERPRISE_API_URL', DEFAULT_ENTERPRISE_API_URL) - -DEFAULT_ENTERPRISE_CONSENT_API_URL = None -if LMS_INTERNAL_ROOT_URL is not None: - DEFAULT_ENTERPRISE_CONSENT_API_URL = LMS_INTERNAL_ROOT_URL + '/consent/api/v1/' -ENTERPRISE_CONSENT_API_URL = ENV_TOKENS.get('ENTERPRISE_CONSENT_API_URL', DEFAULT_ENTERPRISE_CONSENT_API_URL) - -ENTERPRISE_SERVICE_WORKER_USERNAME = ENV_TOKENS.get( - 'ENTERPRISE_SERVICE_WORKER_USERNAME', - ENTERPRISE_SERVICE_WORKER_USERNAME -) -ENTERPRISE_API_CACHE_TIMEOUT = ENV_TOKENS.get( - 'ENTERPRISE_API_CACHE_TIMEOUT', - ENTERPRISE_API_CACHE_TIMEOUT -) -ENTERPRISE_CATALOG_INTERNAL_ROOT_URL = ENV_TOKENS.get( - 'ENTERPRISE_CATALOG_INTERNAL_ROOT_URL', - ENTERPRISE_CATALOG_INTERNAL_ROOT_URL -) - -CHAT_COMPLETION_API = ENV_TOKENS.get('CHAT_COMPLETION_API', '') -CHAT_COMPLETION_API_KEY = ENV_TOKENS.get('CHAT_COMPLETION_API_KEY', '') -LEARNER_ENGAGEMENT_PROMPT_FOR_ACTIVE_CONTRACT = ENV_TOKENS.get('LEARNER_ENGAGEMENT_PROMPT_FOR_ACTIVE_CONTRACT', '') -LEARNER_ENGAGEMENT_PROMPT_FOR_NON_ACTIVE_CONTRACT = ENV_TOKENS.get( - 'LEARNER_ENGAGEMENT_PROMPT_FOR_NON_ACTIVE_CONTRACT', - '' -) -LEARNER_PROGRESS_PROMPT_FOR_ACTIVE_CONTRACT = ENV_TOKENS.get('LEARNER_PROGRESS_PROMPT_FOR_ACTIVE_CONTRACT', '') -LEARNER_PROGRESS_PROMPT_FOR_NON_ACTIVE_CONTRACT = ENV_TOKENS.get('LEARNER_PROGRESS_PROMPT_FOR_NON_ACTIVE_CONTRACT', '') ############## ENTERPRISE SERVICE LMS CONFIGURATION ################################## # The LMS has some features embedded that are related to the Enterprise service, but # which are not provided by the Enterprise service. These settings override the # base values for the parameters as defined in common.py -ENTERPRISE_PLATFORM_WELCOME_TEMPLATE = ENV_TOKENS.get( - 'ENTERPRISE_PLATFORM_WELCOME_TEMPLATE', - ENTERPRISE_PLATFORM_WELCOME_TEMPLATE -) -ENTERPRISE_SPECIFIC_BRANDED_WELCOME_TEMPLATE = ENV_TOKENS.get( - 'ENTERPRISE_SPECIFIC_BRANDED_WELCOME_TEMPLATE', - ENTERPRISE_SPECIFIC_BRANDED_WELCOME_TEMPLATE -) -ENTERPRISE_TAGLINE = ENV_TOKENS.get( - 'ENTERPRISE_TAGLINE', - ENTERPRISE_TAGLINE -) -ENTERPRISE_EXCLUDED_REGISTRATION_FIELDS = set( - ENV_TOKENS.get( - 'ENTERPRISE_EXCLUDED_REGISTRATION_FIELDS', - ENTERPRISE_EXCLUDED_REGISTRATION_FIELDS - ) -) -BASE_COOKIE_DOMAIN = ENV_TOKENS.get( - 'BASE_COOKIE_DOMAIN', - BASE_COOKIE_DOMAIN -) -SYSTEM_TO_FEATURE_ROLE_MAPPING = ENV_TOKENS.get( - 'SYSTEM_TO_FEATURE_ROLE_MAPPING', - SYSTEM_TO_FEATURE_ROLE_MAPPING -) - -# Add an ICP license for serving content in China if your organization is registered to do so -ICP_LICENSE = ENV_TOKENS.get('ICP_LICENSE', None) -ICP_LICENSE_INFO = ENV_TOKENS.get('ICP_LICENSE_INFO', {}) - -# How long to cache OpenAPI schemas and UI, in seconds. -OPENAPI_CACHE_TIMEOUT = ENV_TOKENS.get('OPENAPI_CACHE_TIMEOUT', 60 * 60) - -########################## Parental controls config ####################### - -# The age at which a learner no longer requires parental consent, or None -# if parental consent is never required. -PARENTAL_CONSENT_AGE_LIMIT = ENV_TOKENS.get( - 'PARENTAL_CONSENT_AGE_LIMIT', - PARENTAL_CONSENT_AGE_LIMIT -) +ENTERPRISE_EXCLUDED_REGISTRATION_FIELDS = set(ENTERPRISE_EXCLUDED_REGISTRATION_FIELDS) ########################## Extra middleware classes ####################### # Allow extra middleware classes to be added to the app through configuration. -MIDDLEWARE.extend(ENV_TOKENS.get('EXTRA_MIDDLEWARE_CLASSES', [])) +# TODO: Declare `EXTRA_MIDDLEWARE_CLASSES = []` in lms/envs/common.py so that we can simplify this +# next line. See CMS settings for the example of what we want. +MIDDLEWARE.extend(_YAML_TOKENS.get('EXTRA_MIDDLEWARE_CLASSES', [])) -################# Settings for the maintenance banner ################# -MAINTENANCE_BANNER_TEXT = ENV_TOKENS.get('MAINTENANCE_BANNER_TEXT', None) -########################## limiting dashboard courses ###################### -DASHBOARD_COURSE_LIMIT = ENV_TOKENS.get('DASHBOARD_COURSE_LIMIT', None) - -######################## Setting for content libraries ######################## -MAX_BLOCKS_PER_CONTENT_LIBRARY = ENV_TOKENS.get('MAX_BLOCKS_PER_CONTENT_LIBRARY', MAX_BLOCKS_PER_CONTENT_LIBRARY) - -########################## Derive Any Derived Settings ####################### +####################################################################################################################### +#### DERIVE ANY DERIVED SETTINGS +#### derive_settings(__name__) -############################### Plugin Settings ############################### + +####################################################################################################################### +#### LOAD SETTINGS FROM DJANGO PLUGINS +#### +#### This is at the bottom because it is going to load more settings after base settings are loaded +#### # This is at the bottom because it is going to load more settings after base settings are loaded +# These dicts are defined solely for BACKWARDS COMPATIBILITY with existing plugins which may theoretically +# rely upon them. Please do not add new references to these dicts! +# - If you need to access the YAML values in this module, use _YAML_TOKENS. +# - If you need to access to these values elsewhere, use the corresponding rendered `settings.*` +# value rathering than diving into these dicts. +ENV_TOKENS = _YAML_TOKENS +AUTH_TOKENS = _YAML_TOKENS +ENV_FEATURES = _YAML_TOKENS.get("FEATURES", {}) +ENV_CELERY_QUEUES = _YAML_CELERY_QUEUES +ALTERNATE_QUEUE_ENVS = _YAML_ALTERNATE_WORKER_QUEUES + # Load production.py in plugins add_plugins(__name__, ProjectType.LMS, SettingsType.PRODUCTION) -############## Settings for Completion API ######################### -# Once a user has watched this percentage of a video, mark it as complete: -# (0.0 = 0%, 1.0 = 100%) -COMPLETION_VIDEO_COMPLETE_PERCENTAGE = ENV_TOKENS.get('COMPLETION_VIDEO_COMPLETE_PERCENTAGE', - COMPLETION_VIDEO_COMPLETE_PERCENTAGE) -COMPLETION_BY_VIEWING_DELAY_MS = ENV_TOKENS.get('COMPLETION_BY_VIEWING_DELAY_MS', - COMPLETION_BY_VIEWING_DELAY_MS) - -################# Settings for brand logos. ################# -LOGO_URL = ENV_TOKENS.get('LOGO_URL', LOGO_URL) -LOGO_URL_PNG = ENV_TOKENS.get('LOGO_URL_PNG', LOGO_URL_PNG) -LOGO_TRADEMARK_URL = ENV_TOKENS.get('LOGO_TRADEMARK_URL', LOGO_TRADEMARK_URL) -FAVICON_URL = ENV_TOKENS.get('FAVICON_URL', FAVICON_URL) +####################################################################################################################### +#### MORE YAML POST-PROCESSING +#### +#### More post-processing, but these will not be available Django plugins. +#### Unclear whether or not these are down here intentionally. +#### ######################## CELERY ROUTING ######################## @@ -1078,56 +598,32 @@ EXPLICIT_QUEUES = { } -LOGO_IMAGE_EXTRA_TEXT = ENV_TOKENS.get('LOGO_IMAGE_EXTRA_TEXT', '') - ############## XBlock extra mixins ############################ XBLOCK_MIXINS += tuple(XBLOCK_EXTRA_MIXINS) -############## Settings for course import olx validation ############################ -COURSE_OLX_VALIDATION_STAGE = ENV_TOKENS.get('COURSE_OLX_VALIDATION_STAGE', COURSE_OLX_VALIDATION_STAGE) -COURSE_OLX_VALIDATION_IGNORE_LIST = ENV_TOKENS.get( - 'COURSE_OLX_VALIDATION_IGNORE_LIST', - COURSE_OLX_VALIDATION_IGNORE_LIST -) - -################# show account activate cta after register ######################## -SHOW_ACCOUNT_ACTIVATION_CTA = ENV_TOKENS.get('SHOW_ACCOUNT_ACTIVATION_CTA', SHOW_ACCOUNT_ACTIVATION_CTA) - -################# Discussions micro frontend URL ######################## -DISCUSSIONS_MICROFRONTEND_URL = ENV_TOKENS.get('DISCUSSIONS_MICROFRONTEND_URL', DISCUSSIONS_MICROFRONTEND_URL) - -################### Discussions micro frontend Feedback URL################### -DISCUSSIONS_MFE_FEEDBACK_URL = ENV_TOKENS.get('DISCUSSIONS_MFE_FEEDBACK_URL', DISCUSSIONS_MFE_FEEDBACK_URL) - -############################ AI_TRANSLATIONS URL ################################## -AI_TRANSLATIONS_API_URL = ENV_TOKENS.get('AI_TRANSLATIONS_API_URL', AI_TRANSLATIONS_API_URL) - ############## DRF overrides ############## -REST_FRAMEWORK.update(ENV_TOKENS.get('REST_FRAMEWORK', {})) +REST_FRAMEWORK.update(_YAML_TOKENS.get('REST_FRAMEWORK', {})) ############################# CELERY ############################ -CELERY_IMPORTS.extend(ENV_TOKENS.get('CELERY_EXTRA_IMPORTS', [])) +CELERY_IMPORTS.extend(_YAML_TOKENS.get('CELERY_EXTRA_IMPORTS', [])) # keys for big blue button live provider +# TODO: This should not be in the core platform. If it has to stay for now, though, then we should move these +# defaults into common.py COURSE_LIVE_GLOBAL_CREDENTIALS["BIG_BLUE_BUTTON"] = { - "KEY": ENV_TOKENS.get('BIG_BLUE_BUTTON_GLOBAL_KEY', None), - "SECRET": ENV_TOKENS.get('BIG_BLUE_BUTTON_GLOBAL_SECRET', None), - "URL": ENV_TOKENS.get('BIG_BLUE_BUTTON_GLOBAL_URL', None), + "KEY": _YAML_TOKENS.get('BIG_BLUE_BUTTON_GLOBAL_KEY'), + "SECRET": _YAML_TOKENS.get('BIG_BLUE_BUTTON_GLOBAL_SECRET'), + "URL": _YAML_TOKENS.get('BIG_BLUE_BUTTON_GLOBAL_URL'), } -AVAILABLE_DISCUSSION_TOURS = ENV_TOKENS.get('AVAILABLE_DISCUSSION_TOURS', []) - -############## NOTIFICATIONS EXPIRY ############## -NOTIFICATIONS_EXPIRY = ENV_TOKENS.get('NOTIFICATIONS_EXPIRY', NOTIFICATIONS_EXPIRY) - ############## Event bus producer ############## -EVENT_BUS_PRODUCER_CONFIG = merge_producer_configs(EVENT_BUS_PRODUCER_CONFIG, - ENV_TOKENS.get('EVENT_BUS_PRODUCER_CONFIG', {})) -BEAMER_PRODUCT_ID = ENV_TOKENS.get('BEAMER_PRODUCT_ID', BEAMER_PRODUCT_ID) +EVENT_BUS_PRODUCER_CONFIG = merge_producer_configs( + EVENT_BUS_PRODUCER_CONFIG, + _YAML_TOKENS.get('EVENT_BUS_PRODUCER_CONFIG', {}) +) -# .. setting_name: DISABLED_COUNTRIES -# .. setting_default: [] -# .. setting_description: List of country codes that should be disabled -# .. for now it wil impact country listing in auth flow and user profile. -# .. eg ['US', 'CA'] -DISABLED_COUNTRIES = ENV_TOKENS.get('DISABLED_COUNTRIES', []) +####################################################################################################################### +# HEY! Don't add anything to the end of this file. +# Add your defaults to common.py instead! +# If you really need to add post-YAML logic, add it above the "DERIVE ANY DERIVED SETTINGS" section. +####################################################################################################################### diff --git a/lms/envs/static.py b/lms/envs/static.py deleted file mode 100644 index ab0bea3b48..0000000000 --- a/lms/envs/static.py +++ /dev/null @@ -1,76 +0,0 @@ -""" -This config file runs the simplest dev environment using sqlite, and db-based -sessions. Assumes structure: - -/envroot/ - /db # This is where it'll write the database file - /edx-platform # The location of this repo - /log # Where we're going to write log files -""" - -# We intentionally define lots of variables that aren't used, and -# want to import all variables from base settings files -# pylint: disable=wildcard-import, unused-wildcard-import - - -from openedx.core.lib.derived import derive_settings -from openedx.core.lib.logsettings import get_logger_config - -from .common import * - -STATIC_GRAB = True - -LOGGING = get_logger_config(ENV_ROOT / "log", - logging_env="dev") - -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ENV_ROOT / "db" / "edx.db", - 'ATOMIC_REQUESTS': True, - }, - 'student_module_history': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': ENV_ROOT / "db" / "student_module_history.db", - 'ATOMIC_REQUESTS': True, - } -} - -CACHES = { - # This is the cache used for most things. - # In staging/prod envs, the sessions also live here. - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': 'edx_loc_mem_cache', - 'KEY_FUNCTION': 'common.djangoapps.util.memcache.safe_key', - }, - - # The general cache is what you get if you use our util.cache. It's used for - # things like caching the course.xml file for different A/B test groups. - # We set it to be a DummyCache to force reloading of course.xml in dev. - # In staging environments, we would grab VERSION from data uploaded by the - # push process. - 'general': { - 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', - 'KEY_PREFIX': 'general', - 'VERSION': 4, - 'KEY_FUNCTION': 'common.djangoapps.util.memcache.safe_key', - } -} - -# Dummy secret key for dev -SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' - -############################ FILE UPLOADS (for discussion forums) ############################# -DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' -MEDIA_ROOT = ENV_ROOT / "uploads" -MEDIA_URL = "/discussion/upfiles/" -FILE_UPLOAD_TEMP_DIR = ENV_ROOT / "uploads" -FILE_UPLOAD_HANDLERS = [ - 'django.core.files.uploadhandler.MemoryFileUploadHandler', - 'django.core.files.uploadhandler.TemporaryFileUploadHandler', -] - -########################## Derive Any Derived Settings ####################### - -derive_settings(__name__) diff --git a/lms/envs/test.py b/lms/envs/test.py index a9e8aaf9f2..1be492f989 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -18,7 +18,6 @@ from collections import OrderedDict from uuid import uuid4 import openid.oidutil -import django from django.utils.translation import gettext_lazy from edx_django_utils.plugins import add_plugins from path import Path as path @@ -288,6 +287,7 @@ MKTG_URL_LINK_MAP = { SUPPORT_SITE_LINK = 'https://example.support.edx.org' PASSWORD_RESET_SUPPORT_LINK = 'https://support.example.com/password-reset-help.html' ACTIVATION_EMAIL_SUPPORT_LINK = 'https://support.example.com/activation-email-help.html' +SEND_ACTIVATION_EMAIL_URL = 'https://courses.example.edx.org/api/send_account_activation_email' LOGIN_ISSUE_SUPPORT_LINK = 'https://support.example.com/login-issue-help.html' ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS = OrderedDict([ ("utm_campaign", "edX.org Referral"), @@ -330,18 +330,13 @@ YOUTUBE_PORT = 8031 LTI_PORT = 8765 VIDEO_SOURCE_PORT = 8777 -FEATURES['PREVIEW_LMS_BASE'] = "preview.localhost" ############### Module Store Items ########## -PREVIEW_DOMAIN = FEATURES['PREVIEW_LMS_BASE'].split(':')[0] -HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS = { - PREVIEW_DOMAIN: 'draft-preferred' -} +HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS = {} ################### Make tests faster PASSWORD_HASHERS = [ - 'django.contrib.auth.hashers.SHA1PasswordHasher', 'django.contrib.auth.hashers.MD5PasswordHasher', ] @@ -564,7 +559,7 @@ PDF_RECEIPT_BILLING_ADDRESS = 'add your own billing address here with appropriat PDF_RECEIPT_TERMS_AND_CONDITIONS = 'add your own terms and conditions' PDF_RECEIPT_TAX_ID_LABEL = 'Tax ID' -PROFILE_MICROFRONTEND_URL = "http://profile-mfe/abc/" +PROFILE_MICROFRONTEND_URL = "http://profile-mfe" ORDER_HISTORY_MICROFRONTEND_URL = "http://order-history-mfe/" ACCOUNT_MICROFRONTEND_URL = "http://account-mfe" AUTHN_MICROFRONTEND_URL = "http://authn-mfe" @@ -650,10 +645,47 @@ SURVEY_REPORT_CHECK_THRESHOLD = 6 SURVEY_REPORT_ENABLE = True ANONYMOUS_SURVEY_REPORT = False -CSRF_TRUSTED_ORIGINS = ['.example.com'] -CSRF_TRUSTED_ORIGINS_WITH_SCHEME = ['https://*.example.com'] +CSRF_TRUSTED_ORIGINS = ['https://*.example.com'] -# values are already updated above with default CSRF_TRUSTED_ORIGINS values but in -# case of new django version these values will override. -if django.VERSION[0] >= 4: # for greater than django 3.2 use with schemes. - CSRF_TRUSTED_ORIGINS = CSRF_TRUSTED_ORIGINS_WITH_SCHEME +############## Settings for JWT token handling ############## +TOKEN_SIGNING = { + 'JWT_ISSUER': 'token-test-issuer', + 'JWT_SIGNING_ALGORITHM': 'RS512', + 'JWT_SUPPORTED_VERSION': '1.2.0', + 'JWT_PRIVATE_SIGNING_JWK': """ + { + "kid": "token-test-sign", + "kty": "RSA", + "key_ops": [ + "sign" + ], + "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", + "e": "AQAB", + "d": "HIiV7KNjcdhVbpn3KT-I9n3JPf5YbGXsCIedmPqDH1d4QhBofuAqZ9zebQuxkRUpmqtYMv0Zi6ECSUqH387GYQF_XvFUFcjQRPycISd8TH0DAKaDpGr-AYNshnKiEtQpINhcP44I1AYNPCwyoxXA1fGTtmkKChsuWea7o8kytwU5xSejvh5-jiqu2SF4GEl0BEXIAPZsgbzoPIWNxgO4_RzNnWs6nJZeszcaDD0CyezVSuH9QcI6g5QFzAC_YuykSsaaFJhZ05DocBsLczShJ9Omf6PnK9xlm26I84xrEh_7x4fVmNBg3xWTLh8qOnHqGko93A1diLRCrKHOvnpvgQ", + "p": "3T3DEtBUka7hLGdIsDlC96Uadx_q_E4Vb1cxx_4Ss_wGp1Loz3N3ZngGyInsKlmbBgLo1Ykd6T9TRvRNEWEtFSOcm2INIBoVoXk7W5RuPa8Cgq2tjQj9ziGQ08JMejrPlj3Q1wmALJr5VTfvSYBu0WkljhKNCy1KB6fCby0C9WE", + "q": "vUqzWPZnDG4IXyo-k5F0bHV0BNL_pVhQoLW7eyFHnw74IOEfSbdsMspNcPSFIrtgPsn7981qv3lN_staZ6JflKfHayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0", + "dp": "Azh08H8r2_sJuBXAzx_mQ6iZnAZQ619PnJFOXjTqnMgcaK8iSHLL2CgDIUQwteUcBphgP0uBrfWIBs5jmM8rUtVz4CcrPb5jdjhHjuu4NxmnFbPlhNoOp8OBUjPP3S-h-fPoaFjxDrUqz_zCdPVzp4S6UTkf6Hu-SiI9CFVFZ8E", + "dq": "WQ44_KTIbIej9qnYUPMA1DoaAF8ImVDIdiOp9c79dC7FvCpN3w-lnuugrYDM1j9Tk5bRrY7-JuE6OaKQgOtajoS1BIxjYHj5xAVPD15CVevOihqeq5Zx0ZAAYmmCKRrfUe0iLx2QnIcoKH1-Azs23OXeeo6nysznZjvv9NVJv60", + "qi": "KSWGH607H1kNG2okjYdmVdNgLxTUB-Wye9a9FNFE49UmQIOJeZYXtDzcjk8IiK3g-EU3CqBeDKVUgHvHFu4_Wj3IrIhKYizS4BeFmOcPDvylDQCmJcC9tXLQgHkxM_MEJ7iLn9FOLRshh7GPgZphXxMhezM26Cz-8r3_mACHu84" + } + """, # noqa: E501, + + 'JWT_PUBLIC_SIGNING_JWK_SET': """ + { + "keys": [ + { + "kid": "token-test-sign", + "kty": "RSA", + "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", + "e": "AQAB" + }, + { + "kid": "token-test-wrong-key", + "kty": "RSA", + "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", + "e": "AQAB" + } + ] + } + """, # noqa: E501 +} diff --git a/lms/envs/test_static_optimized.py b/lms/envs/test_static_optimized.py deleted file mode 100644 index b57276b040..0000000000 --- a/lms/envs/test_static_optimized.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -Settings used when generating static assets for use in tests. - -Note: it isn't possible to have a single settings file, because Django doesn't -support both generating static assets to a directory and also serving static -from the same directory. -""" - -# Start with the common settings - - -from openedx.core.lib.derived import derive_settings -from openedx.core.lib.django_require.staticstorage import OptimizedCachedRequireJsStorage - -from .common import * # pylint: disable=wildcard-import, unused-wildcard-import - -# Use an in-memory database since this settings file is only used for updating assets -DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'ATOMIC_REQUESTS': True, - }, - 'student_module_history': { - 'ENGINE': 'django.db.backends.sqlite3', - }, -} - -# Provide a dummy XQUEUE_INTERFACE setting as LMS expects it to exist on start up -XQUEUE_INTERFACE = { - "url": "https://sandbox-xqueue.edx.org", - "django_auth": { - "username": "lms", - "password": "***REMOVED***" - }, - "basic_auth": ('anant', 'agarwal'), -} - -PROCTORING_BACKENDS = { - 'DEFAULT': 'mock', - 'mock': {}, - 'mock_proctoring_without_rules': {}, -} - -######################### PIPELINE #################################### - -# Use RequireJS optimized storage -STATICFILES_STORAGE = f"{OptimizedCachedRequireJsStorage.__module__}.{OptimizedCachedRequireJsStorage.__name__}" - -# Revert to the default set of finders as we don't want to dynamically pick up files from the pipeline -STATICFILES_FINDERS = [ - 'django.contrib.staticfiles.finders.FileSystemFinder', - 'django.contrib.staticfiles.finders.AppDirectoriesFinder', - 'openedx.core.lib.xblock_pipeline.finder.XBlockPipelineFinder', -] - -# Redirect to the test_root folder within the repo -TEST_ROOT = REPO_ROOT / "test_root" -LOG_DIR = (TEST_ROOT / "log").abspath() - -# Store the static files under test root so that they don't overwrite existing static assets -STATIC_ROOT = (TEST_ROOT / "staticfiles" / "lms").abspath() -WEBPACK_LOADER['DEFAULT']['STATS_FILE'] = STATIC_ROOT / "webpack-stats.json" - -# Disable uglify when tests are running (used by build.js). -# 1. Uglify is by far the slowest part of the build process -# 2. Having full source code makes debugging tests easier for developers -os.environ['REQUIRE_BUILD_PROFILE_OPTIMIZE'] = 'none' - -########################## Derive Any Derived Settings ####################### - -derive_settings(__name__) diff --git a/lms/lib/courseware_search/lms_filter_generator.py b/lms/lib/courseware_search/lms_filter_generator.py index b0c0564df4..5b2592e4cd 100644 --- a/lms/lib/courseware_search/lms_filter_generator.py +++ b/lms/lib/courseware_search/lms_filter_generator.py @@ -9,6 +9,7 @@ from openedx.core.djangoapps.course_groups.partition_scheme import CohortPartiti from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api.partition_schemes import RandomUserPartitionScheme from common.djangoapps.student.models import CourseEnrollment +from xmodule.course_block import CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE INCLUDE_SCHEMES = [CohortPartitionScheme, RandomUserPartitionScheme, ] SCHEME_SUPPORTS_ASSIGNMENT = [RandomUserPartitionScheme, ] @@ -63,6 +64,6 @@ class LmsSearchFilterGenerator(SearchFilterGenerator): if not getattr(settings, "SEARCH_SKIP_INVITATION_ONLY_FILTERING", True): exclude_dictionary['invitation_only'] = True if not getattr(settings, "SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING", True): - exclude_dictionary['catalog_visibility'] = 'none' + exclude_dictionary['catalog_visibility'] = [CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE] return exclude_dictionary diff --git a/lms/lib/courseware_search/test/test_lms_filter_generator.py b/lms/lib/courseware_search/test/test_lms_filter_generator.py index 492cf64d8c..8afa94f70f 100644 --- a/lms/lib/courseware_search/test/test_lms_filter_generator.py +++ b/lms/lib/courseware_search/test/test_lms_filter_generator.py @@ -6,6 +6,7 @@ from unittest.mock import Mock, patch from lms.lib.courseware_search.lms_filter_generator import LmsSearchFilterGenerator from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory +from xmodule.course_block import CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order @@ -139,3 +140,9 @@ class LmsSearchFilterGeneratorTestCase(ModuleStoreTestCase): assert 'org' not in exclude_dictionary assert 'org' in field_dictionary assert ['TestSite3'] == field_dictionary['org'] + + @patch('django.conf.settings.SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING', False) + def test_excludes_catalog_visibility(self): + _, _, exclude_dictionary = LmsSearchFilterGenerator.generate_field_filters(user=self.user) + assert 'catalog_visibility' in exclude_dictionary + assert exclude_dictionary['catalog_visibility'] == [CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE] diff --git a/lms/startup.py b/lms/startup.py deleted file mode 100644 index ed1ad1caba..0000000000 --- a/lms/startup.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -Module for code that should run during LMS startup (deprecated) -""" - - -import django -from django.conf import settings - -# Force settings to run so that the python path is modified -settings.INSTALLED_APPS # pylint: disable=pointless-statement - - -def run(): - """ - Executed during django startup - - NOTE: DO **NOT** add additional code to this method or this file! The Platform Team - is moving all startup code to more standard locations using Django best practices. - """ - django.setup() diff --git a/lms/static/completion/js/.eslintrc.js b/lms/static/completion/js/.eslintrc.js deleted file mode 100644 index b3039be23a..0000000000 --- a/lms/static/completion/js/.eslintrc.js +++ /dev/null @@ -1,14 +0,0 @@ -module.exports = { - extends: '@edx/eslint-config', - root: true, - settings: { - 'import/resolver': 'webpack', - }, - rules: { - indent: ['error', 4], - 'react/jsx-indent': ['error', 4], - 'react/jsx-indent-props': ['error', 4], - 'import/extensions': 'off', - 'import/no-unresolved': 'off', - }, -}; diff --git a/lms/static/images/programs/sample-cert.png b/lms/static/images/programs/sample-cert.png index 09de8897d3..4639a97f8c 100644 Binary files a/lms/static/images/programs/sample-cert.png and b/lms/static/images/programs/sample-cert.png differ diff --git a/lms/static/js/fixtures/calculator.html b/lms/static/js/fixtures/calculator.html index 2a6f189590..2bbff75422 100644 --- a/lms/static/js/fixtures/calculator.html +++ b/lms/static/js/fixtures/calculator.html @@ -13,8 +13,8 @@
  • For detailed information, see - Entering Mathematical and Scientific Expressions in the - EdX Learner's Guide. + Entering Mathematical and Scientific Expressions in the + Open edX Learner's Guide.

  • diff --git a/lms/static/js/instructor_dashboard/data_download.js b/lms/static/js/instructor_dashboard/data_download.js index 54492a06d5..94f406b4f3 100644 --- a/lms/static/js/instructor_dashboard/data_download.js +++ b/lms/static/js/instructor_dashboard/data_download.js @@ -101,6 +101,7 @@ this.$proctored_exam_csv_btn = this.$section.find("input[name='proctored-exam-results-report']"); this.$survey_results_csv_btn = this.$section.find("input[name='survey-results-report']"); this.$list_may_enroll_csv_btn = this.$section.find("input[name='list-may-enroll-csv']"); + this.$list_learners_not_activated_csv_btn = this.$section.find("input[name='list-learners-not-activated-csv']"); this.$list_problem_responses_csv_input = this.$section.find("input[name='problem-location']"); this.$list_problem_responses_csv_btn = this.$section.find("input[name='list-problem-responses-csv']"); this.$list_anon_btn = this.$section.find("input[name='list-anon-ids']"); @@ -321,6 +322,31 @@ } }); }); + this.$list_learners_not_activated_csv_btn.click(function() { + var url = dataDownloadObj.$list_learners_not_activated_csv_btn.data('endpoint'); + var errorMessage = gettext('Error generating list of inactive students who are enrolled in a course. Please try again.'); + dataDownloadObj.clear_display(); + return $.ajax({ + type: 'POST', + dataType: 'json', + url: url, + error: function(error) { + if (error.responseText) { + errorMessage = JSON.parse(error.responseText); + } + dataDownloadObj.$reports_request_response_error.text(errorMessage); + return dataDownloadObj.$reports_request_response_error.css({ + display: 'block' + }); + }, + success: function(data) { + dataDownloadObj.$reports_request_response.text(data.status); + return $('.msg-confirm').css({ + display: 'block' + }); + } + }); + }); this.$grade_config_btn.click(function() { var url = dataDownloadObj.$grade_config_btn.data('endpoint'); return $.ajax({ diff --git a/lms/static/js/instructor_dashboard/membership.js b/lms/static/js/instructor_dashboard/membership.js index f9e157607b..ac5d63fbdc 100644 --- a/lms/static/js/instructor_dashboard/membership.js +++ b/lms/static/js/instructor_dashboard/membership.js @@ -789,7 +789,7 @@ such that the value can be defined later than this assignment (file load order). } if (allowed.length && emailStudents) { // Translators: A list of users appears after this sentence; - renderList(gettext('Email cannot be sent to the following users via batch enrollment. They will be allowed to enroll once they register:'), (function() { // eslint-disable-line max-len + renderList(gettext('Email was sent to the following users. They will be allowed to enroll once they register:'), (function() { // eslint-disable-line max-len var k, len2, results; results = []; for (k = 0, len2 = allowed.length; k < len2; k++) { @@ -813,7 +813,7 @@ such that the value can be defined later than this assignment (file load order). } if (autoenrolled.length && emailStudents) { // Translators: A list of users appears after this sentence; - renderList(gettext('Email cannot be sent to the following users via batch enrollment. They will be enrolled once they register:'), (function() { // eslint-disable-line max-len + renderList(gettext('Email was sent to the following users. They will be enrolled once they register:'), (function() { // eslint-disable-line max-len var k, len2, results; results = []; for (k = 0, len2 = autoenrolled.length; k < len2; k++) { diff --git a/lms/static/js/instructor_dashboard/util.js b/lms/static/js/instructor_dashboard/util.js index 8e39f4d03e..4e24c44c77 100644 --- a/lms/static/js/instructor_dashboard/util.js +++ b/lms/static/js/instructor_dashboard/util.js @@ -507,7 +507,7 @@ return edx.HtmlUtils.joinHtml(edx.HtmlUtils.HTML( ''), dataContext.name, - edx.HtmlUtils.HTML('')); + edx.HtmlUtils.HTML('Opens in a new tab')); } } ]; diff --git a/lms/static/js/learner_dashboard/.eslintrc.js b/lms/static/js/learner_dashboard/.eslintrc.js deleted file mode 100644 index 752a664a53..0000000000 --- a/lms/static/js/learner_dashboard/.eslintrc.js +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = { - extends: '@edx/eslint-config', - root: true, - settings: { - 'import/resolver': { - webpack: { - config: 'webpack.dev.config.js', - }, - }, - }, - rules: { - indent: ['error', 4], - 'react/jsx-indent': ['error', 4], - 'react/jsx-indent-props': ['error', 4], - 'import/extensions': 'off', - 'import/no-unresolved': 'off', - }, -}; diff --git a/lms/static/js/learner_dashboard/views/program_card_view.js b/lms/static/js/learner_dashboard/views/program_card_view.js index f4715e2538..c37ad70468 100644 --- a/lms/static/js/learner_dashboard/views/program_card_view.js +++ b/lms/static/js/learner_dashboard/views/program_card_view.js @@ -1,7 +1,6 @@ /* globals gettext */ import Backbone from 'backbone'; -import picturefill from 'picturefill'; import HtmlUtils from 'edx-ui-toolkit/js/utils/html-utils'; @@ -45,17 +44,6 @@ class ProgramCardView extends Backbone.View { ); HtmlUtils.setHtml(this.$el, this.tpl(data)); - this.postRender(); - } - - postRender() { - if (navigator.userAgent.indexOf('MSIE') !== -1 - || navigator.appVersion.indexOf('Trident/') > 0) { - /* Microsoft Internet Explorer detected in. */ - window.setTimeout(() => { - this.reLoadBannerImage(); - }, 100); - } } // Calculate counts for progress and percentages for styling @@ -82,26 +70,6 @@ class ProgramCardView extends Backbone.View { const int = (val / total) * 100; return `${int}%`; } - - // Defer loading the rest of the page to limit FOUC - reLoadBannerImage() { - const $img = this.$('.program_card .banner-image'); - const imgSrcAttr = $img ? $img.attr('src') : {}; - - if (!imgSrcAttr || imgSrcAttr.length < 0) { - try { - ProgramCardView.reEvaluatePicture(); - } catch (err) { - // Swallow the error here - } - } - } - - static reEvaluatePicture() { - picturefill({ - reevaluate: true, - }); - } } export default ProgramCardView; diff --git a/lms/static/js/spec/student_account/account_settings_factory_spec.js b/lms/static/js/spec/student_account/account_settings_factory_spec.js deleted file mode 100644 index 075142c84a..0000000000 --- a/lms/static/js/spec/student_account/account_settings_factory_spec.js +++ /dev/null @@ -1,334 +0,0 @@ -define(['backbone', - 'jquery', - 'underscore', - 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', - 'js/spec/views/fields_helpers', - 'js/spec/student_account/helpers', - 'js/spec/student_account/account_settings_fields_helpers', - 'js/student_account/views/account_settings_factory', - 'js/student_account/views/account_settings_view' -], -function(Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViewsSpecHelpers, Helpers, - AccountSettingsFieldViewSpecHelpers, AccountSettingsPage) { - 'use strict'; - - describe('edx.user.AccountSettingsFactory', function() { - var createAccountSettingsPage = function() { - var context = AccountSettingsPage( - Helpers.FIELDS_DATA, - false, - [], - Helpers.AUTH_DATA, - Helpers.PASSWORD_RESET_SUPPORT_LINK, - Helpers.USER_ACCOUNTS_API_URL, - Helpers.USER_PREFERENCES_API_URL, - 1, - Helpers.PLATFORM_NAME, - Helpers.CONTACT_EMAIL, - true, - Helpers.ENABLE_COPPA_COMPLIANCE - ); - return context.accountSettingsView; - }; - - var requests; - - beforeEach(function() { - setFixtures(''); - }); - - it('shows loading error when UserAccountModel fails to load', function() { - requests = AjaxHelpers.requests(this); - - var accountSettingsView = createAccountSettingsPage(); - - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - var request = requests[0]; - expect(request.method).toBe('GET'); - expect(request.url).toBe(Helpers.USER_ACCOUNTS_API_URL); - - AjaxHelpers.respondWithError(requests, 500); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); - }); - - it('shows loading error when UserPreferencesModel fails to load', function() { - requests = AjaxHelpers.requests(this); - - var accountSettingsView = createAccountSettingsPage(); - - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - var request = requests[0]; - expect(request.method).toBe('GET'); - expect(request.url).toBe(Helpers.USER_ACCOUNTS_API_URL); - - AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - request = requests[1]; - expect(request.method).toBe('GET'); - expect(request.url).toBe('/api/user/v1/preferences/time_zones/?country_code=1'); - AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); - - request = requests[2]; - expect(request.method).toBe('GET'); - expect(request.url).toBe(Helpers.USER_PREFERENCES_API_URL); - - AjaxHelpers.respondWithError(requests, 500); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); - }); - - it('renders fields after the models are successfully fetched', function() { - requests = AjaxHelpers.requests(this); - - var accountSettingsView = createAccountSettingsPage(); - - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); - AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); - AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData()); - - accountSettingsView.render(); - - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - Helpers.expectSettingsSectionsAndFieldsToBeRendered(accountSettingsView); - }); - - it('expects all fields to behave correctly', function() { - var i, view; - - requests = AjaxHelpers.requests(this); - - var accountSettingsView = createAccountSettingsPage(); - - AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); - AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); - AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData()); - AjaxHelpers.respondWithJson(requests, {}); // Page viewed analytics event - - var sectionsData = accountSettingsView.options.tabSections.aboutTabSections; - - expect(sectionsData[0].fields.length).toBe(7); - - var textFields = [sectionsData[0].fields[1], sectionsData[0].fields[2]]; - for (i = 0; i < textFields.length; i++) { - view = textFields[i].view; - FieldViewsSpecHelpers.verifyTextField(view, { - title: view.options.title, - valueAttribute: view.options.valueAttribute, - helpMessage: view.options.helpMessage, - validValue: 'My Name', - invalidValue1: '', - invalidValue2: '@', - validationError: 'Think again!', - defaultValue: '' - }, requests); - } - - expect(sectionsData[1].fields.length).toBe(4); - var dropdownFields = [ - sectionsData[1].fields[0], - sectionsData[1].fields[1], - sectionsData[1].fields[2] - ]; - _.each(dropdownFields, function(field) { - // eslint-disable-next-line no-shadow - var view = field.view; - FieldViewsSpecHelpers.verifyDropDownField(view, { - title: view.options.title, - valueAttribute: view.options.valueAttribute, - helpMessage: '', - validValue: Helpers.FIELD_OPTIONS[1][0], - invalidValue1: Helpers.FIELD_OPTIONS[2][0], - invalidValue2: Helpers.FIELD_OPTIONS[3][0], - validationError: 'Nope, this will not do!', - defaultValue: null - }, requests); - }); - }); - }); - - describe('edx.user.AccountSettingsFactory', function() { - var createEnterpriseLearnerAccountSettingsPage = function() { - var context = AccountSettingsPage( - Helpers.FIELDS_DATA, - false, - [], - Helpers.AUTH_DATA, - Helpers.PASSWORD_RESET_SUPPORT_LINK, - Helpers.USER_ACCOUNTS_API_URL, - Helpers.USER_PREFERENCES_API_URL, - 1, - Helpers.PLATFORM_NAME, - Helpers.CONTACT_EMAIL, - true, - Helpers.ENABLE_COPPA_COMPLIANCE, - '', - - Helpers.SYNC_LEARNER_PROFILE_DATA, - Helpers.ENTERPRISE_NAME, - Helpers.ENTERPRISE_READ_ONLY_ACCOUNT_FIELDS, - Helpers.EDX_SUPPORT_URL - ); - return context.accountSettingsView; - }; - - var requests; - var accountInfoTab = { - BASIC_ACCOUNT_INFORMATION: 0, - ADDITIONAL_INFORMATION: 1 - }; - var basicAccountInfoFields = { - USERNAME: 0, - FULL_NAME: 1, - EMAIL_ADDRESS: 2, - PASSWORD: 3, - LANGUAGE: 4, - COUNTRY: 5, - TIMEZONE: 6 - }; - var additionalInfoFields = { - EDUCATION: 0, - GENDER: 1, - YEAR_OF_BIRTH: 2, - PREFERRED_LANGUAGE: 3 - }; - - beforeEach(function() { - setFixtures(''); - }); - - it('shows loading error when UserAccountModel fails to load for enterprise learners', function() { - var accountSettingsView, request; - requests = AjaxHelpers.requests(this); - - accountSettingsView = createEnterpriseLearnerAccountSettingsPage(); - - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - request = requests[0]; - expect(request.method).toBe('GET'); - expect(request.url).toBe(Helpers.USER_ACCOUNTS_API_URL); - - AjaxHelpers.respondWithError(requests, 500); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); - }); - - it('shows loading error when UserPreferencesModel fails to load for enterprise learners', function() { - var accountSettingsView, request; - requests = AjaxHelpers.requests(this); - - accountSettingsView = createEnterpriseLearnerAccountSettingsPage(); - - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - request = requests[0]; - expect(request.method).toBe('GET'); - expect(request.url).toBe(Helpers.USER_ACCOUNTS_API_URL); - - AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - request = requests[1]; - expect(request.method).toBe('GET'); - expect(request.url).toBe('/api/user/v1/preferences/time_zones/?country_code=1'); - AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); - - request = requests[2]; - expect(request.method).toBe('GET'); - expect(request.url).toBe(Helpers.USER_PREFERENCES_API_URL); - - AjaxHelpers.respondWithError(requests, 500); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); - }); - - it('renders fields after the models are successfully fetched for enterprise learners', function() { - var accountSettingsView; - requests = AjaxHelpers.requests(this); - - accountSettingsView = createEnterpriseLearnerAccountSettingsPage(); - - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); - AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); - AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData()); - - accountSettingsView.render(); - - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - Helpers.expectSettingsSectionsAndFieldsToBeRenderedWithMessage(accountSettingsView); - }); - - it('expects all fields to behave correctly for enterprise learners', function() { - var accountSettingsView, i, view, sectionsData, textFields, dropdownFields; - requests = AjaxHelpers.requests(this); - - accountSettingsView = createEnterpriseLearnerAccountSettingsPage(); - - AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); - AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); - AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData()); - AjaxHelpers.respondWithJson(requests, {}); // Page viewed analytics event - - sectionsData = accountSettingsView.options.tabSections.aboutTabSections; - - expect(sectionsData[accountInfoTab.BASIC_ACCOUNT_INFORMATION].fields.length).toBe(7); - - // Verify that username, name and email fields are readonly - textFields = [ - sectionsData[accountInfoTab.BASIC_ACCOUNT_INFORMATION].fields[basicAccountInfoFields.USERNAME], - sectionsData[accountInfoTab.BASIC_ACCOUNT_INFORMATION].fields[basicAccountInfoFields.FULL_NAME], - sectionsData[accountInfoTab.BASIC_ACCOUNT_INFORMATION].fields[basicAccountInfoFields.EMAIL_ADDRESS] - ]; - for (i = 0; i < textFields.length; i++) { - view = textFields[i].view; - - FieldViewsSpecHelpers.verifyReadonlyTextField(view, { - title: view.options.title, - valueAttribute: view.options.valueAttribute, - helpMessage: view.options.helpMessage, - validValue: 'My Name', - defaultValue: '' - }, requests); - } - - // Verify un-editable country dropdown field - view = sectionsData[ - accountInfoTab.BASIC_ACCOUNT_INFORMATION - ].fields[basicAccountInfoFields.COUNTRY].view; - - FieldViewsSpecHelpers.verifyReadonlyDropDownField(view, { - title: view.options.title, - valueAttribute: view.options.valueAttribute, - helpMessage: '', - validValue: Helpers.FIELD_OPTIONS[1][0], - editable: 'never', - defaultValue: null - }); - - expect(sectionsData[accountInfoTab.ADDITIONAL_INFORMATION].fields.length).toBe(4); - dropdownFields = [ - sectionsData[accountInfoTab.ADDITIONAL_INFORMATION].fields[additionalInfoFields.EDUCATION], - sectionsData[accountInfoTab.ADDITIONAL_INFORMATION].fields[additionalInfoFields.GENDER], - sectionsData[accountInfoTab.ADDITIONAL_INFORMATION].fields[additionalInfoFields.YEAR_OF_BIRTH] - ]; - _.each(dropdownFields, function(field) { - view = field.view; - FieldViewsSpecHelpers.verifyDropDownField(view, { - title: view.options.title, - valueAttribute: view.options.valueAttribute, - helpMessage: '', - validValue: Helpers.FIELD_OPTIONS[1][0], // dummy option for dropdown field - invalidValue1: Helpers.FIELD_OPTIONS[2][0], // dummy option for dropdown field - invalidValue2: Helpers.FIELD_OPTIONS[3][0], // dummy option for dropdown field - validationError: 'Nope, this will not do!', - defaultValue: null - }, requests); - }); - }); - }); -}); diff --git a/lms/static/js/spec/student_account/account_settings_fields_helpers.js b/lms/static/js/spec/student_account/account_settings_fields_helpers.js deleted file mode 100644 index 4aea86b235..0000000000 --- a/lms/static/js/spec/student_account/account_settings_fields_helpers.js +++ /dev/null @@ -1,34 +0,0 @@ -define(['backbone', - 'jquery', - 'underscore', - 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', - 'js/spec/views/fields_helpers', - 'string_utils'], -function(Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViewsSpecHelpers) { - 'use strict'; - - var verifyAuthField = function(view, data, requests) { - var selector = '.u-field-value .u-field-link-title-' + view.options.valueAttribute; - - spyOn(view, 'redirect_to'); - - FieldViewsSpecHelpers.expectTitleAndMessageToContain(view, data.title, data.helpMessage); - expect(view.$(selector).text().trim()).toBe('Unlink This Account'); - view.$(selector).click(); - FieldViewsSpecHelpers.expectMessageContains(view, 'Unlinking'); - AjaxHelpers.expectRequest(requests, 'POST', data.disconnectUrl); - AjaxHelpers.respondWithNoContent(requests); - - expect(view.$(selector).text().trim()).toBe('Link Your Account'); - FieldViewsSpecHelpers.expectMessageContains(view, 'Successfully unlinked.'); - - view.$(selector).click(); - FieldViewsSpecHelpers.expectMessageContains(view, 'Linking'); - expect(view.redirect_to).toHaveBeenCalledWith(data.connectUrl); - }; - - return { - verifyAuthField: verifyAuthField - }; -}); diff --git a/lms/static/js/spec/student_account/account_settings_fields_spec.js b/lms/static/js/spec/student_account/account_settings_fields_spec.js deleted file mode 100644 index 76ea7c512b..0000000000 --- a/lms/static/js/spec/student_account/account_settings_fields_spec.js +++ /dev/null @@ -1,216 +0,0 @@ -define(['backbone', - 'jquery', - 'underscore', - 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', - 'js/student_account/models/user_account_model', - 'js/views/fields', - 'js/spec/views/fields_helpers', - 'js/spec/student_account/account_settings_fields_helpers', - 'js/student_account/views/account_settings_fields', - 'js/student_account/models/user_account_model', - 'string_utils'], -function(Backbone, $, _, AjaxHelpers, TemplateHelpers, UserAccountModel, FieldViews, FieldViewsSpecHelpers, - AccountSettingsFieldViewSpecHelpers, AccountSettingsFieldViews) { - 'use strict'; - - describe('edx.AccountSettingsFieldViews', function() { - var requests, - timerCallback, // eslint-disable-line no-unused-vars - data; - - beforeEach(function() { - timerCallback = jasmine.createSpy('timerCallback'); - jasmine.clock().install(); - }); - - afterEach(function() { - jasmine.clock().uninstall(); - }); - - it('sends request to reset password on clicking link in PasswordFieldView', function() { - requests = AjaxHelpers.requests(this); - - var fieldData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.PasswordFieldView, { - linkHref: '/password_reset', - emailAttribute: 'email', - valueAttribute: 'password' - }); - - var view = new AccountSettingsFieldViews.PasswordFieldView(fieldData).render(); - expect(view.$('.u-field-value > button').is(':disabled')).toBe(false); - view.$('.u-field-value > button').click(); - expect(view.$('.u-field-value > button').is(':disabled')).toBe(true); - AjaxHelpers.expectRequest(requests, 'POST', '/password_reset', 'email=legolas%40woodland.middlearth'); - AjaxHelpers.respondWithJson(requests, {success: 'true'}); - FieldViewsSpecHelpers.expectMessageContains( - view, - "We've sent a message to legolas@woodland.middlearth. " - + 'Click the link in the message to reset your password.' - ); - }); - - it('update time zone dropdown after country dropdown changes', function() { - var baseSelector = '.u-field-value > select'; - var groupsSelector = baseSelector + '> optgroup'; - var groupOptionsSelector = groupsSelector + '> option'; - - var timeZoneData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.TimeZoneFieldView, { - valueAttribute: 'time_zone', - groupOptions: [{ - groupTitle: gettext('All Time Zones'), - selectOptions: FieldViewsSpecHelpers.SELECT_OPTIONS, - nullValueOptionLabel: 'Default (Local Time Zone)' - }], - persistChanges: true, - required: true - }); - var countryData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.DropdownFieldView, { - valueAttribute: 'country', - options: [['KY', 'Cayman Islands'], ['CA', 'Canada'], ['GY', 'Guyana']], - persistChanges: true - }); - - var countryChange = {country: 'GY'}; - var timeZoneChange = {time_zone: 'Pacific/Kosrae'}; - - var timeZoneView = new AccountSettingsFieldViews.TimeZoneFieldView(timeZoneData).render(); - var countryView = new AccountSettingsFieldViews.DropdownFieldView(countryData).render(); - - requests = AjaxHelpers.requests(this); - - timeZoneView.listenToCountryView(countryView); - - // expect time zone dropdown to have single subheader ('All Time Zones') - expect(timeZoneView.$(groupsSelector).length).toBe(1); - expect(timeZoneView.$(groupOptionsSelector).length).toBe(3); - expect(timeZoneView.$(groupOptionsSelector)[0].value).toBe(FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]); - - // change country - countryView.$(baseSelector).val(countryChange[countryData.valueAttribute]).change(); - countryView.$(baseSelector).focusout(); - FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, countryChange); - AjaxHelpers.respondWithJson(requests, {success: 'true'}); - - AjaxHelpers.expectRequest( - requests, - 'GET', - '/api/user/v1/preferences/time_zones/?country_code=GY' - ); - AjaxHelpers.respondWithJson(requests, [ - {time_zone: 'America/Guyana', description: 'America/Guyana (ECT, UTC-0500)'}, - {time_zone: 'Pacific/Kosrae', description: 'Pacific/Kosrae (KOST, UTC+1100)'} - ]); - - // expect time zone dropdown to have two subheaders (country/all time zone sub-headers) with new values - expect(timeZoneView.$(groupsSelector).length).toBe(2); - expect(timeZoneView.$(groupOptionsSelector).length).toBe(6); - expect(timeZoneView.$(groupOptionsSelector)[0].value).toBe('America/Guyana'); - - // select time zone option from option - timeZoneView.$(baseSelector).val(timeZoneChange[timeZoneData.valueAttribute]).change(); - timeZoneView.$(baseSelector).focusout(); - FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, timeZoneChange); - AjaxHelpers.respondWithJson(requests, {success: 'true'}); - timeZoneView.render(); - - // expect time zone dropdown to have three subheaders (currently selected/country/all time zones) - expect(timeZoneView.$(groupsSelector).length).toBe(3); - expect(timeZoneView.$(groupOptionsSelector).length).toBe(6); - expect(timeZoneView.$(groupOptionsSelector)[0].value).toBe('Pacific/Kosrae'); - }); - - it('sends request to /i18n/setlang/ after changing language in LanguagePreferenceFieldView', function() { - requests = AjaxHelpers.requests(this); - - var selector = '.u-field-value > select'; - var fieldData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.DropdownFieldView, { - valueAttribute: 'language', - options: FieldViewsSpecHelpers.SELECT_OPTIONS, - persistChanges: true - }); - - var view = new AccountSettingsFieldViews.LanguagePreferenceFieldView(fieldData).render(); - - data = {language: FieldViewsSpecHelpers.SELECT_OPTIONS[2][0]}; - view.$(selector).val(data[fieldData.valueAttribute]).change(); - view.$(selector).focusout(); - FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data); - AjaxHelpers.respondWithNoContent(requests); - - AjaxHelpers.expectRequest( - requests, - 'POST', - '/i18n/setlang/', - $.param({ - language: data[fieldData.valueAttribute], - next: window.location.href - }) - ); - // Django will actually respond with a 302 redirect, but that would cause a page load during these - // unittests. 204 should work fine for testing. - AjaxHelpers.respondWithNoContent(requests); - FieldViewsSpecHelpers.expectMessageContains(view, 'Your changes have been saved.'); - - data = {language: FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]}; - view.$(selector).val(data[fieldData.valueAttribute]).change(); - view.$(selector).focusout(); - FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data); - AjaxHelpers.respondWithNoContent(requests); - - AjaxHelpers.expectRequest( - requests, - 'POST', - '/i18n/setlang/', - $.param({ - language: data[fieldData.valueAttribute], - next: window.location.href - }) - ); - AjaxHelpers.respondWithError(requests, 500); - FieldViewsSpecHelpers.expectMessageContains( - view, - 'You must sign out and sign back in before your language changes take effect.' - ); - }); - - it('reads and saves the value correctly for LanguageProficienciesFieldView', function() { - requests = AjaxHelpers.requests(this); - - var selector = '.u-field-value > select'; - var fieldData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.DropdownFieldView, { - valueAttribute: 'language_proficiencies', - options: FieldViewsSpecHelpers.SELECT_OPTIONS, - persistChanges: true - }); - fieldData.model.set({language_proficiencies: [{code: FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]}]}); - - var view = new AccountSettingsFieldViews.LanguageProficienciesFieldView(fieldData).render(); - - expect(view.modelValue()).toBe(FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]); - - data = {language_proficiencies: [{code: FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]}]}; - view.$(selector).val(FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]).change(); - view.$(selector).focusout(); - FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data); - AjaxHelpers.respondWithNoContent(requests); - }); - - it('correctly links and unlinks from AuthFieldView', function() { - requests = AjaxHelpers.requests(this); - - var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.LinkFieldView, { - title: 'Yet another social network', - helpMessage: '', - valueAttribute: 'auth-yet-another', - connected: true, - acceptsLogins: 'true', - connectUrl: 'yetanother.com/auth/connect', - disconnectUrl: 'yetanother.com/auth/disconnect' - }); - var view = new AccountSettingsFieldViews.AuthFieldView(fieldData).render(); - - AccountSettingsFieldViewSpecHelpers.verifyAuthField(view, fieldData, requests); - }); - }); -}); diff --git a/lms/static/js/spec/student_account/account_settings_view_spec.js b/lms/static/js/spec/student_account/account_settings_view_spec.js deleted file mode 100644 index c0c213cf3c..0000000000 --- a/lms/static/js/spec/student_account/account_settings_view_spec.js +++ /dev/null @@ -1,91 +0,0 @@ -define(['backbone', - 'jquery', - 'underscore', - 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', - 'js/spec/student_account/helpers', - 'js/views/fields', - 'js/student_account/models/user_account_model', - 'js/student_account/views/account_settings_view' -], -function(Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, FieldViews, UserAccountModel, - AccountSettingsView) { - 'use strict'; - - describe('edx.user.AccountSettingsView', function() { - var createAccountSettingsView = function() { - var model = new UserAccountModel(); - model.set(Helpers.createAccountSettingsData()); - - var aboutSectionsData = [ - { - title: 'Basic Account Information', - messageType: 'info', - message: 'Your profile settings are managed by Test Enterprise. ' - + 'Contact your administrator or edX Support for help.', - fields: [ - { - view: new FieldViews.ReadonlyFieldView({ - model: model, - title: 'Username', - valueAttribute: 'username' - }) - }, - { - view: new FieldViews.TextFieldView({ - model: model, - title: 'Full Name', - valueAttribute: 'name' - }) - } - ] - }, - { - title: 'Additional Information', - fields: [ - { - view: new FieldViews.DropdownFieldView({ - model: model, - title: 'Education Completed', - valueAttribute: 'level_of_education', - options: Helpers.FIELD_OPTIONS - }) - } - ] - } - ]; - - var accountSettingsView = new AccountSettingsView({ - el: $('.wrapper-account-settings'), - model: model, - tabSections: { - aboutTabSections: aboutSectionsData - } - }); - - return accountSettingsView; - }; - - beforeEach(function() { - setFixtures(''); - }); - - it('shows loading error correctly', function() { - var accountSettingsView = createAccountSettingsView(); - - accountSettingsView.render(); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - accountSettingsView.showLoadingError(); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); - }); - - it('renders all fields as expected', function() { - var accountSettingsView = createAccountSettingsView(); - - accountSettingsView.render(); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - Helpers.expectSettingsSectionsAndFieldsToBeRendered(accountSettingsView); - }); - }); -}); diff --git a/lms/static/js/student_account/AccountsClient.js b/lms/static/js/student_account/AccountsClient.js index 6a8f8950fb..7c691bf88e 100644 --- a/lms/static/js/student_account/AccountsClient.js +++ b/lms/static/js/student_account/AccountsClient.js @@ -1,4 +1,3 @@ -import 'whatwg-fetch'; import Cookies from 'js-cookie'; const deactivate = (password) => fetch('/api/user/v1/accounts/deactivate_logout/', { diff --git a/lms/static/js/student_account/components/.eslintrc.js b/lms/static/js/student_account/components/.eslintrc.js deleted file mode 100644 index 752a664a53..0000000000 --- a/lms/static/js/student_account/components/.eslintrc.js +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = { - extends: '@edx/eslint-config', - root: true, - settings: { - 'import/resolver': { - webpack: { - config: 'webpack.dev.config.js', - }, - }, - }, - rules: { - indent: ['error', 4], - 'react/jsx-indent': ['error', 4], - 'react/jsx-indent-props': ['error', 4], - 'import/extensions': 'off', - 'import/no-unresolved': 'off', - }, -}; diff --git a/lms/static/js/student_account/components/PasswordResetConfirmation.jsx b/lms/static/js/student_account/components/PasswordResetConfirmation.jsx index 4244d45b61..eca90ee84f 100644 --- a/lms/static/js/student_account/components/PasswordResetConfirmation.jsx +++ b/lms/static/js/student_account/components/PasswordResetConfirmation.jsx @@ -1,6 +1,5 @@ /* globals gettext */ -import 'whatwg-fetch'; import PropTypes from 'prop-types'; import React from 'react'; diff --git a/lms/static/js/student_account/enrollment.js b/lms/static/js/student_account/enrollment.js index 1ce8fb0f20..68b4319027 100644 --- a/lms/static/js/student_account/enrollment.js +++ b/lms/static/js/student_account/enrollment.js @@ -2,6 +2,11 @@ 'use strict'; define(['jquery', 'jquery.cookie'], function($) { + const ErrorStatuses = { + forbidden: 403, + badRequest: 400 + }; + var EnrollmentInterface = { urls: { @@ -30,12 +35,14 @@ context: this }).fail(function(jqXHR) { var responseData = JSON.parse(jqXHR.responseText); - if (jqXHR.status === 403 && responseData.user_message_url) { - // Check if we've been blocked from the course - // because of country access rules. - // If so, redirect to a page explaining to the user - // why they were blocked. - this.redirect(responseData.user_message_url); + if (jqXHR.status === ErrorStatuses.forbidden) { + if (responseData.user_message_url) { + this.redirect(responseData.user_message_url); + } else { + this.showMessage(responseData); + } + } else if (jqXHR.status === ErrorStatuses.badRequest) { + this.showMessage(responseData); } else { // Otherwise, redirect the user to the next page. if (redirectUrl) { @@ -52,7 +59,54 @@ } }); }, + /** + * Show a message in the frontend. + * @param {Object} message The message to display. + */ + showMessage: function(message) { + const componentId = 'student-enrollment-feedback-error'; + const existing = document.getElementById(componentId); + if (existing) { + existing.remove(); + } + // Using a fixed dashboard URL as the redirect destination since this is the most logical + // place for users to go after encountering an enrollment error. The URL is hardcoded + // because environment variables are not injected into the HTML/JavaScript context. + const DASHBOARD_URL = '/dashboard'; + const textContent = (message && message.detail) ? message.detail : String(message); + const messageDiv = document.createElement('div'); + messageDiv.setAttribute('id', componentId); + messageDiv.setAttribute('class', 'fixed-top d-flex justify-content-center align-items-center'); + messageDiv.style.cssText = [ + 'width:100vw', + 'height:100vh', + 'background:rgba(0,0,0,0.5)', + 'z-index:9999' + ].join(';'); + const buttonText = typeof gettext === 'function' ? gettext('Close') : 'Close'; + + messageDiv.innerHTML = ` +
    + +
    + `; + const actionContainer = messageDiv.querySelector('.nav-actions'); + actionContainer.classList.replace('d-none', 'd-flex'); + actionContainer.querySelector('button').addEventListener('click', () => this.redirect(DASHBOARD_URL) ) + document.body.appendChild(messageDiv); + + }, /** * Redirect to a URL. Mainly useful for mocking out in tests. * @param {string} url The URL to redirect to. @@ -65,3 +119,4 @@ return EnrollmentInterface; }); }).call(this, define || RequireJS.define); + diff --git a/lms/static/js/student_account/views/FinishAuthView.js b/lms/static/js/student_account/views/FinishAuthView.js index f9ae7157ed..02f94c89a7 100644 --- a/lms/static/js/student_account/views/FinishAuthView.js +++ b/lms/static/js/student_account/views/FinishAuthView.js @@ -68,7 +68,9 @@ this.purchaseWorkflow = queryParams.purchaseWorkflow; if (queryParams.next) { // Ensure that the next URL is internal for security reasons + this.updateTaskDescription(gettext("query param next is provide")); if (!window.isExternal(queryParams.next)) { + this.updateTaskDescription(gettext("query param next is internal")); this.nextUrl = queryParams.next; } } @@ -135,6 +137,7 @@ The track selection page would allow the user to select the course mode ("verified", "honor", etc.) -- or, if the only course mode was "honor", it would redirect the user to the dashboard. */ + this.updateTaskDescription(gettext("course mode param is not provided")); redirectUrl = this.appendPurchaseWorkflow(this.urls.trackSelection + courseId + '/'); } else if (this.courseMode === 'honor' || this.courseMode === 'audit') { /* The newer version of the course details page allows the user @@ -142,6 +145,7 @@ chosen "honor", we send them immediately to the next URL rather than the payment flow. The user may decide to upgrade from the dashboard later. */ + this.updateTaskDescription(gettext("course mode param is provided")); } else { /* If the user selected any other kind of course mode, send them to the payment/verification flow. */ @@ -160,6 +164,7 @@ shoppingCartInterface.addCourseToCart(this.courseId); } else { // Otherwise, redirect the user to the next page. + this.updateTaskDescription(gettext(" redirect the user to the next page")); this.redirect(redirectUrl); } }, diff --git a/lms/static/js/student_account/views/account_section_view.js b/lms/static/js/student_account/views/account_section_view.js deleted file mode 100644 index 70cd217477..0000000000 --- a/lms/static/js/student_account/views/account_section_view.js +++ /dev/null @@ -1,48 +0,0 @@ -// eslint-disable-next-line no-shadow-restricted-names -(function(define, undefined) { - 'use strict'; - - define([ - 'gettext', - 'jquery', - 'underscore', - 'backbone', - 'edx-ui-toolkit/js/utils/html-utils', - 'text!templates/student_account/account_settings_section.underscore' - ], function(gettext, $, _, Backbone, HtmlUtils, sectionTemplate) { - var AccountSectionView = Backbone.View.extend({ - - initialize: function(options) { - this.options = options; - _.bindAll(this, 'render', 'renderFields'); - }, - - render: function() { - HtmlUtils.setHtml( - this.$el, - HtmlUtils.template(sectionTemplate)({ - HtmlUtils: HtmlUtils, - sections: this.options.sections, - tabName: this.options.tabName, - tabLabel: this.options.tabLabel - }) - ); - - this.renderFields(); - }, - - renderFields: function() { - var view = this; - - _.each(view.$('.' + view.options.tabName + '-section-body'), function(sectionEl, index) { - _.each(view.options.sections[index].fields, function(field) { - $(sectionEl).append(field.view.render().el); - }); - }); - return this; - } - }); - - return AccountSectionView; - }); -}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_account/views/account_settings_factory.js b/lms/static/js/student_account/views/account_settings_factory.js deleted file mode 100644 index 70d3ad205c..0000000000 --- a/lms/static/js/student_account/views/account_settings_factory.js +++ /dev/null @@ -1,495 +0,0 @@ -// eslint-disable-next-line no-shadow-restricted-names -(function(define, undefined) { - 'use strict'; - - define([ - 'gettext', 'jquery', 'underscore', 'backbone', 'logger', - 'js/student_account/models/user_account_model', - 'js/student_account/models/user_preferences_model', - 'js/student_account/views/account_settings_fields', - 'js/student_account/views/account_settings_view', - 'edx-ui-toolkit/js/utils/string-utils', - 'edx-ui-toolkit/js/utils/html-utils' - ], function(gettext, $, _, Backbone, Logger, UserAccountModel, UserPreferencesModel, - AccountSettingsFieldViews, AccountSettingsView, StringUtils, HtmlUtils) { - return function( - fieldsData, - disableOrderHistoryTab, - ordersHistoryData, - authData, - passwordResetSupportUrl, - userAccountsApiUrl, - userPreferencesApiUrl, - accountUserId, - platformName, - contactEmail, - allowEmailChange, - enableCoppaCompliance, - socialPlatforms, - syncLearnerProfileData, - enterpriseName, - enterpriseReadonlyAccountFields, - edxSupportUrl, - extendedProfileFields, - displayAccountDeletion, - isSecondaryEmailFeatureEnabled, - betaLanguage - ) { - var $accountSettingsElement, userAccountModel, userPreferencesModel, aboutSectionsData, - accountsSectionData, ordersSectionData, accountSettingsView, showAccountSettingsPage, - showLoadingError, orderNumber, getUserField, userFields, timeZoneDropdownField, countryDropdownField, - emailFieldView, secondaryEmailFieldView, socialFields, accountDeletionFields, platformData, - aboutSectionMessageType, aboutSectionMessage, fullnameFieldView, countryFieldView, - fullNameFieldData, emailFieldData, secondaryEmailFieldData, countryFieldData, additionalFields, - fieldItem, emailFieldViewIndex, focusId, yearOfBirthViewIndex, levelOfEducationFieldData, - tabIndex = 0; - - $accountSettingsElement = $('.wrapper-account-settings'); - - userAccountModel = new UserAccountModel(); - userAccountModel.url = userAccountsApiUrl; - - userPreferencesModel = new UserPreferencesModel(); - userPreferencesModel.url = userPreferencesApiUrl; - - if (syncLearnerProfileData && enterpriseName) { - aboutSectionMessageType = 'info'; - aboutSectionMessage = HtmlUtils.interpolateHtml( - gettext('Your profile settings are managed by {enterprise_name}. Contact your administrator or {link_start}edX Support{link_end} for help.'), // eslint-disable-line max-len - { - enterprise_name: enterpriseName, - link_start: HtmlUtils.HTML( - StringUtils.interpolate( - '', { - edx_support_url: edxSupportUrl - } - ) - ), - link_end: HtmlUtils.HTML('') - } - ); - } - - emailFieldData = { - model: userAccountModel, - title: gettext('Email Address (Sign In)'), - valueAttribute: 'email', - helpMessage: StringUtils.interpolate( - gettext('You receive messages from {platform_name} and course teams at this address.'), // eslint-disable-line max-len - {platform_name: platformName} - ), - persistChanges: true - }; - if (!allowEmailChange || (syncLearnerProfileData && enterpriseReadonlyAccountFields.fields.indexOf('email') !== -1)) { // eslint-disable-line max-len - emailFieldView = { - view: new AccountSettingsFieldViews.ReadonlyFieldView(emailFieldData) - }; - } else { - emailFieldView = { - view: new AccountSettingsFieldViews.EmailFieldView(emailFieldData) - }; - } - - secondaryEmailFieldData = { - model: userAccountModel, - title: gettext('Recovery Email Address'), - valueAttribute: 'secondary_email', - helpMessage: gettext('You may access your account with this address if single-sign on or access to your primary email is not available.'), // eslint-disable-line max-len - persistChanges: true - }; - - fullNameFieldData = { - model: userAccountModel, - title: gettext('Full Name'), - valueAttribute: 'name', - helpMessage: gettext('The name that is used for ID verification and that appears on your certificates.'), // eslint-disable-line max-len, - persistChanges: true - }; - if (syncLearnerProfileData && enterpriseReadonlyAccountFields.fields.indexOf('name') !== -1) { - fullnameFieldView = { - view: new AccountSettingsFieldViews.ReadonlyFieldView(fullNameFieldData) - }; - } else { - fullnameFieldView = { - view: new AccountSettingsFieldViews.TextFieldView(fullNameFieldData) - }; - } - - countryFieldData = { - model: userAccountModel, - required: true, - title: gettext('Country or Region of Residence'), - valueAttribute: 'country', - options: fieldsData.country.options, - persistChanges: true, - helpMessage: gettext('The country or region where you live.') - }; - if (syncLearnerProfileData && enterpriseReadonlyAccountFields.fields.indexOf('country') !== -1) { - countryFieldData.editable = 'never'; - countryFieldView = { - view: new AccountSettingsFieldViews.DropdownFieldView( - countryFieldData - ) - }; - } else { - countryFieldView = { - view: new AccountSettingsFieldViews.DropdownFieldView(countryFieldData) - }; - } - - levelOfEducationFieldData = fieldsData.level_of_education.options; - if (enableCoppaCompliance) { - levelOfEducationFieldData = levelOfEducationFieldData.filter(option => option[0] !== 'el'); - } - - aboutSectionsData = [ - { - title: gettext('Basic Account Information'), - subtitle: gettext('These settings include basic information about your account.'), - - messageType: aboutSectionMessageType, - message: aboutSectionMessage, - - fields: [ - { - view: new AccountSettingsFieldViews.ReadonlyFieldView({ - model: userAccountModel, - title: gettext('Username'), - valueAttribute: 'username', - helpMessage: StringUtils.interpolate( - gettext('The name that identifies you on {platform_name}. You cannot change your username.'), // eslint-disable-line max-len - {platform_name: platformName} - ) - }) - }, - fullnameFieldView, - emailFieldView, - { - view: new AccountSettingsFieldViews.PasswordFieldView({ - model: userAccountModel, - title: gettext('Password'), - screenReaderTitle: gettext('Reset Your Password'), - valueAttribute: 'password', - emailAttribute: 'email', - passwordResetSupportUrl: passwordResetSupportUrl, - linkTitle: gettext('Reset Your Password'), - linkHref: fieldsData.password.url, - helpMessage: gettext('Check your email account for instructions to reset your password.') // eslint-disable-line max-len - }) - }, - { - view: new AccountSettingsFieldViews.LanguagePreferenceFieldView({ - model: userPreferencesModel, - title: gettext('Language'), - valueAttribute: 'pref-lang', - required: true, - refreshPageOnSave: true, - helpMessage: StringUtils.interpolate( - gettext('The language used throughout this site. This site is currently available in a limited number of languages. Changing the value of this field will cause the page to refresh.'), // eslint-disable-line max-len - {platform_name: platformName} - ), - options: fieldsData.language.options, - persistChanges: true, - focusNextID: '#u-field-select-country' - }) - }, - countryFieldView, - { - view: new AccountSettingsFieldViews.TimeZoneFieldView({ - model: userPreferencesModel, - required: true, - title: gettext('Time Zone'), - valueAttribute: 'time_zone', - helpMessage: gettext('Select the time zone for displaying course dates. If you do not specify a time zone, course dates, including assignment deadlines, will be displayed in your browser\'s local time zone.'), // eslint-disable-line max-len - groupOptions: [{ - groupTitle: gettext('All Time Zones'), - selectOptions: fieldsData.time_zone.options, - nullValueOptionLabel: gettext('Default (Local Time Zone)') - }], - persistChanges: true - }) - } - ] - }, - { - title: gettext('Additional Information'), - fields: [ - { - view: new AccountSettingsFieldViews.DropdownFieldView({ - model: userAccountModel, - title: gettext('Education Completed'), - valueAttribute: 'level_of_education', - options: levelOfEducationFieldData, - persistChanges: true - }) - }, - { - view: new AccountSettingsFieldViews.DropdownFieldView({ - model: userAccountModel, - title: gettext('Gender'), - valueAttribute: 'gender', - options: fieldsData.gender.options, - persistChanges: true - }) - }, - { - view: new AccountSettingsFieldViews.DropdownFieldView({ - model: userAccountModel, - title: gettext('Year of Birth'), - valueAttribute: 'year_of_birth', - options: fieldsData.year_of_birth.options, - persistChanges: true - }) - }, - { - view: new AccountSettingsFieldViews.LanguageProficienciesFieldView({ - model: userAccountModel, - title: gettext('Preferred Language'), - valueAttribute: 'language_proficiencies', - options: fieldsData.preferred_language.options, - persistChanges: true - }) - } - ] - } - ]; - - if (enableCoppaCompliance) { - yearOfBirthViewIndex = aboutSectionsData[1].fields.findIndex(function(field) { - return field.view.options.valueAttribute === 'year_of_birth'; - }); - aboutSectionsData[1].fields.splice(yearOfBirthViewIndex, 1); - } - - // Secondary email address - if (isSecondaryEmailFeatureEnabled) { - secondaryEmailFieldView = { - view: new AccountSettingsFieldViews.EmailFieldView(secondaryEmailFieldData), - successMessage: function() { - return HtmlUtils.joinHtml( - this.indicators.success, - StringUtils.interpolate( - gettext('We\'ve sent a confirmation message to {new_secondary_email_address}. Click the link in the message to update your secondary email address.'), // eslint-disable-line max-len - { - new_secondary_email_address: this.fieldValue() - } - ) - ); - } - }; - emailFieldViewIndex = aboutSectionsData[0].fields.indexOf(emailFieldView); - - // Insert secondary email address after email address field. - aboutSectionsData[0].fields.splice( - emailFieldViewIndex + 1, 0, secondaryEmailFieldView - ); - } - - // Add the extended profile fields - additionalFields = aboutSectionsData[1]; - for (var field in extendedProfileFields) { // eslint-disable-line guard-for-in, no-restricted-syntax, vars-on-top, max-len - fieldItem = extendedProfileFields[field]; - if (fieldItem.field_type === 'TextField') { - additionalFields.fields.push({ - view: new AccountSettingsFieldViews.ExtendedFieldTextFieldView({ - model: userAccountModel, - title: fieldItem.field_label, - fieldName: fieldItem.field_name, - valueAttribute: 'extended_profile', - persistChanges: true - }) - }); - } else { - if (fieldItem.field_type === 'ListField') { - additionalFields.fields.push({ - view: new AccountSettingsFieldViews.ExtendedFieldListFieldView({ - model: userAccountModel, - title: fieldItem.field_label, - fieldName: fieldItem.field_name, - options: fieldItem.field_options, - valueAttribute: 'extended_profile', - persistChanges: true - }) - }); - } - } - } - - // Add the social link fields - socialFields = { - title: gettext('Social Media Links'), - subtitle: gettext('Optionally, link your personal accounts to the social media icons on your edX profile.'), // eslint-disable-line max-len - fields: [] - }; - - for (var socialPlatform in socialPlatforms) { // eslint-disable-line guard-for-in, no-restricted-syntax, vars-on-top, max-len - platformData = socialPlatforms[socialPlatform]; - socialFields.fields.push( - { - view: new AccountSettingsFieldViews.SocialLinkTextFieldView({ - model: userAccountModel, - title: StringUtils.interpolate( - gettext('{platform_display_name} Link'), - {platform_display_name: platformData.display_name} - ), - valueAttribute: 'social_links', - helpMessage: StringUtils.interpolate( - gettext('Enter your {platform_display_name} username or the URL to your {platform_display_name} page. Delete the URL to remove the link.'), // eslint-disable-line max-len - {platform_display_name: platformData.display_name} - ), - platform: socialPlatform, - persistChanges: true, - placeholder: platformData.example - }) - } - ); - } - aboutSectionsData.push(socialFields); - - // Add account deletion fields - if (displayAccountDeletion) { - accountDeletionFields = { - title: gettext('Delete My Account'), - fields: [], - // Used so content can be rendered external to Backbone - domHookId: 'account-deletion-container' - }; - aboutSectionsData.push(accountDeletionFields); - } - - // set TimeZoneField to listen to CountryField - - getUserField = function(list, search) { - // eslint-disable-next-line no-shadow - return _.find(list, function(field) { - return field.view.options.valueAttribute === search; - }).view; - }; - userFields = _.find(aboutSectionsData, function(section) { - return section.title === gettext('Basic Account Information'); - }).fields; - timeZoneDropdownField = getUserField(userFields, 'time_zone'); - countryDropdownField = getUserField(userFields, 'country'); - timeZoneDropdownField.listenToCountryView(countryDropdownField); - - accountsSectionData = [ - { - title: gettext('Linked Accounts'), - subtitle: StringUtils.interpolate( - gettext('You can link your social media accounts to simplify signing in to {platform_name}.'), - {platform_name: platformName} - ), - fields: _.map(authData.providers, function(provider) { - return { - view: new AccountSettingsFieldViews.AuthFieldView({ - title: provider.name, - valueAttribute: 'auth-' + provider.id, - helpMessage: '', - connected: provider.connected, - connectUrl: provider.connect_url, - acceptsLogins: provider.accepts_logins, - disconnectUrl: provider.disconnect_url, - platformName: platformName - }) - }; - }) - } - ]; - - ordersHistoryData.unshift( - { - title: gettext('ORDER NAME'), - order_date: gettext('ORDER PLACED'), - price: gettext('TOTAL'), - number: gettext('ORDER NUMBER') - } - ); - - ordersSectionData = [ - { - title: gettext('My Orders'), - subtitle: StringUtils.interpolate( - gettext('This page contains information about orders that you have placed with {platform_name}.'), // eslint-disable-line max-len - {platform_name: platformName} - ), - fields: _.map(ordersHistoryData, function(order) { - orderNumber = order.number; - if (orderNumber === 'ORDER NUMBER') { - orderNumber = 'orderId'; - } - return { - view: new AccountSettingsFieldViews.OrderHistoryFieldView({ - totalPrice: order.price, - orderId: order.number, - orderDate: order.order_date, - receiptUrl: order.receipt_url, - valueAttribute: 'order-' + orderNumber, - lines: order.lines - }) - }; - }) - } - ]; - - accountSettingsView = new AccountSettingsView({ - model: userAccountModel, - accountUserId: accountUserId, - el: $accountSettingsElement, - tabSections: { - aboutTabSections: aboutSectionsData, - accountsTabSections: accountsSectionData, - ordersTabSections: ordersSectionData - }, - userPreferencesModel: userPreferencesModel, - disableOrderHistoryTab: disableOrderHistoryTab, - betaLanguage: betaLanguage - }); - - accountSettingsView.render(); - focusId = $.cookie('focus_id'); - if (focusId) { - // eslint-disable-next-line no-bitwise - if (~focusId.indexOf('beta-language')) { - tabIndex = -1; - - // Scroll to top of selected element - $('html, body').animate({ - scrollTop: $(focusId).offset().top - }, 'slow'); - } - $(focusId).attr({tabindex: tabIndex}).focus(); - // Deleting the cookie - document.cookie = 'focus_id=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/account;'; - } - showAccountSettingsPage = function() { - // Record that the account settings page was viewed. - Logger.log('edx.user.settings.viewed', { - page: 'account', - visibility: null, - user_id: accountUserId - }); - }; - - showLoadingError = function() { - accountSettingsView.showLoadingError(); - }; - - userAccountModel.fetch({ - success: function() { - // Fetch the user preferences model - userPreferencesModel.fetch({ - success: showAccountSettingsPage, - error: showLoadingError - }); - }, - error: showLoadingError - }); - - return { - userAccountModel: userAccountModel, - userPreferencesModel: userPreferencesModel, - accountSettingsView: accountSettingsView - }; - }; - }); -}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_account/views/account_settings_fields.js b/lms/static/js/student_account/views/account_settings_fields.js deleted file mode 100644 index 1fc174f935..0000000000 --- a/lms/static/js/student_account/views/account_settings_fields.js +++ /dev/null @@ -1,466 +0,0 @@ -// eslint-disable-next-line no-shadow-restricted-names -(function(define, undefined) { - 'use strict'; - - define([ - 'gettext', - 'jquery', - 'underscore', - 'backbone', - 'js/views/fields', - 'text!templates/fields/field_text_account.underscore', - 'text!templates/fields/field_readonly_account.underscore', - 'text!templates/fields/field_link_account.underscore', - 'text!templates/fields/field_dropdown_account.underscore', - 'text!templates/fields/field_social_link_account.underscore', - 'text!templates/fields/field_order_history.underscore', - 'edx-ui-toolkit/js/utils/string-utils', - 'edx-ui-toolkit/js/utils/html-utils' - ], function( - gettext, $, _, Backbone, - FieldViews, - field_text_account_template, - field_readonly_account_template, - field_link_account_template, - field_dropdown_account_template, - field_social_link_template, - field_order_history_template, - StringUtils, - HtmlUtils - ) { - var AccountSettingsFieldViews = { - ReadonlyFieldView: FieldViews.ReadonlyFieldView.extend({ - fieldTemplate: field_readonly_account_template - }), - TextFieldView: FieldViews.TextFieldView.extend({ - fieldTemplate: field_text_account_template - }), - DropdownFieldView: FieldViews.DropdownFieldView.extend({ - fieldTemplate: field_dropdown_account_template - }), - EmailFieldView: FieldViews.TextFieldView.extend({ - fieldTemplate: field_text_account_template, - successMessage: function() { - return HtmlUtils.joinHtml( - this.indicators.success, - StringUtils.interpolate( - gettext('We\'ve sent a confirmation message to {new_email_address}. Click the link in the message to update your email address.'), // eslint-disable-line max-len - {new_email_address: this.fieldValue()} - ) - ); - } - }), - LanguagePreferenceFieldView: FieldViews.DropdownFieldView.extend({ - fieldTemplate: field_dropdown_account_template, - - initialize: function(options) { - this._super(options); // eslint-disable-line no-underscore-dangle - this.listenTo(this.model, 'revertValue', this.revertValue); - }, - - revertValue: function(event) { - var attributes = {}, - oldPrefLang = $(event.target).data('old-lang-code'); - - if (oldPrefLang) { - attributes['pref-lang'] = oldPrefLang; - this.saveAttributes(attributes); - } - }, - - saveSucceeded: function() { - var data = { - language: this.modelValue(), - next: window.location.href - }; - - var view = this; - $.ajax({ - type: 'POST', - url: '/i18n/setlang/', - data: data, - dataType: 'html', - success: function() { - view.showSuccessMessage(); - }, - error: function() { - view.showNotificationMessage( - HtmlUtils.joinHtml( - view.indicators.error, - gettext('You must sign out and sign back in before your language changes take effect.') // eslint-disable-line max-len - ) - ); - } - }); - } - - }), - TimeZoneFieldView: FieldViews.DropdownFieldView.extend({ - fieldTemplate: field_dropdown_account_template, - - initialize: function(options) { - this.options = _.extend({}, options); - _.bindAll(this, 'listenToCountryView', 'updateCountrySubheader', 'replaceOrAddGroupOption'); - this._super(options); // eslint-disable-line no-underscore-dangle - }, - - listenToCountryView: function(view) { - this.listenTo(view.model, 'change:country', this.updateCountrySubheader); - }, - - updateCountrySubheader: function(user) { - var view = this; - $.ajax({ - type: 'GET', - url: '/api/user/v1/preferences/time_zones/', - data: {country_code: user.attributes.country}, - success: function(data) { - var countryTimeZones = $.map(data, function(timeZoneInfo) { - return [[timeZoneInfo.time_zone, timeZoneInfo.description]]; - }); - view.replaceOrAddGroupOption( - 'Country Time Zones', - countryTimeZones - ); - view.render(); - } - }); - }, - - updateValueInField: function() { - var options; - if (this.modelValue()) { - options = [[this.modelValue(), this.displayValue(this.modelValue())]]; - this.replaceOrAddGroupOption( - 'Currently Selected Time Zone', - options - ); - } - this._super(); // eslint-disable-line no-underscore-dangle - }, - - replaceOrAddGroupOption: function(title, options) { - var groupOption = { - groupTitle: gettext(title), - selectOptions: options - }; - - var index = _.findIndex(this.options.groupOptions, function(group) { - return group.groupTitle === gettext(title); - }); - if (index >= 0) { - this.options.groupOptions[index] = groupOption; - } else { - this.options.groupOptions.unshift(groupOption); - } - } - - }), - PasswordFieldView: FieldViews.LinkFieldView.extend({ - fieldType: 'button', - fieldTemplate: field_link_account_template, - events: { - 'click button': 'linkClicked' - }, - initialize: function(options) { - this.options = _.extend({}, options); - this._super(options); - _.bindAll(this, 'resetPassword'); - }, - linkClicked: function(event) { - event.preventDefault(); - this.toggleDisableButton(true); - this.resetPassword(event); - }, - resetPassword: function() { - var data = {}; - data[this.options.emailAttribute] = this.model.get(this.options.emailAttribute); - - var view = this; - $.ajax({ - type: 'POST', - url: view.options.linkHref, - data: data, - success: function() { - view.showSuccessMessage(); - view.setMessageTimeout(); - }, - error: function(xhr) { - view.showErrorMessage(xhr); - view.setMessageTimeout(); - view.toggleDisableButton(false); - } - }); - }, - toggleDisableButton: function(disabled) { - var button = this.$('#u-field-link-' + this.options.valueAttribute); - if (button) { - button.prop('disabled', disabled); - } - }, - setMessageTimeout: function() { - var view = this; - setTimeout(function() { - view.showHelpMessage(); - }, 6000); - }, - successMessage: function() { - return HtmlUtils.joinHtml( - this.indicators.success, - HtmlUtils.interpolateHtml( - gettext('We\'ve sent a message to {email}. Click the link in the message to reset your password. Didn\'t receive the message? Contact {anchorStart}technical support{anchorEnd}.'), // eslint-disable-line max-len - { - email: this.model.get(this.options.emailAttribute), - anchorStart: HtmlUtils.HTML( - StringUtils.interpolate( - '', { - passwordResetSupportUrl: this.options.passwordResetSupportUrl - } - ) - ), - anchorEnd: HtmlUtils.HTML('') - } - ) - ); - } - }), - LanguageProficienciesFieldView: FieldViews.DropdownFieldView.extend({ - fieldTemplate: field_dropdown_account_template, - modelValue: function() { - var modelValue = this.model.get(this.options.valueAttribute); - if (_.isArray(modelValue) && modelValue.length > 0) { - return modelValue[0].code; - } else { - return null; - } - }, - saveValue: function() { - var attributes = {}, - value = ''; - if (this.persistChanges === true) { - value = this.fieldValue() ? [{code: this.fieldValue()}] : []; - attributes[this.options.valueAttribute] = value; - this.saveAttributes(attributes); - } - } - }), - SocialLinkTextFieldView: FieldViews.TextFieldView.extend({ - render: function() { - HtmlUtils.setHtml(this.$el, HtmlUtils.template(field_text_account_template)({ - id: this.options.valueAttribute + '_' + this.options.platform, - title: this.options.title, - value: this.modelValue(), - message: this.options.helpMessage, - placeholder: this.options.placeholder || '' - })); - this.delegateEvents(); - return this; - }, - - modelValue: function() { - var socialLinks = this.model.get(this.options.valueAttribute); - for (var i = 0; i < socialLinks.length; i++) { // eslint-disable-line vars-on-top - if (socialLinks[i].platform === this.options.platform) { - return socialLinks[i].social_link; - } - } - return null; - }, - saveValue: function() { - var attributes, value; - if (this.persistChanges === true) { - attributes = {}; - value = this.fieldValue() != null ? [{ - platform: this.options.platform, - social_link: this.fieldValue() - }] : []; - attributes[this.options.valueAttribute] = value; - this.saveAttributes(attributes); - } - } - }), - ExtendedFieldTextFieldView: FieldViews.TextFieldView.extend({ - render: function() { - HtmlUtils.setHtml(this.$el, HtmlUtils.template(field_text_account_template)({ - id: this.options.valueAttribute + '_' + this.options.field_name, - title: this.options.title, - value: this.modelValue(), - message: this.options.helpMessage, - placeholder: this.options.placeholder || '' - })); - this.delegateEvents(); - return this; - }, - - modelValue: function() { - var extendedProfileFields = this.model.get(this.options.valueAttribute); - for (var i = 0; i < extendedProfileFields.length; i++) { // eslint-disable-line vars-on-top - if (extendedProfileFields[i].field_name === this.options.fieldName) { - return extendedProfileFields[i].field_value; - } - } - return null; - }, - saveValue: function() { - var attributes, value; - if (this.persistChanges === true) { - attributes = {}; - value = this.fieldValue() != null ? [{ - field_name: this.options.fieldName, - field_value: this.fieldValue() - }] : []; - attributes[this.options.valueAttribute] = value; - this.saveAttributes(attributes); - } - } - }), - ExtendedFieldListFieldView: FieldViews.DropdownFieldView.extend({ - fieldTemplate: field_dropdown_account_template, - modelValue: function() { - var extendedProfileFields = this.model.get(this.options.valueAttribute); - for (var i = 0; i < extendedProfileFields.length; i++) { // eslint-disable-line vars-on-top - if (extendedProfileFields[i].field_name === this.options.fieldName) { - return extendedProfileFields[i].field_value; - } - } - return null; - }, - saveValue: function() { - var attributes = {}, - value; - if (this.persistChanges === true) { - value = this.fieldValue() ? [{ - field_name: this.options.fieldName, - field_value: this.fieldValue() - }] : []; - attributes[this.options.valueAttribute] = value; - this.saveAttributes(attributes); - } - } - }), - AuthFieldView: FieldViews.LinkFieldView.extend({ - fieldTemplate: field_social_link_template, - className: function() { - return 'u-field u-field-social u-field-' + this.options.valueAttribute; - }, - initialize: function(options) { - this.options = _.extend({}, options); - this._super(options); - _.bindAll(this, 'redirect_to', 'disconnect', 'successMessage', 'inProgressMessage'); - }, - render: function() { - var linkTitle = '', - linkClass = '', - subTitle = '', - screenReaderTitle = StringUtils.interpolate( - gettext('Link your {accountName} account'), - {accountName: this.options.title} - ); - if (this.options.connected) { - linkTitle = gettext('Unlink This Account'); - linkClass = 'social-field-linked'; - subTitle = StringUtils.interpolate( - gettext('You can use your {accountName} account to sign in to your {platformName} account.'), // eslint-disable-line max-len - {accountName: this.options.title, platformName: this.options.platformName} - ); - screenReaderTitle = StringUtils.interpolate( - gettext('Unlink your {accountName} account'), - {accountName: this.options.title} - ); - } else if (this.options.acceptsLogins) { - linkTitle = gettext('Link Your Account'); - linkClass = 'social-field-unlinked'; - subTitle = StringUtils.interpolate( - gettext('Link your {accountName} account to your {platformName} account and use {accountName} to sign in to {platformName}.'), // eslint-disable-line max-len - {accountName: this.options.title, platformName: this.options.platformName} - ); - } - - HtmlUtils.setHtml(this.$el, HtmlUtils.template(this.fieldTemplate)({ - id: this.options.valueAttribute, - title: this.options.title, - screenReaderTitle: screenReaderTitle, - linkTitle: linkTitle, - subTitle: subTitle, - linkClass: linkClass, - linkHref: '#', - message: this.helpMessage - })); - this.delegateEvents(); - return this; - }, - linkClicked: function(event) { - event.preventDefault(); - - this.showInProgressMessage(); - - if (this.options.connected) { - this.disconnect(); - } else { - // Direct the user to the providers site to start the authentication process. - // See python-social-auth docs for more information. - this.redirect_to(this.options.connectUrl); - } - }, - redirect_to: function(url) { - window.location.href = url; - }, - disconnect: function() { - var data = {}; - - // Disconnects the provider from the user's edX account. - // See python-social-auth docs for more information. - var view = this; - $.ajax({ - type: 'POST', - url: this.options.disconnectUrl, - data: data, - dataType: 'html', - success: function() { - view.options.connected = false; - view.render(); - view.showSuccessMessage(); - }, - error: function(xhr) { - view.showErrorMessage(xhr); - } - }); - }, - inProgressMessage: function() { - return HtmlUtils.joinHtml(this.indicators.inProgress, ( - this.options.connected ? gettext('Unlinking') : gettext('Linking') - )); - }, - successMessage: function() { - return HtmlUtils.joinHtml(this.indicators.success, gettext('Successfully unlinked.')); - } - }), - - OrderHistoryFieldView: FieldViews.ReadonlyFieldView.extend({ - fieldType: 'orderHistory', - fieldTemplate: field_order_history_template, - - initialize: function(options) { - this.options = options; - this._super(options); - this.template = HtmlUtils.template(this.fieldTemplate); - }, - - render: function() { - HtmlUtils.setHtml(this.$el, this.template({ - totalPrice: this.options.totalPrice, - orderId: this.options.orderId, - orderDate: this.options.orderDate, - receiptUrl: this.options.receiptUrl, - valueAttribute: this.options.valueAttribute, - lines: this.options.lines - })); - this.delegateEvents(); - return this; - } - }) - }; - - return AccountSettingsFieldViews; - }); -}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_account/views/account_settings_view.js b/lms/static/js/student_account/views/account_settings_view.js deleted file mode 100644 index 6ee9c9101d..0000000000 --- a/lms/static/js/student_account/views/account_settings_view.js +++ /dev/null @@ -1,157 +0,0 @@ -// eslint-disable-next-line no-shadow-restricted-names -(function(define, undefined) { - 'use strict'; - - define([ - 'gettext', - 'jquery', - 'underscore', - 'common/js/components/views/tabbed_view', - 'edx-ui-toolkit/js/utils/html-utils', - 'js/student_account/views/account_section_view', - 'text!templates/student_account/account_settings.underscore' - ], function(gettext, $, _, TabbedView, HtmlUtils, AccountSectionView, accountSettingsTemplate) { - var AccountSettingsView = TabbedView.extend({ - - navLink: '.account-nav-link', - activeTab: 'aboutTabSections', - events: { - 'click .account-nav-link': 'switchTab', - 'keydown .account-nav-link': 'keydownHandler', - 'click .btn-alert-primary': 'revertValue' - }, - - initialize: function(options) { - this.options = options; - _.bindAll(this, 'render', 'switchTab', 'setActiveTab', 'showLoadingError'); - }, - - render: function() { - var tabName, betaLangMessage, helpTranslateText, helpTranslateLink, betaLangCode, oldLangCode, - view = this; - var accountSettingsTabs = [ - { - name: 'aboutTabSections', - id: 'about-tab', - label: gettext('Account Information'), - class: 'active', - tabindex: 0, - selected: true, - expanded: true - }, - { - name: 'accountsTabSections', - id: 'accounts-tab', - label: gettext('Linked Accounts'), - tabindex: -1, - selected: false, - expanded: false - } - ]; - if (!view.options.disableOrderHistoryTab) { - accountSettingsTabs.push({ - name: 'ordersTabSections', - id: 'orders-tab', - label: gettext('Order History'), - tabindex: -1, - selected: false, - expanded: false - }); - } - - if (!_.isEmpty(view.options.betaLanguage) && $.cookie('old-pref-lang')) { - betaLangMessage = HtmlUtils.interpolateHtml( - gettext('You have set your language to {beta_language}, which is currently not fully translated. You can help us translate this language fully by joining the Transifex community and adding translations from English for learners that speak {beta_language}.'), // eslint-disable-line max-len - { - beta_language: view.options.betaLanguage.name - } - ); - helpTranslateText = HtmlUtils.interpolateHtml( - gettext('Help Translate into {beta_language}'), - { - beta_language: view.options.betaLanguage.name - } - ); - betaLangCode = this.options.betaLanguage.code.split('-'); - if (betaLangCode.length > 1) { - betaLangCode = betaLangCode[0] + '_' + betaLangCode[1].toUpperCase(); - } else { - betaLangCode = betaLangCode[0]; - } - helpTranslateLink = 'https://www.transifex.com/open-edx/edx-platform/translate/#' + betaLangCode; - oldLangCode = $.cookie('old-pref-lang'); - // Deleting the cookie - document.cookie = 'old-pref-lang=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/account;'; - - $.cookie('focus_id', '#beta-language-message'); - } - HtmlUtils.setHtml(this.$el, HtmlUtils.template(accountSettingsTemplate)({ - accountSettingsTabs: accountSettingsTabs, - HtmlUtils: HtmlUtils, - message: betaLangMessage, - helpTranslateText: helpTranslateText, - helpTranslateLink: helpTranslateLink, - oldLangCode: oldLangCode - })); - _.each(accountSettingsTabs, function(tab) { - tabName = tab.name; - view.renderSection(view.options.tabSections[tabName], tabName, tab.label); - }); - return this; - }, - - switchTab: function(e) { - var $currentTab, - $accountNavLink = $('.account-nav-link'); - - if (e) { - e.preventDefault(); - $currentTab = $(e.target); - this.activeTab = $currentTab.data('name'); - - _.each(this.$('.account-settings-tabpanels'), function(tabPanel) { - $(tabPanel).addClass('hidden'); - }); - - $('#' + this.activeTab + '-tabpanel').removeClass('hidden'); - - $accountNavLink.attr('tabindex', -1); - $accountNavLink.attr('aria-selected', false); - $accountNavLink.attr('aria-expanded', false); - - $currentTab.attr('tabindex', 0); - $currentTab.attr('aria-selected', true); - $currentTab.attr('aria-expanded', true); - - $(this.navLink).removeClass('active'); - $currentTab.addClass('active'); - } - }, - - setActiveTab: function() { - this.switchTab(); - }, - - renderSection: function(tabSections, tabName, tabLabel) { - var accountSectionView = new AccountSectionView({ - tabName: tabName, - tabLabel: tabLabel, - sections: tabSections, - el: '#' + tabName + '-tabpanel' - }); - - accountSectionView.render(); - }, - - showLoadingError: function() { - this.$('.ui-loading-error').removeClass('is-hidden'); - }, - - revertValue: function(event) { - this.options.userPreferencesModel.trigger('revertValue', event); - } - }); - - return AccountSettingsView; - }); -}).call(this, define || RequireJS.define); diff --git a/lms/static/learner_profile b/lms/static/learner_profile deleted file mode 120000 index ca7ce1f797..0000000000 --- a/lms/static/learner_profile +++ /dev/null @@ -1 +0,0 @@ -../../openedx/features/learner_profile/static/learner_profile \ No newline at end of file diff --git a/lms/static/lms/js/build.js b/lms/static/lms/js/build.js index c22f366c5d..1d5e1a983b 100644 --- a/lms/static/lms/js/build.js +++ b/lms/static/lms/js/build.js @@ -33,10 +33,8 @@ 'js/discussions_management/views/discussions_dashboard_factory', 'js/header_factory', 'js/student_account/logistration_factory', - 'js/student_account/views/account_settings_factory', 'js/student_account/views/finish_auth_factory', 'js/views/message_banner', - 'learner_profile/js/learner_profile_factory', 'lms/js/preview/preview_factory', 'support/js/certificates_factory', 'support/js/enrollment_factory', diff --git a/lms/static/lms/js/require-config.js b/lms/static/lms/js/require-config.js index f6a807bc57..a02f1b275c 100644 --- a/lms/static/lms/js/require-config.js +++ b/lms/static/lms/js/require-config.js @@ -101,7 +101,6 @@ 'utility': 'js/src/utility', 'draggabilly': 'js/vendor/draggabilly', 'bootstrap': 'common/js/vendor/bootstrap.bundle', - 'picturefill': 'common/js/vendor/picturefill', 'hls': 'common/js/vendor/hls', 'tinymce': 'js/vendor/tinymce/js/tinymce/tinymce.full.min', 'jquery.tinymce': 'js/vendor/tinymce/js/tinymce/jquery.tinymce.min', diff --git a/lms/static/lms/js/spec/main.js b/lms/static/lms/js/spec/main.js index cd8668ddc4..d8bc1417e8 100644 --- a/lms/static/lms/js/spec/main.js +++ b/lms/static/lms/js/spec/main.js @@ -73,7 +73,6 @@ '_split': 'js/split', 'mathjax_delay_renderer': 'js/mathjax_delay_renderer', 'MathJaxProcessor': 'js/customwmd', - 'picturefill': 'common/js/vendor/picturefill', 'bootstrap': 'common/js/vendor/bootstrap.bundle', 'draggabilly': 'xmodule_js/common_static/js/vendor/draggabilly', @@ -761,9 +760,6 @@ 'js/spec/shoppingcart/shoppingcart_spec.js', 'js/spec/staff_debug_actions_spec.js', 'js/spec/student_account/access_spec.js', - 'js/spec/student_account/account_settings_factory_spec.js', - 'js/spec/student_account/account_settings_fields_spec.js', - 'js/spec/student_account/account_settings_view_spec.js', 'js/spec/student_account/emailoptin_spec.js', 'js/spec/student_account/enrollment_spec.js', 'js/spec/student_account/finish_auth_spec.js', @@ -787,10 +783,6 @@ 'js/spec/views/file_uploader_spec.js', 'js/spec/views/message_banner_spec.js', 'js/spec/views/notification_spec.js', - 'learner_profile/js/spec/learner_profile_factory_spec.js', - 'learner_profile/js/spec/views/learner_profile_fields_spec.js', - 'learner_profile/js/spec/views/learner_profile_view_spec.js', - 'learner_profile/js/spec/views/section_two_tab_spec.js', 'support/js/spec/collections/enrollment_spec.js', 'support/js/spec/models/enrollment_spec.js', 'support/js/spec/views/certificates_spec.js', diff --git a/lms/static/sass/_build-course.scss b/lms/static/sass/_build-course.scss index 732eb2caf8..47fde243c7 100644 --- a/lms/static/sass/_build-course.scss +++ b/lms/static/sass/_build-course.scss @@ -66,6 +66,3 @@ // responsive @import 'base/layouts'; // temporary spot for responsive course @import 'header'; - -// features -@import 'features/course-sock'; diff --git a/lms/static/sass/_build-lms-v1.scss b/lms/static/sass/_build-lms-v1.scss index 1171e3d14c..4d64e67685 100644 --- a/lms/static/sass/_build-lms-v1.scss +++ b/lms/static/sass/_build-lms-v1.scss @@ -52,7 +52,6 @@ @import 'multicourse/survey-page'; // base - specific views -@import 'views/account-settings'; @import 'views/course-entitlements'; @import 'views/login-register'; @import 'views/verification'; @@ -69,7 +68,6 @@ // features @import 'features/bookmarks-v1'; @import "features/announcements"; -@import 'features/learner-profile'; @import 'features/_unsupported-browser-alert'; @import 'features/content-type-gating'; @import 'features/course-duration-limits'; diff --git a/lms/static/sass/bootstrap/lms-main.scss b/lms/static/sass/bootstrap/lms-main.scss index 70bd33da28..1299b84fa2 100644 --- a/lms/static/sass/bootstrap/lms-main.scss +++ b/lms/static/sass/bootstrap/lms-main.scss @@ -28,7 +28,6 @@ $static-path: '../..'; @import 'features/bookmarks'; @import 'features/course-experience'; @import 'features/course-search'; -@import 'features/course-sock'; @import 'features/course-upgrade-message'; @import 'features/course-duration-limits'; diff --git a/lms/static/sass/features/_course-sock.scss b/lms/static/sass/features/_course-sock.scss deleted file mode 100644 index a3a1f1484d..0000000000 --- a/lms/static/sass/features/_course-sock.scss +++ /dev/null @@ -1,170 +0,0 @@ -.verification-sock { - display: inline-block; - position: relative; - width: 100%; - max-width: map-get($container-max-widths, xl); - margin: $baseline auto 0; - -webkit-transition: all 0.4s ease-out; - -moz-transition: all 0.4s ease-out; - -o-transition: all 0.4s ease-out; - -ms-transition: all 0.4s ease-out; - transition: all 0.4s ease-out; - - .action-toggle-verification-sock { - @include left(50%); - @include margin-left(-1 * $baseline * 15/2); - - position: absolute; - top: (-1 * $baseline); - width: ($baseline * 15); - color: theme-color("inverse"); - background-color: theme-color("success"); - border-color: theme-color("success"); - background-image: none; - box-shadow: none; - -webkit-transition: background-color 0.5s; - transition: background-color 0.5s; - cursor: pointer; - - &.active, - &:focus, - &:hover { - color: theme-color("success"); - background-color: theme-color("inverse"); - border-color: theme-color("success"); - background-image: none; - box-shadow: none; - } - } - - .verification-main-panel { - display: none; - overflow: hidden; - border-top: 1px solid $border-color; - padding: ($baseline * 5/2) ($baseline * 2); - -webkit-transition: height ease-out; - transition: height ease-out; - - .verification-desc-panel { - color: $black-t3; - position: relative; - - h2 { - font-size: 1.5rem; - font-weight: $font-weight-bold; - } - - h3 { - font-size: 1.25rem; - } - - @media (max-width: 960px) { - .mini-cert { - display: none; - border: 1px solid $black-t0; - } - } - - .mini-cert { - @include right($baseline); - - position: absolute; - top: $baseline; - width: ($baseline * 13); - } - - .learner-story-container { - display: flex; - max-width: 630px; - - .student-image { - margin: ($baseline / 4) $baseline 0 0; - height: ($baseline * 5/2); - width: ($baseline * 5/2); - } - - .story-quote > .author { - display: block; - margin-top: ($baseline / 4); - font-weight: 600; - } - - &:not(:first-child) { - margin-top: ($baseline * 2); - } - } - - .action-upgrade-certificate { - position: absolute; - right: $baseline; - background-color: theme-color("success"); - border-color: theme-color("success"); - color: theme-color("inverse"); - background-image: none; - box-shadow: none; - cursor: pointer; - - &:hover { - background-color: theme-color("inverse"); - color: theme-color("success"); - } - - @media (max-width: 960px) { - & { - position: relative; - margin-top: ($baseline * 2); - } - } - - @media (min-width: 960px) { - &.stuck-top { - bottom: auto; - top: $baseline * (52 / 5); - } - - &.stuck-bottom { - top: auto; - bottom: $baseline * (-1 * 3/2); - } - - &.attached { - @include right($baseline); - - position: fixed; - bottom: $baseline; - top: auto; - } - } - } - } - } -} - -// Overrides for the courseware page. -.view-courseware { - .verification-sock { - margin-top: 0; - border-top: none; - border-bottom: none; - - .action-toggle-verification-sock { - top: (-1 * $baseline * 5/4); - - &:not(.active) { - color: theme-color("inverse"); - background-color: theme-color("success"); - box-shadow: none; - border: 1px solid theme-color("success"); - - &:hover { - background-color: $success-color-hover; - } - } - } - - .verification-main-panel { - border-top: 0; - border-bottom: 1px solid $border-color; - } - } -} diff --git a/lms/static/sass/features/_learner-profile.scss b/lms/static/sass/features/_learner-profile.scss deleted file mode 100644 index 8d35a7eccc..0000000000 --- a/lms/static/sass/features/_learner-profile.scss +++ /dev/null @@ -1,875 +0,0 @@ -// lms - application - learner profile -// ==================== - -.learner-achievements { - .learner-message { - @extend %no-content; - - margin: $baseline*0.75 0; - - .message-header, - .message-actions { - text-align: center; - } - - .message-actions { - margin-top: $baseline/2; - - .btn-brand { - color: $white; - } - } - } -} - -.certificate-card { - display: flex; - flex-direction: row; - margin-bottom: $baseline; - padding: $baseline/2; - border: 1px; - border-style: solid; - background-color: $white; - cursor: pointer; - - &:hover { - box-shadow: 0 0 1px 1px $gray-l2; - } - - .card-logo { - @include margin-right($baseline); - - width: 100px; - height: 100px; - - @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap - display: none; - } - } - - .card-content { - color: $body-color; - margin-top: $baseline/2; - } - - .card-supertitle { - @extend %t-title6; - - color: $lightest-base-font-color; - } - - .card-title { - @extend %t-title5; - @extend %t-strong; - - margin-bottom: $baseline/2; - } - - .card-text { - @extend %t-title8; - - color: $lightest-base-font-color; - } - - &.mode-audit { - border-color: $audit-mode-color; - - .card-logo { - background-image: url('#{$static-path}/images/certificates/audit.png'); - } - } - - &.mode-honor { - border-color: $honor-mode-color; - - .card-logo { - background-image: url('#{$static-path}/images/certificates/honor.png'); - } - } - - &.mode-verified { - border-color: $verified-mode-color; - - .card-logo { - background-image: url('#{$static-path}/images/certificates/verified.png'); - } - } - - &.mode-professional { - border-color: $professional-certificate-color; - - .card-logo { - background-image: url('#{$static-path}/images/certificates/professional.png'); - } - } -} - -.view-profile { - $profile-image-dimension: 120px; - - .window-wrap, - .content-wrapper { - background-color: $body-bg; - padding: 0; - margin-top: 0; - } - - .page-banner { - background-color: $gray-l4; - max-width: none; - - .user-messages { - max-width: map-get($container-max-widths, xl); - margin: auto; - padding: $baseline/2; - } - } - - .ui-loading-indicator { - @extend .ui-loading-base; - - padding-bottom: $baseline; - - // center horizontally - @include margin-left(auto); - @include margin-right(auto); - - width: ($baseline*5); - } - - .profile-image-field { - button { - background: transparent !important; - border: none !important; - padding: 0; - } - - .u-field-image { - padding-top: 0; - padding-bottom: ($baseline/4); - } - - .image-wrapper { - width: $profile-image-dimension; - position: relative; - margin: auto; - - .image-frame { - display: block; - position: relative; - width: $profile-image-dimension; - height: $profile-image-dimension; - border-radius: ($profile-image-dimension/2); - overflow: hidden; - border: 3px solid $gray-l6; - margin-top: $baseline*-0.75; - background: $white; - } - - .u-field-upload-button { - position: absolute; - top: 0; - opacity: 0; - width: $profile-image-dimension; - height: $profile-image-dimension; - border-radius: ($profile-image-dimension/2); - border: 2px dashed transparent; - background: rgba(229, 241, 247, 0.8); - color: $link-color; - text-shadow: none; - - @include transition(all $tmg-f1 ease-in-out 0s); - - z-index: 6; - - i { - color: $link-color; - } - - &:focus, - &:hover { - @include show-hover-state(); - - border-color: $link-color; - } - - &.in-progress { - opacity: 1; - } - } - - .button-visible { - @include show-hover-state(); - } - - .upload-button-icon, - .upload-button-title { - display: block; - margin-bottom: ($baseline/4); - - @include transform(translateY(35px)); - - line-height: 1.3em; - text-align: center; - z-index: 7; - color: $body-color; - } - - .upload-button-input { - position: absolute; - top: 0; - - @include left(0); - - width: $profile-image-dimension; - border-radius: ($profile-image-dimension/2); - height: 100%; - cursor: pointer; - z-index: 5; - outline: 0; - opacity: 0; - } - - .u-field-remove-button { - position: relative; - display: block; - width: $profile-image-dimension; - margin-top: ($baseline / 4); - padding: ($baseline / 5) 0 0; - text-align: center; - opacity: 0; - transition: opacity 0.5s; - } - - &:hover, - &:active { - .u-field-remove-button { - opacity: 1; - } - } - } - } - - .wrapper-profile { - min-height: 200px; - background-color: $gray-l6; - - .ui-loading-indicator { - margin-top: 100px; - } - } - - .profile-self { - .wrapper-profile-field-account-privacy { - @include clearfix(); - - box-sizing: border-box; - width: 100%; - margin: 0 auto; - border-bottom: 1px solid $gray-l3; - background-color: $gray-l4; - padding: ($baseline*0.75) 5%; - display: table; - - .wrapper-profile-records { - display: table-row; - - button { - @extend %btn-secondary-blue-outline; - - margin-top: 1em; - background: $blue; - color: #fff; - } - } - - @include media-breakpoint-up(sm) { - .wrapper-profile-records { - display: table-cell; - vertical-align: middle; - white-space: nowrap; - - button { - margin-top: 0; - } - } - } - - .u-field-account_privacy { - @extend .container; - - display: table-cell; - border: none; - box-shadow: none; - padding: 0; - margin: 0; - vertical-align: middle; - - @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap - max-width: calc(100% - 40px); - min-width: auto; - } - - .btn-change-privacy { - @extend %btn-primary-blue; - - padding-top: 4px; - padding-bottom: 5px; - background-image: none; - box-shadow: none; - } - } - - .u-field-title { - @extend %t-strong; - - width: auto; - color: $body-color; - cursor: text; - text-shadow: none; // override bad lms styles on labels - } - - .u-field-value { - width: auto; - - @include margin-left($baseline/2); - } - - .u-field-message { - @include float(left); - - width: 100%; - padding: 0; - color: $body-color; - - .u-field-message-notification { - color: $gray-d2; - } - } - } - } - - .wrapper-profile-sections { - @extend .container; - - @include padding($baseline*1.5, 5%, $baseline*1.5, 5%); - - display: flex; - min-width: 0; - max-width: 100%; - - @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap - @include margin-left(0); - - flex-wrap: wrap; - } - } - - .profile-header { - max-width: map-get($container-max-widths, xl); - margin: auto; - padding: $baseline 5% 0; - - .header { - @extend %t-title4; - @extend %t-ultrastrong; - - display: inline-block; - color: #222; - } - - .subheader { - @extend %t-title6; - } - } - - .wrapper-profile-section-container-one { - @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap - width: 100%; - } - - .wrapper-profile-section-one { - width: 300px; - background-color: $white; - border-top: 5px solid $blue; - padding-bottom: $baseline; - - @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap - @include margin-left(0); - - width: 100%; - } - - .profile-section-one-fields { - margin: 0 $baseline/2; - - .social-links { - @include padding($baseline/4, 0, 0, $baseline/4); - - font-size: 2rem; - - & > span { - color: $gray-l4; - } - - a { - .fa-facebook-square { - color: $facebook-blue; - } - - .fa-twitter-square { - color: $twitter-blue; - } - - .fa-linkedin-square { - color: $linkedin-blue; - } - } - } - - .u-field { - font-weight: $font-semibold; - - @include padding(0, 0, 0, 3px); - - color: $body-color; - margin-top: $baseline/5; - - .u-field-value, - .u-field-title { - font-weight: 500; - width: calc(100% - 40px); - color: $lightest-base-font-color; - } - - .u-field-value-readonly { - font-family: $font-family-sans-serif; - color: $darkest-base-font-color; - } - - &.u-field-dropdown { - position: relative; - - &:not(.editable-never) { - cursor: pointer; - } - } - - &:not(.u-field-readonly) { - &.u-field-value { - @extend %t-weight3; - } - - &:not(:last-child) { - padding-bottom: $baseline/4; - border-bottom: 1px solid $border-color; - - &:hover.mode-placeholder { - padding-bottom: $baseline/5; - border-bottom: 2px dashed $link-color; - } - } - } - } - - & > .u-field { - &:not(:first-child) { - font-size: $body-font-size; - color: $body-color; - font-weight: $font-light; - margin-bottom: 0; - } - - &:first-child { - @extend %t-title4; - @extend %t-weight4; - - font-size: em(24); - } - } - - select { - width: 85%; - } - - .u-field-message { - @include right(0); - - position: absolute; - top: 0; - width: 20px; - - .icon { - vertical-align: baseline; - } - } - } - } - } - - - .wrapper-profile-section-container-two { - @include float(left); - @include padding-left($baseline); - - font-family: $font-family-sans-serif; - flex-grow: 1; - - @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap - width: 90%; - margin-top: $baseline; - padding: 0; - } - - .u-field-textarea { - @include padding(0, ($baseline*0.75), ($baseline*0.75), 0); - - margin-bottom: ($baseline/2); - - @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap - @include padding-left($baseline/4); - } - - .u-field-header { - position: relative; - - .u-field-message { - @include right(0); - - top: $baseline/4; - position: absolute; - } - } - - &.editable-toggle { - cursor: pointer; - } - } - - .u-field-title { - @extend %t-title6; - - display: inline-block; - margin-top: 0; - margin-bottom: ($baseline/4); - color: $gray-d3; - width: 100%; - font: $font-semibold 1.4em/1.4em $font-family-sans-serif; - } - - .u-field-value { - @extend %t-copy-base; - - width: 100%; - overflow: auto; - - textarea { - width: 100%; - background-color: transparent; - border-radius: 5px; - border-color: $gray-d1; - resize: none; - white-space: pre-line; - outline: 0; - box-shadow: none; - -webkit-appearance: none; - } - - a { - color: inherit; - } - } - - .u-field-message { - @include float(right); - - width: auto; - - .message-can-edit { - position: absolute; - } - } - - .u-field.mode-placeholder { - padding: $baseline; - margin: $baseline*0.75 0; - border: 2px dashed $gray-l3; - - i { - font-size: 12px; - - @include padding-right(5px); - - vertical-align: middle; - color: $body-color; - } - - .u-field-title { - width: 100%; - text-align: center; - } - - .u-field-value { - text-align: center; - line-height: 1.5em; - - @extend %t-copy-sub1; - - color: $body-color; - } - - &:hover { - border: 2px dashed $link-color; - - .u-field-title, - i { - color: $link-color; - } - } - } - - .wrapper-u-field { - font-size: $body-font-size; - color: $body-color; - - .u-field-header .u-field-title { - color: $body-color; - } - - .u-field-footer { - .field-textarea-character-count { - @extend %t-weight1; - - @include float(right); - - margin-top: $baseline/4; - } - } - } - - .profile-private-message { - @include padding-left($baseline*0.75); - - line-height: 3em; - } - } - - .badge-paging-header { - padding-top: $baseline; - } - - .page-content-nav { - @extend %page-content-nav; - } - - .badge-set-display { - @extend .container; - - padding: 0; - - .badge-list { - // We're using a div instead of ul for accessibility, so we have to match the style - // used by ul. - margin: 1em 0; - padding: 0 0 0 40px; - } - - .badge-display { - width: 50%; - display: inline-block; - vertical-align: top; - padding: 2em 0; - - .badge-image-container { - padding-right: $baseline; - margin-left: 1em; - width: 20%; - vertical-align: top; - display: inline-block; - - img.badge { - width: 100%; - } - - .accomplishment-placeholder { - border: 4px dotted $gray-l4; - border-radius: 50%; - display: block; - width: 100%; - padding-bottom: 100%; - } - } - - .badge-details { - @extend %t-copy-sub1; - @extend %t-regular; - - max-width: 70%; - display: inline-block; - color: $gray-d1; - - .badge-name { - @extend %t-strong; - @extend %t-copy-base; - - color: $gray-d3; - } - - .badge-description { - padding-bottom: $baseline; - line-height: 1.5em; - } - - .badge-date-stamp { - @extend %t-copy-sub1; - } - - .find-button-container { - border: 1px solid $blue-l1; - padding: ($baseline / 2) $baseline ($baseline / 2) $baseline; - display: inline-block; - border-radius: 5px; - font-weight: bold; - color: $blue-s3; - } - - .share-button { - @extend %t-action3; - @extend %button-reset; - - background: $gray-l6; - color: $gray-d1; - padding: ($baseline / 4) ($baseline / 2); - margin-bottom: ($baseline / 2); - display: inline-block; - border-radius: 5px; - border: 2px solid $gray-d1; - cursor: pointer; - transition: background 0.5s; - - .share-prefix { - display: inline-block; - vertical-align: middle; - } - - .share-icon-container { - display: inline-block; - - img.icon-mozillaopenbadges { - max-width: 1.5em; - margin-right: 0.25em; - } - } - - &:hover { - background: $gray-l4; - } - - &:active { - box-shadow: inset 0 4px 15px 0 $black-t2; - transition: none; - } - } - } - } - - .badge-placeholder { - background-color: $gray-l7; - box-shadow: inset 0 0 4px 0 $gray-l4; - } - } - - // ------------------------------ - // #BADGES MODAL - // ------------------------------ - .badges-overlay { - @extend %ui-depth1; - - position: fixed; - top: 0; - left: 0; - background-color: $dark-trans-bg; /* dim the background */ - width: 100%; - height: 100%; - vertical-align: middle; - - .badges-modal { - @extend %t-copy-lead1; - @extend %ui-depth2; - - color: $lighter-base-font-color; - box-sizing: content-box; - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 80%; - max-width: 700px; - max-height: calc(100% - 100px); - margin-right: auto; - margin-left: auto; - border-top: rem(10) solid $blue-l2; - background: $light-gray3; - padding-right: ($baseline * 2); - padding-left: ($baseline * 2); - padding-bottom: ($baseline); - overflow-x: hidden; - - .modal-header { - margin-top: ($baseline / 2); - margin-bottom: ($baseline / 2); - } - - .close { - @extend %button-reset; - @extend %t-strong; - - color: $lighter-base-font-color; - position: absolute; - right: ($baseline); - top: $baseline; - cursor: pointer; - padding: ($baseline / 4) ($baseline / 2); - - @include transition(all $tmg-f2 ease-in-out 0s); - - &:focus, - &:hover { - background-color: $blue-d2; - border-radius: 3px; - color: $white; - } - } - - .badges-steps { - display: table; - } - - .image-container { - // Lines the image up with the content of the above list. - @include ltr { - @include padding-left(2em); - } - - @include rtl { - @include padding-right(1em); - - float: right; - } - } - - .backpack-logo { - @include float(right); - @include margin-left($baseline); - } - } - } - - .modal-hr { - display: block; - border: none; - background-color: $light-gray; - height: rem(2); - width: 100%; - } -} diff --git a/lms/static/sass/partials/lms/theme/_variables-v1.scss b/lms/static/sass/partials/lms/theme/_variables-v1.scss index 1cff0168ac..5dca9b8495 100644 --- a/lms/static/sass/partials/lms/theme/_variables-v1.scss +++ b/lms/static/sass/partials/lms/theme/_variables-v1.scss @@ -527,9 +527,6 @@ $palette-success-border: #b9edb9; $palette-success-back: #ecfaec; $palette-success-text: #008100; -// learner profile elements -$learner-profile-container-flex: 768px; - // course elements $course-bg-color: $uxpl-grayscale-x-back !default; $account-content-wrapper-bg: shade($body-bg, 2%) !default; diff --git a/lms/static/sass/views/_account-settings.scss b/lms/static/sass/views/_account-settings.scss deleted file mode 100644 index a4e5ff76ea..0000000000 --- a/lms/static/sass/views/_account-settings.scss +++ /dev/null @@ -1,683 +0,0 @@ -// lms - application - account settings -// ==================== - -// Table of Contents -// * +Container - Account Settings -// * +Main - Header -// * +Settings Section -// * +Alert Messages - - -// +Container - Account Settings -.wrapper-account-settings { - background: $white; - width: 100%; - - .account-settings-container { - max-width: grid-width(12); - padding: 10px; - margin: 0 auto; - } - - .ui-loading-indicator, - .ui-loading-error { - @extend .ui-loading-base; - // center horizontally - @include margin-left(auto); - @include margin-right(auto); - - padding: ($baseline*3); - text-align: center; - - .message-error { - color: $alert-color; - } - } -} - -// +Main - Header -.wrapper-account-settings { - .wrapper-header { - max-width: grid-width(12); - height: 139px; - border-bottom: 4px solid $m-gray-l4; - - .header-title { - @extend %t-title4; - - margin-bottom: ($baseline/2); - padding-top: ($baseline*2); - } - - .header-subtitle { - color: $gray-l2; - } - - .account-nav { - @include float(left); - - margin: ($baseline/2) 0; - padding: 0; - list-style: none; - - .account-nav-link { - @include float(left); - - font-size: em(14); - color: $gray; - padding: $baseline/4 $baseline*1.25 $baseline; - display: inline-block; - box-shadow: none; - border-bottom: 4px solid transparent; - border-radius: 0; - background: transparent none; - } - - button { - @extend %ui-clear-button; - @extend %btn-no-style; - - @include appearance(none); - - display: block; - padding: ($baseline/4); - - &:hover, - &:focus { - text-decoration: none; - border-bottom-color: $courseware-border-bottom-color; - } - - &.active { - border-bottom-color: theme-color("dark"); - } - } - } - - @include media-breakpoint-down(md) { - border-bottom-color: transparent; - - .account-nav { - display: flex; - border-bottom: none; - - .account-nav-link { - border-bottom: 4px solid theme-color("light"); - } - } - } - } -} - -// +Settings Section -.account-settings-sections { - .section-header { - @extend %t-title5; - @extend %t-strong; - - padding-top: ($baseline/2)*3; - color: $dark-gray1; - } - - .section { - background-color: $white; - margin: $baseline 5% 0; - border-bottom: 4px solid $m-gray-l4; - - .account-settings-header-subtitle { - font-size: em(14); - line-height: normal; - color: $dark-gray; - padding-bottom: 10px; - } - - .account-settings-header-subtitle-warning { - @extend .account-settings-header-subtitle; - - color: $alert-color; - } - - .account-settings-section-body { - .u-field { - border-bottom: 2px solid $m-gray-l4; - padding: $baseline*0.75 0; - - .field { - width: 30%; - vertical-align: top; - display: inline-block; - position: relative; - - select { - @include appearance(none); - - padding: 14px 30px 14px 15px; - border: 1px solid $gray58-border; - background-color: transparent; - border-radius: 2px; - position: relative; - z-index: 10; - - &::-ms-expand { - display: none; - } - - ~ .icon-caret-down { - &::after { - content: ""; - border-left: 6px solid transparent; - border-right: 6px solid transparent; - border-top: 7px solid $blue; - position: absolute; - right: 10px; - bottom: 20px; - z-index: 0; - } - } - } - - .field-label { - display: block; - width: auto; - margin-bottom: 0.625rem; - font-size: 1rem; - line-height: 1; - color: $dark-gray; - white-space: nowrap; - } - - .field-input { - @include transition(all 0.125s ease-in-out 0s); - - display: inline-block; - padding: 0.625rem; - border: 1px solid $gray58-border; - border-radius: 2px; - background: $white; - font-size: $body-font-size; - color: $dark-gray; - width: 100%; - height: 48px; - box-shadow: none; - } - - .u-field-link { - @extend %ui-clear-button; - - // set styles - @extend %btn-pl-default-base; - - @include font-size(18); - - width: 100%; - border: 1px solid $blue; - color: $blue; - padding: 11px 14px; - line-height: normal; - } - } - - .u-field-order { - display: flex; - align-items: center; - font-size: em(16); - font-weight: 600; - color: $dark-gray; - width: 100%; - padding-top: $baseline; - padding-bottom: $baseline; - line-height: normal; - flex-flow: row wrap; - - span { - padding: $baseline; - } - - .u-field-order-number { - @include float(left); - - width: 30%; - } - - .u-field-order-date { - @include float(left); - - padding-left: 30px; - width: 20%; - } - - .u-field-order-price { - @include float(left); - - width: 15%; - } - - .u-field-order-link { - width: 10%; - padding: 0; - - .u-field-link { - @extend %ui-clear-button; - @extend %btn-pl-default-base; - - @include font-size(14); - - border: 1px solid $blue; - color: $blue; - line-height: normal; - padding: 10px; - width: 110px; - } - } - } - - .u-field-order-lines { - @extend .u-field-order; - - padding: 5px 0 0; - font-weight: 100; - - .u-field-order-number { - padding: 20px 10px 20px 30px; - } - } - - .social-field-linked { - background: $m-gray-l4; - box-shadow: 0 1px 2px 1px $shadow-l2; - padding: 1.25rem; - box-sizing: border-box; - margin: 10px; - width: 100%; - - .field-label { - @include font-size(24); - } - - .u-field-social-help { - display: inline-block; - padding: 20px 0 6px; - } - - .u-field-link { - @include font-size(14); - @include text-align(left); - - border: none; - margin-top: $baseline; - font-weight: $font-semibold; - padding: 0; - - &:focus, - &:hover, - &:active { - background-color: transparent; - color: $m-blue-d3; - border: none; - } - } - } - - .social-field-unlinked { - background: $m-gray-l4; - box-shadow: 0 1px 2px 1px $shadow-l2; - padding: 1.25rem; - box-sizing: border-box; - text-align: center; - margin: 10px; - width: 100%; - - .field-label { - @include font-size(24); - - text-align: center; - } - - .u-field-link { - @include font-size(14); - - margin-top: $baseline; - font-weight: $font-semibold; - } - } - - .u-field-message { - position: relative; - padding: $baseline*0.75 0 0 ($baseline*4); - width: 60%; - - .u-field-message-notification { - position: absolute; - left: 0; - top: 0; - bottom: 0; - margin: auto; - padding: 38px 0 0 ($baseline*5); - } - } - - &:last-child { - border-bottom: none; - margin-bottom: ($baseline*2); - } - - // Responsive behavior - @include media-breakpoint-down(md) { - .u-field-value { - width: 100%; - } - - .u-field-message { - width: 100%; - padding: $baseline/2 0; - - .u-field-message-notification { - position: relative; - padding: 0; - } - } - - .u-field-order { - display: flex; - flex-wrap: nowrap; - - .u-field-order-number, - .u-field-order-date, - .u-field-order-price, - .u-field-order-link { - width: auto; - float: none; - flex-grow: 1; - - &:first-of-type { - flex-grow: 2; - } - } - } - } - } - - .u-field { - &.u-field-dropdown, - &.editable-never &.mode-display { - .u-field-value { - margin-bottom: ($baseline); - - .u-field-title { - font-size: 16px; - line-height: 22px; - margin-bottom: 18px; - } - - .u-field-value-readonly { - font-size: 22px; - color: #636c72; - line-height: 30px; - white-space: nowrap; - } - } - } - } - - .u-field-readonly .u-field-title { - font-size: 16px; - color: #636c72; - line-height: 22px; - padding-top: ($baseline/2); - padding-bottom: 0; - margin-bottom: 8px !important; - } - - .u-field-readonly .u-field-value { - font-size: 22px; - color: #636c72; - line-height: 30px; - padding-top: 8px; - padding-bottom: ($baseline); - white-space: nowrap; - } - - .u-field-orderHistory { - border-bottom: none; - border: 1px solid $m-gray-l4; - margin-bottom: $baseline; - padding: 0; - - &:last-child { - border-bottom: 1px solid $m-gray-l4; - } - - &:hover, - &:focus { - background-color: $light-gray4; - } - } - - .u-field-order-orderId { - border: none; - margin-top: $baseline; - margin-bottom: 0; - padding-bottom: 0; - - &:hover, - &:focus { - background-color: transparent; - } - - .u-field-order { - font-weight: $font-semibold; - padding-top: 0; - padding-bottom: 0; - - .u-field-order-title { - font-size: em(16); - } - } - } - - .u-field-social { - border-bottom: none; - margin-right: 20px; - width: 30%; - display: inline-block; - vertical-align: top; - - .u-field-social-help { - @include font-size(12); - - color: $m-gray-d1; - } - } - } - - .account-deletion-details { - .btn-outline-primary { - @extend %ui-clear-button; - - // set styles - @extend %btn-pl-default-base; - - @include font-size(18); - - border: 1px solid $blue; - color: $blue; - padding: 11px 14px; - line-height: normal; - margin: 20px 0; - } - - .paragon__modal-open { - overflow-y: scroll; - color: $dark-gray; - - .paragon__modal-title { - font-weight: $font-semibold; - } - - .paragon__modal-body { - line-height: 1.5; - - .alert-title { - line-height: 1.5; - } - } - - .paragon__alert-warning { - color: $dark-gray; - } - - .next-steps { - margin-bottom: 10px; - font-weight: $font-semibold; - } - - .confirm-password-input { - width: 50%; - } - - .paragon__btn:not(.cancel-btn) { - @extend %btn-primary-blue; - } - } - - .modal-alert { - display: flex; - - .icon-wrapper { - padding-right: 15px; - } - - .alert-content { - .alert-title { - color: $dark-gray; - margin-bottom: 10px; - font: { - size: 1rem; - weight: $font-semibold; - } - } - - a { - color: $blue-u1; - } - } - } - - .delete-confirmation-wrapper { - .paragon__modal-footer { - .paragon__btn-outline-primary { - @extend %ui-clear-button; - - // set styles - @extend %btn-pl-default-base; - - @include margin-left(25px); - - border-color: $blue; - color: $blue; - padding: 11px 14px; - line-height: normal; - } - } - } - } - - &:last-child { - border-bottom: none; - } - } -} - -// * +Alert Messages -.account-settings-message, -.account-settings-section-message { - font-size: 16px; - line-height: 22px; - margin-top: 15px; - margin-bottom: 30px; - - .alert-message { - color: #292b2c; - font-family: $font-family-sans-serif; - position: relative; - padding: 10px 10px 10px 35px; - border: 1px solid transparent; - border-radius: 0; - box-shadow: none; - margin-bottom: 8px; - - & > .fa { - position: absolute; - left: 11px; - top: 13px; - font-size: 16px; - } - - span { - display: block; - - a { - text-decoration: underline; - } - } - } - - .success { - background-color: #ecfaec; - border-color: #b9edb9; - } - - .info { - background-color: #d8edf8; - border-color: #bbdff2; - } - - .warning { - background-color: #fcf8e3; - border-color: #faebcc; - } - - .error { - background-color: #f2dede; - border-color: #ebccd1; - } -} - -.account-settings-message { - margin-bottom: 0; - - .alert-message { - padding: 10px; - - .alert-actions { - margin-top: 10px; - - .btn-alert-primary { - @extend %btn-primary-blue; - - @include font-size(18); - - border: 1px solid $m-blue-d3; - border-radius: 3px; - box-shadow: none; - padding: 11px 14px; - line-height: normal; - } - - .btn-alert-secondary { - @extend %ui-clear-button; - - // set styles - @extend %btn-pl-default-base; - - @include font-size(18); - - background-color: white; - border: 1px solid $blue; - color: $blue; - padding: 11px 14px; - line-height: normal; - } - } - } -} diff --git a/lms/templates/calculator/toggle_calculator.html b/lms/templates/calculator/toggle_calculator.html index d4a2401fee..b37d10fa3e 100644 --- a/lms/templates/calculator/toggle_calculator.html +++ b/lms/templates/calculator/toggle_calculator.html @@ -24,10 +24,10 @@ from openedx.core.djangolib.markup import HTML, Text

    ${_('Use the arrow keys to navigate the tips or use the tab key to return to the calculator')}

    - ${Text(_("For detailed information, see {math_link_start}Entering Mathematical and Scientific Expressions{math_link_end} in the {guide_link_start}edX Guide for Students{guide_link_end}.")).format( - math_link_start=HTML(''), + ${Text(_("For detailed information, see {math_link_start}Entering Mathematical and Scientific Expressions{math_link_end} in the {guide_link_start}Open edX Guide for Students{guide_link_end}.")).format( + math_link_start=HTML(''), math_link_end=HTML(''), - guide_link_start=HTML(''), + guide_link_start=HTML(''), guide_link_end=HTML(''), )} diff --git a/lms/templates/certificates/_accomplishment-rendering.html b/lms/templates/certificates/_accomplishment-rendering.html index 7c5ae957f3..999c7a39f4 100644 --- a/lms/templates/certificates/_accomplishment-rendering.html +++ b/lms/templates/certificates/_accomplishment-rendering.html @@ -46,17 +46,16 @@ course_mode_class = course_mode if course_mode else ''

    % if certificate_data: % for signatory in certificate_data.get('signatories', []): - % if signatory.get('signature_image_path'):
    - ${signatory['name']} - + % if signatory.get('signature_image_path'): + ${signatory['name']} + % endif

    ${signatory['name']}

    ${signatory['title']} ${signatory['organization']}

    - % endif % endfor % endif
    diff --git a/lms/templates/certificates/_edx-accomplishment-print-help.html b/lms/templates/certificates/_edx-accomplishment-print-help.html index eae27d96b0..1e6a0d2279 100644 --- a/lms/templates/certificates/_edx-accomplishment-print-help.html +++ b/lms/templates/certificates/_edx-accomplishment-print-help.html @@ -8,7 +8,7 @@ from openedx.core.djangolib.markup import HTML, Text
    diff --git a/lms/templates/course_modes/_upgrade_button.html b/lms/templates/course_modes/_upgrade_button.html deleted file mode 100644 index 02ff82c101..0000000000 --- a/lms/templates/course_modes/_upgrade_button.html +++ /dev/null @@ -1,36 +0,0 @@ -<%page args="content_gating_enabled, course_duration_limit_enabled, min_price, price_before_discount" expression_filter="h"/> - -<%! -from django.utils.translation import gettext as _ -from openedx.core.djangolib.markup import HTML, Text -%> - -<%namespace name='static' file='../static_content.html'/> - -
  • - - % if content_gating_enabled or course_duration_limit_enabled: - -
  • - -<%static:require_module_async module_name="js/commerce/track_ecommerce_events" class_name="TrackECommerceEvents"> -var upgradeLink = $("#track_selection_upgrade"); - -TrackECommerceEvents.trackUpsellClick(upgradeLink, 'track_selection', { - pageName: "track_selection", - linkType: "button", - linkCategory: "(none)" -}); - - \ No newline at end of file diff --git a/lms/templates/course_modes/choose.html b/lms/templates/course_modes/choose.html deleted file mode 100644 index 78b6dcc26e..0000000000 --- a/lms/templates/course_modes/choose.html +++ /dev/null @@ -1,233 +0,0 @@ -<%page expression_filter="h"/> -<%inherit file="../main.html" /> -<%! -from django.utils.translation import gettext as _ -from django.urls import reverse -from openedx.core.djangolib.js_utils import js_escaped_string -from openedx.core.djangolib.markup import HTML, Text -%> - -<%block name="bodyclass">register verification-process step-select-track -<%block name="pagetitle"> - ${_("Enroll In {course_name} | Choose Your Track").format(course_name=course_name)} - - -<%block name="js_extra"> - - - -<%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/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index eec9caeadb..e4f78e2afc 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -14,9 +14,20 @@ from openedx.core.lib.courses import course_image_url <%inherit file="../main.html" /> <%block name="headextra"> - ## OG (Open Graph) title and description added below to give social media info to display + <% + site_domain = static.get_value('site_domain', settings.SITE_NAME) + site_protocol = 'https' if settings.HTTPS == 'on' else 'http' + + og_img_url = "{protocol}://{domain}{path}".format( + protocol=site_protocol, + domain=site_domain, + path=course_image_urls['large'] + ) + %> + ## OG (Open Graph) title, image and description added below to give social media info to display ## (https://developers.facebook.com/docs/opengraph/howtos/maximizing-distribution-media-content#tags) + diff --git a/lms/templates/courseware/courseware.html b/lms/templates/courseware/courseware.html index eb94fab17c..ce9dd19b29 100644 --- a/lms/templates/courseware/courseware.html +++ b/lms/templates/courseware/courseware.html @@ -290,7 +290,6 @@ ${HTML(fragment.foot_html())}
    - ${HTML(course_sock_fragment.body_html())}
    % else: diff --git a/lms/templates/vert_module.html b/lms/templates/vert_module.html index 7df5b8b5b4..909e7db829 100644 --- a/lms/templates/vert_module.html +++ b/lms/templates/vert_module.html @@ -62,7 +62,7 @@ from openedx.core.djangolib.markup import HTML
    % for idx, item in enumerate(items): % if item['content']: -
    +
    ${HTML(item['content'])}
    %endif diff --git a/lms/templates/video.html b/lms/templates/video.html index 1d1a374f63..ae984f3197 100644 --- a/lms/templates/video.html +++ b/lms/templates/video.html @@ -57,7 +57,7 @@ from openedx.core.djangolib.js_utils import (

    ${_('Video')}

    % if download_video_link: - + ${_('Download video file')} % endif @@ -83,21 +83,33 @@ from openedx.core.djangolib.js_utils import (
    -
    % for sharing_site_info in sharing_sites_info: - + % endfor -
    diff --git a/lms/urls.py b/lms/urls.py index f97bc7f0d0..d6884690d0 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -192,12 +192,12 @@ urlpatterns = [ path('api-admin/', include(('openedx.core.djangoapps.api_admin.urls', 'openedx.core.djangoapps.api_admin'), namespace='api_admin')), - # Learner Dashboard - path('dashboard/', include('lms.djangoapps.learner_dashboard.urls')), - path('api/dashboard/', include('lms.djangoapps.learner_dashboard.api.urls', namespace='dashboard_api')), - - # Learner Home + # Learner Home and Program Dashboard path('api/learner_home/', include('lms.djangoapps.learner_home.urls', namespace='learner_home')), + path('dashboard/', include('lms.djangoapps.learner_dashboard.urls')), + # This is the legacy URL for the program dashboard API when the legacy learner dashboard existed. + # Current-and-future advertised URLs for this API will be under 'api/learner_home' + path('api/dashboard/', include('openedx.core.djangoapps.programs.rest_api.urls', namespace='dashboard_api')), path( 'api/experiments/', @@ -658,12 +658,6 @@ urlpatterns += [ include('openedx.features.calendar_sync.urls'), ), - # Learner profile - path( - 'u/', - include('openedx.features.learner_profile.urls'), - ), - # Survey Report re_path( fr'^survey_report/', @@ -872,6 +866,7 @@ if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): if enterprise_enabled(): urlpatterns += [ path('', include('enterprise.urls')), + path('', include('channel_integrations.urls')), ] # OAuth token exchange diff --git a/lms/wsgi.py b/lms/wsgi.py index 169b6929fb..0321c827cc 100644 --- a/lms/wsgi.py +++ b/lms/wsgi.py @@ -15,9 +15,6 @@ defuse_xml_libs() import os # lint-amnesty, pylint: disable=wrong-import-order, wrong-import-position os.environ.setdefault("DJANGO_SETTINGS_MODULE", "lms.envs.aws") -import lms.startup as startup # lint-amnesty, pylint: disable=wrong-import-position -startup.run() - # This application object is used by the development server # as well as any WSGI server configured to use this file. from django.core.wsgi import get_wsgi_application # lint-amnesty, pylint: disable=wrong-import-order, wrong-import-position diff --git a/lms/wsgi_apache_lms.py b/lms/wsgi_apache_lms.py index e40be055d4..d1c6013e2b 100644 --- a/lms/wsgi_apache_lms.py +++ b/lms/wsgi_apache_lms.py @@ -14,9 +14,6 @@ import os # lint-amnesty, pylint: disable=wrong-import-order, wrong-import-posi os.environ.setdefault("DJANGO_SETTINGS_MODULE", "lms.envs.aws") os.environ.setdefault("SERVICE_VARIANT", "lms") -import lms.startup as startup # lint-amnesty, pylint: disable=wrong-import-position -startup.run() - # This application object is used by the development server # as well as any WSGI server configured to use this file. from django.core.wsgi import get_wsgi_application # lint-amnesty, pylint: disable=wrong-import-order, wrong-import-position diff --git a/manage.py b/manage.py index 98bcc40717..9b8518937d 100755 --- a/manage.py +++ b/manage.py @@ -12,7 +12,6 @@ Any arguments not understood by this manage.py will be passed to django-admin.py """ # pylint: disable=wrong-import-order, wrong-import-position - from openedx.core.lib.logsettings import log_python_warnings log_python_warnings() @@ -20,7 +19,6 @@ log_python_warnings() from openedx.core.lib.safe_lxml import defuse_xml_libs # isort:skip defuse_xml_libs() -import importlib import os import sys from argparse import ArgumentParser @@ -41,7 +39,7 @@ def parse_args(): lms.add_argument( '--settings', help="Which django settings module to use under lms.envs. If not provided, the DJANGO_SETTINGS_MODULE " - "environment variable will be used if it is set, otherwise it will default to lms.envs.devstack_docker") + "environment variable will be used if it is set, otherwise it will default to lms.envs.devstack") lms.add_argument( '--service-variant', choices=['lms', 'lms-xml', 'lms-preview'], @@ -50,8 +48,7 @@ def parse_args(): lms.set_defaults( help_string=lms.format_help(), settings_base='lms/envs', - default_settings='lms.envs.devstack_docker', - startup='lms.startup', + default_settings='lms.envs.devstack', ) cms = subparsers.add_parser( @@ -63,14 +60,13 @@ def parse_args(): cms.add_argument( '--settings', help="Which django settings module to use under cms.envs. If not provided, the DJANGO_SETTINGS_MODULE " - "environment variable will be used if it is set, otherwise it will default to cms.envs.devstack_docker") + "environment variable will be used if it is set, otherwise it will default to cms.envs.devstack") cms.add_argument('-h', '--help', action='store_true', help='show this help message and exit') cms.set_defaults( help_string=cms.format_help(), settings_base='cms/envs', - default_settings='cms.envs.devstack_docker', + default_settings='cms.envs.devstack', service_variant='cms', - startup='cms.startup', ) edx_args, django_args = parser.parse_known_args() @@ -99,8 +95,5 @@ if __name__ == "__main__": # This will trigger django-admin.py to print out its help django_args.append('--help') - startup = importlib.import_module(edx_args.startup) - startup.run() - from django.core.management import execute_from_command_line execute_from_command_line([sys.argv[0]] + django_args) diff --git a/mypy.ini b/mypy.ini index c0d739e846..f0267364ff 100644 --- a/mypy.ini +++ b/mypy.ini @@ -7,11 +7,17 @@ plugins = mypy_drf_plugin.main files = cms/lib/xblock/upstream_sync.py, + cms/lib/xblock/upstream_sync_container.py, cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py, + cms/djangoapps/import_from_modulestore, openedx/core/djangoapps/content/learning_sequences, + # FIXME: need to solve type issues and add 'search' app here: + # openedx/core/djangoapps/content/search, openedx/core/djangoapps/content_staging, openedx/core/djangoapps/content_libraries, + openedx/core/djangoapps/programs/rest_api, openedx/core/djangoapps/xblock, + openedx/core/lib/derived.py, openedx/core/types, openedx/core/djangoapps/content_tagging, xmodule/util/keys.py, diff --git a/openedx/README.rst b/openedx/README.rst index ced9d71fc8..62cb82c01a 100644 --- a/openedx/README.rst +++ b/openedx/README.rst @@ -1,15 +1,8 @@ -Open edX --------- +openedx +------- -This is the root package for Open edX. The intent is that all importable code -from Open edX will eventually live here, including the code in the lms, cms, -and common directories. +This directory (openedx) should contain code that is used by both `LMS `_ and `CMS `_. If your code is specific to LMS or CMS, put it in those directories instead. -If you're adding a new Django app, place it in core/djangoapps. If you're adding -utilities that require Django, place them in core/djangolib. If you're adding -code that defines no Django models or views of its own but is widely useful, put it -in core/lib. +Like openedx, the directory `common `_ also contains code used by both LMS and CMS. At some point we'll merge the two. -Note: All new code should be created in this package, and the legacy code will -be moved here gradually. For now the code is not structured like this, and hence -legacy code will continue to live in a number of different packages. +Lastly, the directory `xmodule `_ contains legacy core code, also used by both LMS and CMS. We're in the middle of a long process of phasing that code out. Don't add new code there. diff --git a/openedx/core/constants.py b/openedx/core/constants.py index ad16e9e595..e6a99b2c1e 100644 --- a/openedx/core/constants.py +++ b/openedx/core/constants.py @@ -12,3 +12,8 @@ COURSE_ID_PATTERN = COURSE_KEY_PATTERN.replace('course_key_string', 'course_id') COURSE_KEY_REGEX = COURSE_KEY_PATTERN.replace('P', ':') COURSE_PUBLISHED = 'published' COURSE_UNPUBLISHED = 'unpublished' + +ASSET_KEY_PATTERN = r'(?P(?:/?c4x(:/)?/[^/]+/[^/]+/[^/]+/[^@]+(?:@[^/]+)?)|(?:[^/]+))' + +USAGE_KEY_PATTERN = r'(?P(?:i4x://?[^/]+/[^/]+/[^/]+/[^@]+(?:@[^/]+)?)|(?:[^/]+))' +USAGE_ID_PATTERN = USAGE_KEY_PATTERN.replace('usage_key_string', 'usage_id') diff --git a/openedx/core/djangoapps/ace_common/policies.py b/openedx/core/djangoapps/ace_common/policies.py new file mode 100644 index 0000000000..11dc75b1e6 --- /dev/null +++ b/openedx/core/djangoapps/ace_common/policies.py @@ -0,0 +1,33 @@ +"""Disable User Email OptOut Policy""" + +import logging + +from django.contrib.auth import get_user_model +from edx_ace.channel import ChannelType +from edx_ace.policy import Policy, PolicyResult + + +User = get_user_model() +log = logging.getLogger(__name__) + + +class DisableUserOptout(Policy): + """ + Skips sending ace messages to disabled users + """ + def check(self, message): + """ + Checks if the user is disabled and if so, skips sending the message + """ + skip_disable_user_policy = message.options.get('skip_disable_user_policy', False) + if skip_disable_user_policy: + return PolicyResult(deny=set()) + try: + user = User.objects.get(id=message.recipient.lms_user_id) + except User.DoesNotExist: + log.info(f"Disable User Policy - User not found - {message.recipient.lms_user_id} - {message.name}") + return PolicyResult(deny=set()) + if user.has_usable_password(): + return PolicyResult(deny=set()) + log.info(f"===> User is disabled - {user.email} - {message.name}") + return PolicyResult(deny=set(ChannelType)) diff --git a/openedx/core/djangoapps/cache_toolbox/tests/test_middleware.py b/openedx/core/djangoapps/cache_toolbox/tests/test_middleware.py index c324cf6c52..42c078a793 100644 --- a/openedx/core/djangoapps/cache_toolbox/tests/test_middleware.py +++ b/openedx/core/djangoapps/cache_toolbox/tests/test_middleware.py @@ -27,32 +27,6 @@ class CachedAuthMiddlewareTestCase(TestCase): self.client.response = HttpResponse() self.client.response.cookies = SimpleCookie() # preparing cookies - def _test_change_session_hash(self, test_url, redirect_url, target_status_code=200): - """ - Verify that if a user's session auth hash and the request's hash - differ, the user is logged out. The URL to test and the - expected redirect are passed in, since we want to test this - behavior in both LMS and CMS, but the two systems have - different URLconfs. - """ - response = self.client.get(test_url) - assert response.status_code == 200 - - with patch( - "openedx.core.djangoapps.cache_toolbox.middleware.set_custom_attribute" - ) as mock_set_custom_attribute: - with patch.object(User, 'get_session_auth_hash', return_value='abc123', autospec=True): - # Django 3.2 has _legacy_get_session_auth_hash, and Django 4 does not - # Remove once we reach Django 4 - if hasattr(User, '_legacy_get_session_auth_hash'): - with patch.object(User, '_legacy_get_session_auth_hash', return_value='abc123'): - response = self.client.get(test_url) - else: - response = self.client.get(test_url) - - self.assertRedirects(response, redirect_url, target_status_code=target_status_code) - mock_set_custom_attribute.assert_any_call('failed_session_verification', True) - def _test_custom_attribute_after_changing_hash(self, test_url, mock_set_custom_attribute): """verify that set_custom_attribute is called with expected values""" password = 'test-password' @@ -102,16 +76,44 @@ class CachedAuthMiddlewareTestCase(TestCase): @skip_unless_lms def test_session_change_lms(self): - """Test session verification with LMS-specific URLs.""" + """ + Verify (from the LMS side) that if a user's session auth hash and the request's + hash differ, the user is logged out. + """ dashboard_url = reverse('dashboard') - self._test_change_session_hash(dashboard_url, reverse('signin_user') + '?next=' + dashboard_url) + response = self.client.get(dashboard_url) + assert response.status_code == 200 + + with patch( + "openedx.core.djangoapps.cache_toolbox.middleware.set_custom_attribute" + ) as mock_set_custom_attribute: + with patch.object(User, 'get_session_auth_hash', return_value='abc123', autospec=True): + response = self.client.get(dashboard_url) + + redirect_url = reverse('signin_user') + '?next=' + dashboard_url + self.assertRedirects(response, redirect_url, target_status_code=200) + mock_set_custom_attribute.assert_any_call('failed_session_verification', True) @skip_unless_cms def test_session_change_cms(self): - """Test session verification with CMS-specific URLs.""" + """ + Verify (from the CMS side) that if a user's session auth hash and the request's + hash differ, the user is logged out. + """ home_url = reverse('home') - # Studio login redirects to LMS login - self._test_change_session_hash(home_url, settings.LOGIN_URL + '?next=' + home_url, target_status_code=302) + response = self.client.get(home_url) + assert response.status_code == 302 + assert response.url == "http://course-authoring-mfe/home" + + with patch( + "openedx.core.djangoapps.cache_toolbox.middleware.set_custom_attribute" + ) as mock_set_custom_attribute: + with patch.object(User, 'get_session_auth_hash', return_value='abc123', autospec=True): + response = self.client.get(home_url) + + redirect_url = settings.LOGIN_URL + '?next=' + home_url + self.assertRedirects(response, redirect_url, target_status_code=302) + mock_set_custom_attribute.assert_any_call('failed_session_verification', True) @skip_unless_lms @patch("openedx.core.djangoapps.cache_toolbox.middleware.set_custom_attribute") diff --git a/openedx/core/djangoapps/catalog/management/commands/cache_programs.py b/openedx/core/djangoapps/catalog/management/commands/cache_programs.py index 9e0664f493..4af05da892 100644 --- a/openedx/core/djangoapps/catalog/management/commands/cache_programs.py +++ b/openedx/core/djangoapps/catalog/management/commands/cache_programs.py @@ -200,7 +200,7 @@ class Command(BaseCommand): failure = False logger.info(f'Requesting pathways for {site.domain}.') try: - api_url = urljoin(f"{api_base_url}/", "pathways/") + api_url = urljoin(f"{api_base_url}/", "pathways/?status=published") next_page = 1 while next_page: response = client.get(api_url, params=dict(exclude_utm=1, page=next_page)) diff --git a/openedx/core/djangoapps/catalog/management/commands/tests/test_cache_programs.py b/openedx/core/djangoapps/catalog/management/commands/tests/test_cache_programs.py index 4078b2d90d..1edd52d3c6 100644 --- a/openedx/core/djangoapps/catalog/management/commands/tests/test_cache_programs.py +++ b/openedx/core/djangoapps/catalog/management/commands/tests/test_cache_programs.py @@ -142,6 +142,7 @@ class TestCachePrograms(CatalogIntegrationMixin, CacheIsolationTestCase, SiteMix expected = { 'exclude_utm': ['1'], 'page': [str(page_number)], + 'status': ['published'] } assert request.querystring == expected @@ -156,7 +157,7 @@ class TestCachePrograms(CatalogIntegrationMixin, CacheIsolationTestCase, SiteMix httpretty.register_uri( httpretty.GET, - self.pathway_url + f'?exclude_utm=1&page={page_number}', + self.pathway_url + f'?status=published&exclude_utm=1&page={page_number}', body=pathways_callback, content_type='application/json', match_querystring=True, diff --git a/openedx/core/djangoapps/catalog/models.py b/openedx/core/djangoapps/catalog/models.py index e44a7839b3..98a012e3c3 100644 --- a/openedx/core/djangoapps/catalog/models.py +++ b/openedx/core/djangoapps/catalog/models.py @@ -1,6 +1,4 @@ """Models governing integration with the catalog service.""" - - from config_models.models import ConfigurationModel from django.conf import settings from django.contrib.auth import get_user_model @@ -9,6 +7,8 @@ from django.utils.translation import gettext_lazy as _ from openedx.core.djangoapps.site_configuration import helpers +User = get_user_model() + class CatalogIntegration(ConfigurationModel): """ @@ -72,7 +72,4 @@ class CatalogIntegration(ConfigurationModel): return helpers.get_value('COURSE_CATALOG_API_URL', settings.COURSE_CATALOG_API_URL) def get_service_user(self): - # NOTE: We load the user model here to avoid issues at startup time that result from the hacks - # in lms/startup.py. - User = get_user_model() # pylint: disable=invalid-name return User.objects.get(username=self.service_username) diff --git a/openedx/core/djangoapps/catalog/tests/factories.py b/openedx/core/djangoapps/catalog/tests/factories.py index e5605bc329..5c06f08d6e 100644 --- a/openedx/core/djangoapps/catalog/tests/factories.py +++ b/openedx/core/djangoapps/catalog/tests/factories.py @@ -290,3 +290,4 @@ class PathwayFactory(DictFactoryBase): org_name = factory.Faker('company') programs = factory.LazyFunction(partial(generate_instances, ProgramFactory)) pathway_type = FuzzyChoice(path_type.value for path_type in PathwayType) + status = 'published' diff --git a/openedx/core/djangoapps/content/block_structure/models.py b/openedx/core/djangoapps/content/block_structure/models.py index c2837e1a77..b3a8439ca1 100644 --- a/openedx/core/djangoapps/content/block_structure/models.py +++ b/openedx/core/djangoapps/content/block_structure/models.py @@ -41,8 +41,7 @@ def _directory_name(data_usage_key): # .. setting_description: Specifies the path in storage where block structures would be saved, # for storage-backed block structure cache. # For more information, check https://github.com/openedx/edx-platform/pull/14571. - # .. setting_warnings: Depends on `BLOCK_STRUCTURES_SETTINGS['STORAGE_CLASS']` and on - # `block_structure.storage_backing_for_cache`. + # .. setting_warnings: Depends on `BLOCK_STRUCTURES_SETTINGS['STORAGE_CLASS']` directory_prefix = settings.BLOCK_STRUCTURES_SETTINGS.get('DIRECTORY_PREFIX', '') # replace any '/' in the usage key so they aren't interpreted diff --git a/openedx/core/djangoapps/content/block_structure/store.py b/openedx/core/djangoapps/content/block_structure/store.py index 37a2b57449..bb6359b9d3 100644 --- a/openedx/core/djangoapps/content/block_structure/store.py +++ b/openedx/core/djangoapps/content/block_structure/store.py @@ -16,6 +16,8 @@ from .factory import BlockStructureFactory from .models import BlockStructureModel from .transformer_registry import TransformerRegistry +from edx_django_utils import monitoring + logger = getLogger(__name__) # pylint: disable=C0103 @@ -134,8 +136,17 @@ class BlockStructureStore: to the cache. """ cache_key = self._encode_root_cache_key(bs_model) - self._cache.set(cache_key, serialized_data, timeout=config.cache_timeout_in_seconds()) - logger.info("BlockStructure: Added to cache; %s, size: %d", bs_model, len(serialized_data)) + total_bytes_in_one_mb = 1024 * 1024 + data_size_in_bytes = len(serialized_data) + data_size_in_mbs = round(data_size_in_bytes / total_bytes_in_one_mb, 2) + if data_size_in_bytes < total_bytes_in_one_mb * 2: + self._cache.set(cache_key, serialized_data, timeout=config.cache_timeout_in_seconds()) + logger.info("BlockStructure: Added to cache; %s, size: %.2fMB", bs_model, data_size_in_mbs) + else: + # .. custom_attribute_name: blockstorestructure_size_in_mbs + # .. custom_attribute_description: contains the data chunk size in MBs. The size on which + # the memcached client failed to store value in cache. + monitoring.set_custom_attribute('blockstorestructure_size_in_mbs', data_size_in_mbs) def _get_from_cache(self, bs_model): """ diff --git a/openedx/core/djangoapps/content/learning_sequences/migrations/0017_rename_coursesection_course_context_ordering_course_context_ordering_idx.py b/openedx/core/djangoapps/content/learning_sequences/migrations/0017_rename_coursesection_course_context_ordering_course_context_ordering_idx.py new file mode 100644 index 0000000000..707ac31030 --- /dev/null +++ b/openedx/core/djangoapps/content/learning_sequences/migrations/0017_rename_coursesection_course_context_ordering_course_context_ordering_idx.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.20 on 2025-05-09 14:28 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('learning_sequences', '0016_through_model_for_user_partition_groups_2'), + ] + + operations = [ + migrations.RenameIndex( + model_name='coursesection', + new_name='course_context_ordering_idx', + old_fields=('course_context', 'ordering'), + ), + ] diff --git a/openedx/core/djangoapps/content/learning_sequences/models.py b/openedx/core/djangoapps/content/learning_sequences/models.py index 5d2ee34bbc..877ee084a5 100644 --- a/openedx/core/djangoapps/content/learning_sequences/models.py +++ b/openedx/core/djangoapps/content/learning_sequences/models.py @@ -37,6 +37,8 @@ that separated. yourself to the LearningContext and LearningSequence models. Other tables are not guaranteed to stick around, and values may be deleted unexpectedly. """ +from __future__ import annotations + from django.db import models from model_utils.models import TimeStampedModel @@ -214,7 +216,7 @@ class CourseSection(CourseContentVisibilityMixin, TimeStampedModel): # What is our position within the Course? (starts with 0) ordering = models.PositiveIntegerField(null=False) - new_user_partition_groups = models.ManyToManyField( + new_user_partition_groups: models.ManyToManyField[UserPartitionGroup, models.Model] = models.ManyToManyField( UserPartitionGroup, db_index=True, related_name='sec_user_partition_groups', @@ -226,8 +228,8 @@ class CourseSection(CourseContentVisibilityMixin, TimeStampedModel): unique_together = [ ['course_context', 'usage_key'], ] - index_together = [ - ['course_context', 'ordering'], + indexes = [ + models.Index(fields=['course_context', 'ordering'], name='course_context_ordering_idx'), ] @@ -280,7 +282,7 @@ class CourseSectionSequence(CourseContentVisibilityMixin, TimeStampedModel): # sequences across 20 sections, the numbering here would be 0-199. ordering = models.PositiveIntegerField(null=False) - new_user_partition_groups = models.ManyToManyField( + new_user_partition_groups: models.ManyToManyField[UserPartitionGroup, models.Model] = models.ManyToManyField( UserPartitionGroup, db_index=True, related_name='secseq_user_partition_groups', diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py index b1d224b411..389f6d2116 100644 --- a/openedx/core/djangoapps/content/search/api.py +++ b/openedx/core/djangoapps/content/search/api.py @@ -5,7 +5,7 @@ from __future__ import annotations import logging import time -from contextlib import contextmanager +from contextlib import contextmanager, nullcontext from datetime import datetime, timedelta, timezone from functools import wraps from typing import Callable, Generator @@ -17,27 +17,41 @@ from django.core.paginator import Paginator from meilisearch import Client as MeilisearchClient from meilisearch.errors import MeilisearchApiError, MeilisearchError from meilisearch.models.task import TaskInfo +from opaque_keys import OpaqueKey from opaque_keys.edx.keys import UsageKey -from opaque_keys.edx.locator import LibraryLocatorV2, LibraryCollectionLocator +from opaque_keys.edx.locator import ( + LibraryCollectionLocator, + LibraryContainerLocator, + LibraryLocatorV2, +) from openedx_learning.api import authoring as authoring_api -from common.djangoapps.student.roles import GlobalStaff from rest_framework.request import Request + from common.djangoapps.student.role_helpers import get_course_roles +from common.djangoapps.student.roles import GlobalStaff from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from openedx.core.djangoapps.content.search.models import get_access_ids_for_request +from openedx.core.djangoapps.content.search.index_config import ( + INDEX_DISTINCT_ATTRIBUTE, + INDEX_FILTERABLE_ATTRIBUTES, + INDEX_RANKING_RULES, + INDEX_SEARCHABLE_ATTRIBUTES, + INDEX_SORTABLE_ATTRIBUTES +) +from openedx.core.djangoapps.content.search.models import IncrementalIndexCompleted, get_access_ids_for_request from openedx.core.djangoapps.content_libraries import api as lib_api from xmodule.modulestore.django import modulestore from .documents import ( Fields, meili_id_from_opaque_key, - searchable_doc_for_course_block, - searchable_doc_for_collection, - searchable_doc_for_library_block, - searchable_doc_for_usage_key, searchable_doc_collections, + searchable_doc_for_collection, + searchable_doc_for_container, + searchable_doc_for_course_block, + searchable_doc_for_library_block, + searchable_doc_for_key, searchable_doc_tags, - searchable_doc_tags_for_collection, + searchable_doc_containers, ) log = logging.getLogger(__name__) @@ -217,6 +231,42 @@ def _using_temp_index(status_cb: Callable[[str], None] | None = None) -> Generat _wait_for_meili_task(client.delete_index(temp_index_name)) +def _index_is_empty(index_name: str) -> bool: + """ + Check if an index is empty + + Args: + index_name (str): The name of the index to check + """ + client = _get_meilisearch_client() + index = client.get_index(index_name) + return index.get_stats().number_of_documents == 0 + + +def _configure_index(index_name): + """ + Configure the index. The following index settings are best changed on an empty index. + Changing them on a populated index will "re-index all documents in the index", which can take some time. + + Args: + index_name (str): The name of the index to configure + """ + client = _get_meilisearch_client() + + # Mark usage_key as unique (it's not the primary key for the index, but nevertheless must be unique): + client.index(index_name).update_distinct_attribute(INDEX_DISTINCT_ATTRIBUTE) + # Mark which attributes can be used for filtering/faceted search: + client.index(index_name).update_filterable_attributes(INDEX_FILTERABLE_ATTRIBUTES) + # Mark which attributes are used for keyword search, in order of importance: + client.index(index_name).update_searchable_attributes(INDEX_SEARCHABLE_ATTRIBUTES) + # Mark which attributes can be used for sorting search results: + client.index(index_name).update_sortable_attributes(INDEX_SORTABLE_ATTRIBUTES) + + # Update the search ranking rules to let the (optional) "sort" parameter take precedence over keyword relevance. + # cf https://www.meilisearch.com/docs/learn/core_concepts/relevancy + client.index(index_name).update_ranking_rules(INDEX_RANKING_RULES) + + def _recurse_children(block, fn, status_cb: Callable[[str], None] | None = None) -> None: """ Recurse the children of an XBlock and call the given function for each @@ -279,8 +329,75 @@ def is_meilisearch_enabled() -> bool: return False -# pylint: disable=too-many-statements -def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: +def reset_index(status_cb: Callable[[str], None] | None = None) -> None: + """ + Reset the Meilisearch index, deleting all documents and reconfiguring it + """ + if status_cb is None: + status_cb = log.info + + status_cb("Creating new empty index...") + with _using_temp_index(status_cb) as temp_index_name: + _configure_index(temp_index_name) + status_cb("Index recreated!") + status_cb("Index reset complete.") + + +def _is_index_configured(index_name: str) -> bool: + """ + Check if an index is completely configured + + Args: + index_name (str): The name of the index to check + """ + client = _get_meilisearch_client() + index = client.get_index(index_name) + index_settings = index.get_settings() + for k, v in ( + ("distinctAttribute", INDEX_DISTINCT_ATTRIBUTE), + ("filterableAttributes", INDEX_FILTERABLE_ATTRIBUTES), + ("searchableAttributes", INDEX_SEARCHABLE_ATTRIBUTES), + ("sortableAttributes", INDEX_SORTABLE_ATTRIBUTES), + ("rankingRules", INDEX_RANKING_RULES), + ): + setting = index_settings.get(k, []) + if isinstance(v, list): + v = set(v) + setting = set(setting) + if setting != v: + return False + return True + + +def init_index(status_cb: Callable[[str], None] | None = None, warn_cb: Callable[[str], None] | None = None) -> None: + """ + Initialize the Meilisearch index, creating it and configuring it if it doesn't exist + """ + if status_cb is None: + status_cb = log.info + if warn_cb is None: + warn_cb = log.warning + + if _index_exists(STUDIO_INDEX_NAME): + if _index_is_empty(STUDIO_INDEX_NAME): + warn_cb( + "The studio search index is empty. Please run ./manage.py cms reindex_studio" + " --experimental [--incremental]" + ) + return + if not _is_index_configured(STUDIO_INDEX_NAME): + warn_cb( + "A rebuild of the index is required. Please run ./manage.py cms reindex_studio" + " --experimental [--incremental]" + ) + return + status_cb("Index already exists and is configured.") + return + + reset_index(status_cb) + + +def rebuild_index(status_cb: Callable[[str], None] | None = None, incremental=False) -> None: # lint-amnesty, pylint: disable=too-many-statements """ Rebuild the Meilisearch index from scratch """ @@ -292,7 +409,14 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: # Get the lists of libraries status_cb("Counting libraries...") - lib_keys = [lib.library_key for lib in lib_api.ContentLibrary.objects.select_related('org').only('org', 'slug')] + keys_indexed = [] + if incremental: + keys_indexed = list(IncrementalIndexCompleted.objects.values_list("context_key", flat=True)) + lib_keys = [ + lib.library_key + for lib in lib_api.ContentLibrary.objects.select_related("org").only("org", "slug").order_by("-id") + if lib.library_key not in keys_indexed + ] num_libraries = len(lib_keys) # Get the list of courses @@ -300,88 +424,25 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: num_courses = CourseOverview.objects.count() # Some counters so we can track our progress as indexing progresses: - num_contexts = num_courses + num_libraries - num_contexts_done = 0 # How many courses/libraries we've indexed + num_libs_skipped = len(keys_indexed) + num_contexts = num_courses + num_libraries + num_libs_skipped + num_contexts_done = 0 + num_libs_skipped # How many courses/libraries we've indexed num_blocks_done = 0 # How many individual components/XBlocks we've indexed status_cb(f"Found {num_courses} courses, {num_libraries} libraries.") - with _using_temp_index(status_cb) as temp_index_name: + with _using_temp_index(status_cb) if not incremental else nullcontext(STUDIO_INDEX_NAME) as index_name: ############## Configure the index ############## - # The following index settings are best changed on an empty index. - # Changing them on a populated index will "re-index all documents in the index, which can take some time" + # The index settings are best changed on an empty index. + # Changing them on a populated index will "re-index all documents in the index", which can take some time # and use more RAM. Instead, we configure an empty index then populate it one course/library at a time. - - # Mark usage_key as unique (it's not the primary key for the index, but nevertheless must be unique): - client.index(temp_index_name).update_distinct_attribute(Fields.usage_key) - # Mark which attributes can be used for filtering/faceted search: - client.index(temp_index_name).update_filterable_attributes([ - # Get specific block/collection using combination of block_id and context_key - Fields.block_id, - Fields.block_type, - Fields.context_key, - Fields.usage_key, - Fields.org, - Fields.tags, - Fields.tags + "." + Fields.tags_taxonomy, - Fields.tags + "." + Fields.tags_level0, - Fields.tags + "." + Fields.tags_level1, - Fields.tags + "." + Fields.tags_level2, - Fields.tags + "." + Fields.tags_level3, - Fields.collections, - Fields.collections + "." + Fields.collections_display_name, - Fields.collections + "." + Fields.collections_key, - Fields.type, - Fields.access_id, - Fields.last_published, - Fields.content + "." + Fields.problem_types, - ]) - # Mark which attributes are used for keyword search, in order of importance: - client.index(temp_index_name).update_searchable_attributes([ - # Keyword search does _not_ search the course name, course ID, breadcrumbs, block type, or other fields. - Fields.display_name, - Fields.block_id, - Fields.content, - Fields.description, - Fields.tags, - Fields.collections, - # If we don't list the following sub-fields _explicitly_, they're only sometimes searchable - that is, they - # are searchable only if at least one document in the index has a value. If we didn't list them here and, - # say, there were no tags.level3 tags in the index, the client would get an error if trying to search for - # these sub-fields: "Attribute `tags.level3` is not searchable." - Fields.tags + "." + Fields.tags_taxonomy, - Fields.tags + "." + Fields.tags_level0, - Fields.tags + "." + Fields.tags_level1, - Fields.tags + "." + Fields.tags_level2, - Fields.tags + "." + Fields.tags_level3, - Fields.collections + "." + Fields.collections_display_name, - Fields.collections + "." + Fields.collections_key, - Fields.published + "." + Fields.display_name, - Fields.published + "." + Fields.published_description, - ]) - # Mark which attributes can be used for sorting search results: - client.index(temp_index_name).update_sortable_attributes([ - Fields.display_name, - Fields.created, - Fields.modified, - Fields.last_published, - ]) - - # Update the search ranking rules to let the (optional) "sort" parameter take precedence over keyword relevance. - # cf https://www.meilisearch.com/docs/learn/core_concepts/relevancy - client.index(temp_index_name).update_ranking_rules([ - "sort", - "words", - "typo", - "proximity", - "attribute", - "exactness", - ]) + if not incremental: + _configure_index(index_name) ############## Libraries ############## status_cb("Indexing libraries...") - def index_library(lib_key: str) -> list: + def index_library(lib_key: LibraryLocatorV2) -> list: docs = [] for component in lib_api.get_library_components(lib_key): try: @@ -390,13 +451,14 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: doc.update(searchable_doc_for_library_block(metadata)) doc.update(searchable_doc_tags(metadata.usage_key)) doc.update(searchable_doc_collections(metadata.usage_key)) + doc.update(searchable_doc_containers(metadata.usage_key, "units")) docs.append(doc) except Exception as err: # pylint: disable=broad-except status_cb(f"Error indexing library component {component}: {err}") if docs: try: # Add all the docs in this library at once (usually faster than adding one at a time): - _wait_for_meili_task(client.index(temp_index_name).add_documents(docs)) + _wait_for_meili_task(client.index(index_name).add_documents(docs)) except (TypeError, KeyError, MeilisearchError) as err: status_cb(f"Error indexing library {lib_key}: {err}") return docs @@ -406,8 +468,9 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: docs = [] for collection in batch: try: - doc = searchable_doc_for_collection(library_key, collection.key, collection=collection) - doc.update(searchable_doc_tags_for_collection(library_key, collection.key)) + collection_key = lib_api.library_collection_locator(library_key, collection.key) + doc = searchable_doc_for_collection(collection_key, collection=collection) + doc.update(searchable_doc_tags(collection_key)) docs.append(doc) except Exception as err: # pylint: disable=broad-except status_cb(f"Error indexing collection {collection}: {err}") @@ -416,11 +479,42 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: if docs: try: # Add docs in batch of 100 at once (usually faster than adding one at a time): - _wait_for_meili_task(client.index(temp_index_name).add_documents(docs)) + _wait_for_meili_task(client.index(index_name).add_documents(docs)) except (TypeError, KeyError, MeilisearchError) as err: status_cb(f"Error indexing collection batch {p}: {err}") return num_done + ############## Containers ############## + def index_container_batch(batch, num_done, library_key) -> int: + docs = [] + for container in batch: + try: + container_key = lib_api.library_container_locator( + library_key, + container, + ) + doc = searchable_doc_for_container(container_key) + doc.update(searchable_doc_tags(container_key)) + doc.update(searchable_doc_collections(container_key)) + container_type = lib_api.ContainerType(container_key.container_type) + match container_type: + case lib_api.ContainerType.Unit: + doc.update(searchable_doc_containers(container_key, "subsections")) + case lib_api.ContainerType.Subsection: + doc.update(searchable_doc_containers(container_key, "sections")) + docs.append(doc) + except Exception as err: # pylint: disable=broad-except + status_cb(f"Error indexing container {container.key}: {err}") + num_done += 1 + + if docs: + try: + # Add docs in batch of 100 at once (usually faster than adding one at a time): + _wait_for_meili_task(client.index(index_name).add_documents(docs)) + except (TypeError, KeyError, MeilisearchError) as err: + status_cb(f"Error indexing container batch {p}: {err}") + return num_done + for lib_key in lib_keys: status_cb(f"{num_contexts_done + 1}/{num_contexts}. Now indexing blocks in library {lib_key}") lib_docs = index_library(lib_key) @@ -428,10 +522,10 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: # To reduce memory usage on large instances, split up the Collections into pages of 100 collections: library = lib_api.get_library(lib_key) - collections = authoring_api.get_collections(library.learning_package.id, enabled=True) + collections = authoring_api.get_collections(library.learning_package_id, enabled=True) num_collections = collections.count() num_collections_done = 0 - status_cb(f"{num_collections_done + 1}/{num_collections}. Now indexing collections in library {lib_key}") + status_cb(f"{num_collections_done}/{num_collections}. Now indexing collections in library {lib_key}") paginator = Paginator(collections, 100) for p in paginator.page_range: num_collections_done = index_collection_batch( @@ -439,8 +533,26 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: num_collections_done, lib_key, ) + if incremental: + IncrementalIndexCompleted.objects.get_or_create(context_key=lib_key) status_cb(f"{num_collections_done}/{num_collections} collections indexed for library {lib_key}") + # Similarly, batch process Containers (units, sections, etc) in pages of 100 + containers = authoring_api.get_containers(library.learning_package_id) + num_containers = containers.count() + num_containers_done = 0 + status_cb(f"{num_containers_done}/{num_containers}. Now indexing containers in library {lib_key}") + paginator = Paginator(containers, 100) + for p in paginator.page_range: + num_containers_done = index_container_batch( + paginator.page(p).object_list, + num_containers_done, + lib_key, + ) + status_cb(f"{num_containers_done}/{num_containers} containers indexed for library {lib_key}") + if incremental: + IncrementalIndexCompleted.objects.get_or_create(context_key=lib_key) + num_contexts_done += 1 ############## Courses ############## @@ -464,7 +576,7 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: if docs: # Add all the docs in this course at once (usually faster than adding one at a time): - _wait_for_meili_task(client.index(temp_index_name).add_documents(docs)) + _wait_for_meili_task(client.index(index_name).add_documents(docs)) return docs paginator = Paginator(CourseOverview.objects.only('id', 'display_name'), 1000) @@ -473,10 +585,16 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None: status_cb( f"{num_contexts_done + 1}/{num_contexts}. Now indexing course {course.display_name} ({course.id})" ) + if course.id in keys_indexed: + num_contexts_done += 1 + continue course_docs = index_course(course) + if incremental: + IncrementalIndexCompleted.objects.get_or_create(context_key=course.id) num_contexts_done += 1 num_blocks_done += len(course_docs) + IncrementalIndexCompleted.objects.all().delete() status_cb(f"Done! {num_blocks_done} blocks indexed across {num_contexts_done} courses, collections and libraries.") @@ -509,14 +627,14 @@ def upsert_xblock_index_doc(usage_key: UsageKey, recursive: bool = True) -> None _update_index_docs(docs) -def delete_index_doc(usage_key: UsageKey) -> None: +def delete_index_doc(key: OpaqueKey) -> None: """ Deletes the document for the given XBlock from the search index Args: - usage_key (UsageKey): The usage key of the XBlock to be removed from the index + key (OpaqueKey): The opaque key of the XBlock/Container to be removed from the index """ - doc = searchable_doc_for_usage_key(usage_key) + doc = searchable_doc_for_key(key) _delete_index_doc(doc[Fields.id]) @@ -542,29 +660,6 @@ def _delete_index_doc(doc_id) -> None: _wait_for_meili_tasks(tasks) -def delete_all_draft_docs_for_library(library_key: LibraryLocatorV2) -> None: - """ - Deletes draft documents for the given XBlocks from the search index - """ - current_rebuild_index_name = _get_running_rebuild_index_name() - client = _get_meilisearch_client() - # Delete all documents where last_published is null i.e. never published before. - delete_filter = [ - f'{Fields.context_key}="{library_key}"', - # This field should only be NULL or have a value, but we're also checking IS EMPTY just in case. - # Inner arrays are connected by an OR - [f'{Fields.last_published} IS EMPTY', f'{Fields.last_published} IS NULL'], - ] - - tasks = [] - if current_rebuild_index_name: - # If there is a rebuild in progress, the documents will also be deleted from the new index. - tasks.append(client.index(current_rebuild_index_name).delete_documents(filter=delete_filter)) - tasks.append(client.index(STUDIO_INDEX_NAME).delete_documents(filter=delete_filter)) - - _wait_for_meili_tasks(tasks) - - def upsert_library_block_index_doc(usage_key: UsageKey) -> None: """ Creates or updates the document for the given Library Block in the search index @@ -599,14 +694,14 @@ def _get_document_from_index(document_id: str) -> dict: return document -def upsert_library_collection_index_doc(library_key: LibraryLocatorV2, collection_key: str) -> None: +def upsert_library_collection_index_doc(collection_key: LibraryCollectionLocator) -> None: """ Creates, updates, or deletes the document for the given Library Collection in the search index. If the Collection is not found or disabled (i.e. soft-deleted), then delete it from the search index. """ - doc = searchable_doc_for_collection(library_key, collection_key) - update_components = False + doc = searchable_doc_for_collection(collection_key) + update_items = False # Soft-deleted/disabled collections are removed from the index # and their components updated. @@ -614,7 +709,7 @@ def upsert_library_collection_index_doc(library_key: LibraryLocatorV2, collectio _delete_index_doc(doc[Fields.id]) - update_components = True + update_items = True # Hard-deleted collections are also deleted from the index, # but their components are automatically updated as part of the deletion process, so we don't have to. @@ -627,20 +722,21 @@ def upsert_library_collection_index_doc(library_key: LibraryLocatorV2, collectio else: already_indexed = _get_document_from_index(doc[Fields.id]) if not already_indexed: - update_components = True + update_items = True _update_index_docs([doc]) # Asynchronously update the collection's components "collections" field - if update_components: - from .tasks import update_library_components_collections as update_task + if update_items: + from .tasks import update_library_components_collections as update_components_task + from .tasks import update_library_containers_collections as update_containers_task - update_task.delay(str(library_key), collection_key) + update_components_task.delay(str(collection_key)) + update_containers_task.delay(str(collection_key)) def update_library_components_collections( - library_key: LibraryLocatorV2, - collection_key: str, + collection_key: LibraryCollectionLocator, batch_size: int = 1000, ) -> None: """ @@ -648,8 +744,12 @@ def update_library_components_collections( Because there may be a lot of components, we send these updates to Meilisearch in batches. """ + library_key = collection_key.lib_key library = lib_api.get_library(library_key) - components = authoring_api.get_collection_components(library.learning_package.id, collection_key) + components = authoring_api.get_collection_components( + library.learning_package_id, + collection_key.collection_id, + ) paginator = Paginator(components, batch_size) for page in paginator.page_range: @@ -660,7 +760,8 @@ def update_library_components_collections( library_key, component, ) - doc = searchable_doc_collections(usage_key) + doc = searchable_doc_for_key(usage_key) + doc.update(searchable_doc_collections(usage_key)) docs.append(doc) log.info( @@ -670,6 +771,66 @@ def update_library_components_collections( _update_index_docs(docs) +def update_library_containers_collections( + collection_key: LibraryCollectionLocator, + batch_size: int = 1000, +) -> None: + """ + Updates the "collections" field for all containers associated with a given Library Collection. + + Because there may be a lot of containers, we send these updates to Meilisearch in batches. + """ + library_key = collection_key.lib_key + library = lib_api.get_library(library_key) + containers = authoring_api.get_collection_containers( + library.learning_package_id, + collection_key.collection_id, + ) + + paginator = Paginator(containers, batch_size) + for page in paginator.page_range: + docs = [] + + for container in paginator.page(page).object_list: + container_key = lib_api.library_container_locator( + library_key, + container, + ) + doc = searchable_doc_for_key(container_key) + doc.update(searchable_doc_collections(container_key)) + docs.append(doc) + + log.info( + f"Updating document.collections for library {library_key} containers" + f" page {page} / {paginator.num_pages}" + ) + _update_index_docs(docs) + + +def upsert_library_container_index_doc(container_key: LibraryContainerLocator) -> None: + """ + Creates, updates, or deletes the document for the given Library Container in the search index. + + TODO: add support for indexing a container's components, like upsert_library_collection_index_doc does. + """ + doc = searchable_doc_for_container(container_key) + + # Soft-deleted/disabled containers are removed from the index + # and their components updated. + if doc.get('_disabled'): + + _delete_index_doc(doc[Fields.id]) + + # Hard-deleted containers are also deleted from the index + elif not doc.get(Fields.type): + + _delete_index_doc(doc[Fields.id]) + + # Otherwise, upsert the container. + else: + _update_index_docs([doc]) + + def upsert_content_library_index_docs(library_key: LibraryLocatorV2) -> None: """ Creates or updates the documents for the given Content Library in the search index @@ -683,30 +844,30 @@ def upsert_content_library_index_docs(library_key: LibraryLocatorV2) -> None: _update_index_docs(docs) -def upsert_block_tags_index_docs(usage_key: UsageKey): +def upsert_content_object_tags_index_doc(key: OpaqueKey): """ - Updates the tags data in documents for the given Course/Library block + Updates the tags data in document for the given Course/Library item """ - doc = {Fields.id: meili_id_from_opaque_key(usage_key)} - doc.update(searchable_doc_tags(usage_key)) + doc = {Fields.id: meili_id_from_opaque_key(key)} + doc.update(searchable_doc_tags(key)) _update_index_docs([doc]) -def upsert_block_collections_index_docs(usage_key: UsageKey): +def upsert_item_collections_index_docs(opaque_key: OpaqueKey): """ - Updates the collections data in documents for the given Course/Library block + Updates the collections data in documents for the given Course/Library block, or Container """ - doc = {Fields.id: meili_id_from_opaque_key(usage_key)} - doc.update(searchable_doc_collections(usage_key)) + doc = {Fields.id: meili_id_from_opaque_key(opaque_key)} + doc.update(searchable_doc_collections(opaque_key)) _update_index_docs([doc]) -def upsert_collection_tags_index_docs(collection_usage_key: LibraryCollectionLocator): +def upsert_item_containers_index_docs(opaque_key: OpaqueKey, container_type: str): """ - Updates the tags data in documents for the given library collection + Updates the containers (units/subsections/sections) data in documents for the given Course/Library block """ - - doc = searchable_doc_tags_for_collection(collection_usage_key.library_key, collection_usage_key.collection_id) + doc = {Fields.id: meili_id_from_opaque_key(opaque_key)} + doc.update(searchable_doc_containers(opaque_key, container_type)) _update_index_docs([doc]) diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index 7e547ca4d8..aaabb0b92a 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -6,19 +6,20 @@ from __future__ import annotations import logging from hashlib import blake2b -from django.utils.text import slugify from django.core.exceptions import ObjectDoesNotExist -from opaque_keys.edx.keys import LearningContextKey, UsageKey +from django.utils.text import slugify +from opaque_keys.edx.keys import ContainerKey, LearningContextKey, UsageKey, OpaqueKey +from opaque_keys.edx.locator import LibraryCollectionLocator, LibraryContainerLocator from openedx_learning.api import authoring as authoring_api -from opaque_keys.edx.locator import LibraryLocatorV2 +from openedx_learning.api.authoring_models import Collection from rest_framework.exceptions import NotFound from openedx.core.djangoapps.content.search.models import SearchAccess +from openedx.core.djangoapps.content.search.plain_text_math import process_mathjax from openedx.core.djangoapps.content_libraries import api as lib_api from openedx.core.djangoapps.content_tagging import api as tagging_api from openedx.core.djangoapps.xblock import api as xblock_api from openedx.core.djangoapps.xblock.data import LatestVersion -from openedx_learning.api.authoring_models import Collection log = logging.getLogger(__name__) @@ -66,20 +67,43 @@ class Fields: collections = "collections" collections_display_name = "display_name" collections_key = "key" + # Containers (dictionaries) that this object belongs to. + units = "units" + subsections = "subsections" + sections = "sections" + containers_display_name = "display_name" + containers_key = "key" + + sections_display_name = "display_name" + sections_key = "key" # The "content" field is a dictionary of arbitrary data, depending on the block_type. # It comes from each XBlock's index_dictionary() method (if present) plus some processing. # Text (html) blocks have an "html_content" key in here, capa has "capa_content" and "problem_types", and so on. + # Containers store their list of child usage keys here. content = "content" # Collections use this field to communicate how many entities/components they contain. # Structural XBlocks may use this one day to indicate how many child blocks they ocntain. num_children = "num_children" + # Publish status can be on of: + # "published", + # "modified" (for blocks that were published but have been modified since), + # "never" (for never-published blocks). + publish_status = "publish_status" + # Published data (dictionary) of this object published = "published" published_display_name = "display_name" published_description = "description" + published_content = "content" + published_num_children = "num_children" + + # List of children keys + child_usage_keys = "child_usage_keys" + # List of children display names + child_display_names = "child_display_names" # Note: new fields or values can be added at any time, but if they need to be indexed for filtering or keyword # search, the index configuration will need to be changed, which is only done as part of the 'reindex_studio' @@ -92,10 +116,20 @@ class DocType: """ course_block = "course_block" library_block = "library_block" + library_container = "library_container" collection = "collection" -def meili_id_from_opaque_key(usage_key: UsageKey) -> str: +class PublishStatus: + """ + Values for the 'publish_status' field on each doc in the search index + """ + never = "never" + published = "published" + modified = "modified" + + +def meili_id_from_opaque_key(key: OpaqueKey) -> str: """ Meilisearch requires each document to have a primary key that's either an integer or a string composed of alphanumeric characters (a-z A-Z 0-9), @@ -106,7 +140,7 @@ def meili_id_from_opaque_key(usage_key: UsageKey) -> str: we could use PublishableEntity's primary key / UUID instead. """ # The slugified key _may_ not be unique so we append a hashed string to make it unique: - key_str = str(usage_key) + key_str = str(key) key_bin = key_str.encode() suffix = blake2b(key_bin, digest_size=4, usedforsecurity=False).hexdigest() @@ -122,12 +156,12 @@ def _meili_access_id_from_context_key(context_key: LearningContextKey) -> int: return access.id -def searchable_doc_for_usage_key(usage_key: UsageKey) -> dict: +def searchable_doc_for_key(key: OpaqueKey) -> dict: """ - Generates a base document identified by its usage key. + Generates a base document identified by its opaque key. """ return { - Fields.id: meili_id_from_opaque_key(usage_key), + Fields.id: meili_id_from_opaque_key(key), } @@ -194,6 +228,8 @@ def _fields_from_block(block) -> dict: Fields.access_id: _meili_access_id_from_context_key(block.usage_key.context_key), Fields.breadcrumbs: [], } + if hasattr(block, "edited_on"): + block_data[Fields.modified] = block.edited_on.timestamp() # Get the breadcrumbs (course, section, subsection, etc.): if block.usage_key.context_key.is_course: # Getting parent is not yet implemented in Learning Core (for libraries). cur_block = block @@ -219,14 +255,99 @@ def _fields_from_block(block) -> dict: # Generate description from the content description = _get_description_from_block_content(block_type, content_data) if description: - block_data[Fields.description] = description + block_data[Fields.description] = process_mathjax(description) except Exception as err: # pylint: disable=broad-except log.exception(f"Failed to process index_dictionary for {block.usage_key}: {err}") return block_data -def _tags_for_content_object(object_id: UsageKey | LearningContextKey) -> dict: +def _published_data_from_block(block_published) -> dict: + """ + Given an library block get the published data. + """ + result = { + Fields.published: { + Fields.published_display_name: xblock_api.get_block_display_name(block_published), + } + } + + try: + content_data = _get_content_from_block(block_published) + + description = _get_description_from_block_content( + block_published.scope_ids.block_type, + content_data, + ) + + if description: + result[Fields.published][Fields.published_description] = description + except Exception as err: # pylint: disable=broad-except + log.exception(f"Failed to process index_dictionary for {block_published.usage_key}: {err}") + + return result + + +def searchable_doc_for_library_block(xblock_metadata: lib_api.LibraryXBlockMetadata) -> dict: + """ + Generate a dictionary document suitable for ingestion into a search engine + like Meilisearch or Elasticsearch, so that the given library block can be + found using faceted search. + + Datetime fields (created, modified, last_published) are serialized to POSIX timestamps so that they can be used to + sort the search results. + """ + library_name = lib_api.get_library(xblock_metadata.usage_key.context_key).title + block = xblock_api.load_block(xblock_metadata.usage_key, user=None) + + publish_status = PublishStatus.published + try: + block_published = xblock_api.load_block(xblock_metadata.usage_key, user=None, version=LatestVersion.PUBLISHED) + if xblock_metadata.last_published and xblock_metadata.last_published < xblock_metadata.modified: + publish_status = PublishStatus.modified + except NotFound: + # Never published + block_published = None + publish_status = PublishStatus.never + + doc = searchable_doc_for_key(xblock_metadata.usage_key) + doc.update({ + Fields.type: DocType.library_block, + Fields.breadcrumbs: [], + Fields.created: xblock_metadata.created.timestamp(), + Fields.modified: xblock_metadata.modified.timestamp(), + Fields.last_published: xblock_metadata.last_published.timestamp() if xblock_metadata.last_published else None, + Fields.publish_status: publish_status, + }) + + doc.update(_fields_from_block(block)) + + if block_published: + doc.update(_published_data_from_block(block_published)) + + # Add the breadcrumbs. In v2 libraries, the library itself is not a "parent" of the XBlocks so we add it here: + doc[Fields.breadcrumbs] = [{"display_name": library_name}] + + return doc + + +def searchable_doc_for_course_block(block) -> dict: + """ + Generate a dictionary document suitable for ingestion into a search engine + like Meilisearch or Elasticsearch, so that the given course block can be + found using faceted search. + """ + doc = searchable_doc_for_key(block.usage_key) + doc.update({ + Fields.type: DocType.course_block, + }) + + doc.update(_fields_from_block(block)) + + return doc + + +def searchable_doc_tags(object_id: OpaqueKey) -> dict: """ Given an XBlock, course, library, etc., get the tag data for its index doc. @@ -291,7 +412,7 @@ def _tags_for_content_object(object_id: UsageKey | LearningContextKey) -> dict: return {Fields.tags: result} -def _collections_for_content_object(object_id: UsageKey | LearningContextKey) -> dict: +def searchable_doc_collections(object_id: OpaqueKey) -> dict: """ Given an XBlock, course, library, etc., get the collections for its index doc. @@ -305,7 +426,10 @@ def _collections_for_content_object(object_id: UsageKey | LearningContextKey) -> If the object is in no collections, returns: { - "collections": {}, + "collections": { + "display_name": [], + "key": [], + }, } """ @@ -319,147 +443,82 @@ def _collections_for_content_object(object_id: UsageKey | LearningContextKey) -> # Gather the collections associated with this object collections = None try: - component = lib_api.get_component_from_usage_key(object_id) - collections = authoring_api.get_entity_collections( - component.learning_package_id, - component.key, - ) + if isinstance(object_id, UsageKey): + component = lib_api.get_component_from_usage_key(object_id) + collections = authoring_api.get_entity_collections( + component.learning_package_id, + component.key, + ).values('key', 'title') + elif isinstance(object_id, LibraryContainerLocator): + container = lib_api.get_container(object_id, include_collections=True) + collections = container.collections + else: + log.warning(f"Unexpected key type for {object_id}") + except ObjectDoesNotExist: - log.warning(f"No component found for {object_id}") + log.warning(f"No library item found for {object_id}") if not collections: return result for collection in collections: - result[Fields.collections][Fields.collections_display_name].append(collection.title) - result[Fields.collections][Fields.collections_key].append(collection.key) + result[Fields.collections][Fields.collections_display_name].append(collection["title"]) + result[Fields.collections][Fields.collections_key].append(collection["key"]) return result -def _published_data_from_block(block_published) -> dict: +def searchable_doc_containers(object_id: OpaqueKey, container_type: str) -> dict: """ - Given an library block get the published data. + Given an XBlock, course, library, etc., get the containers that it is part of for its index doc. + + e.g. for something in Units "UNIT_A" and "UNIT_B", this would return: + { + "units": { + "display_name": ["Unit A", "Unit B"], + "key": ["UNIT_A", "UNIT_B"], + } + } + + If the object is in no containers, returns: + { + "sections": { + "display_name": [], + "key": [], + }, + } """ + container_field = getattr(Fields, container_type) result = { - Fields.published: { - Fields.published_display_name: xblock_api.get_block_display_name(block_published), + container_field: { + Fields.containers_display_name: [], + Fields.containers_key: [], } } + # Gather the units associated with this object + containers = None try: - content_data = _get_content_from_block(block_published) + if isinstance(object_id, OpaqueKey): + containers = lib_api.get_containers_contains_item(object_id) + else: + log.warning(f"Unexpected key type for {object_id}") - description = _get_description_from_block_content( - block_published.scope_ids.block_type, - content_data, - ) + except ObjectDoesNotExist: + log.warning(f"No library item found for {object_id}") - if description: - result[Fields.published][Fields.published_description] = description - except Exception as err: # pylint: disable=broad-except - log.exception(f"Failed to process index_dictionary for {block_published.usage_key}: {err}") + if not containers: + return result + + for container in containers: + result[container_field][Fields.containers_display_name].append(container.display_name) + result[container_field][Fields.containers_key].append(str(container.container_key)) return result -def searchable_doc_for_library_block(xblock_metadata: lib_api.LibraryXBlockMetadata) -> dict: - """ - Generate a dictionary document suitable for ingestion into a search engine - like Meilisearch or Elasticsearch, so that the given library block can be - found using faceted search. - - Datetime fields (created, modified, last_published) are serialized to POSIX timestamps so that they can be used to - sort the search results. - """ - library_name = lib_api.get_library(xblock_metadata.usage_key.context_key).title - block = xblock_api.load_block(xblock_metadata.usage_key, user=None) - - try: - block_published = xblock_api.load_block(xblock_metadata.usage_key, user=None, version=LatestVersion.PUBLISHED) - except NotFound: - # Never published - block_published = None - - doc = searchable_doc_for_usage_key(xblock_metadata.usage_key) - doc.update({ - Fields.type: DocType.library_block, - Fields.breadcrumbs: [], - Fields.created: xblock_metadata.created.timestamp(), - Fields.modified: xblock_metadata.modified.timestamp(), - Fields.last_published: xblock_metadata.last_published.timestamp() if xblock_metadata.last_published else None, - }) - - doc.update(_fields_from_block(block)) - - if block_published: - doc.update(_published_data_from_block(block_published)) - - # Add the breadcrumbs. In v2 libraries, the library itself is not a "parent" of the XBlocks so we add it here: - doc[Fields.breadcrumbs] = [{"display_name": library_name}] - - return doc - - -def searchable_doc_tags(usage_key: UsageKey) -> dict: - """ - Generate a dictionary document suitable for ingestion into a search engine - like Meilisearch or Elasticsearch, with the tags data for the given content object. - """ - doc = searchable_doc_for_usage_key(usage_key) - doc.update(_tags_for_content_object(usage_key)) - - return doc - - -def searchable_doc_collections(usage_key: UsageKey) -> dict: - """ - Generate a dictionary document suitable for ingestion into a search engine - like Meilisearch or Elasticsearch, with the collections data for the given content object. - """ - doc = searchable_doc_for_usage_key(usage_key) - doc.update(_collections_for_content_object(usage_key)) - - return doc - - -def searchable_doc_tags_for_collection( - library_key: LibraryLocatorV2, - collection_key: str, -) -> dict: - """ - Generate a dictionary document suitable for ingestion into a search engine - like Meilisearch or Elasticsearch, with the tags data for the given library collection. - """ - collection_usage_key = lib_api.get_library_collection_usage_key( - library_key, - collection_key, - ) - doc = searchable_doc_for_usage_key(collection_usage_key) - doc.update(_tags_for_content_object(collection_usage_key)) - - return doc - - -def searchable_doc_for_course_block(block) -> dict: - """ - Generate a dictionary document suitable for ingestion into a search engine - like Meilisearch or Elasticsearch, so that the given course block can be - found using faceted search. - """ - doc = searchable_doc_for_usage_key(block.usage_key) - doc.update({ - Fields.type: DocType.course_block, - }) - - doc.update(_fields_from_block(block)) - - return doc - - def searchable_doc_for_collection( - library_key: LibraryLocatorV2, - collection_key: str, + collection_key: LibraryCollectionLocator, *, # Optionally provide the collection if we've already fetched one collection: Collection | None = None, @@ -472,34 +531,41 @@ def searchable_doc_for_collection( If no collection is found for the given library_key + collection_key, the returned document will contain only basic information derived from the collection usage key, and no Fields.type value will be included in the returned dict. """ - collection_usage_key = lib_api.get_library_collection_usage_key( - library_key, - collection_key, - ) - - doc = searchable_doc_for_usage_key(collection_usage_key) + doc = searchable_doc_for_key(collection_key) try: - collection = collection or lib_api.get_library_collection_from_usage_key(collection_usage_key) + collection = collection or lib_api.get_library_collection_from_locator(collection_key) except lib_api.ContentLibraryCollectionNotFound: # Collection not found, so we can only return the base doc pass if collection: - assert collection.key == collection_key + assert collection.key == collection_key.collection_id + + draft_num_children = authoring_api.filter_publishable_entities( + collection.entities, + has_draft=True, + ).count() + published_num_children = authoring_api.filter_publishable_entities( + collection.entities, + has_published=True, + ).count() doc.update({ - Fields.context_key: str(library_key), - Fields.org: str(library_key.org), - Fields.usage_key: str(collection_usage_key), + Fields.context_key: str(collection_key.context_key), + Fields.org: str(collection_key.org), + Fields.usage_key: str(collection_key), Fields.block_id: collection.key, Fields.type: DocType.collection, Fields.display_name: collection.title, Fields.description: collection.description, Fields.created: collection.created.timestamp(), Fields.modified: collection.modified.timestamp(), - Fields.num_children: collection.entities.count(), - Fields.access_id: _meili_access_id_from_context_key(library_key), + Fields.num_children: draft_num_children, + Fields.published: { + Fields.published_num_children: published_num_children, + }, + Fields.access_id: _meili_access_id_from_context_key(collection_key.context_key), Fields.breadcrumbs: [{"display_name": collection.learning_package.title}], }) @@ -509,3 +575,98 @@ def searchable_doc_for_collection( doc['_disabled'] = True return doc + + +def searchable_doc_for_container( + container_key: ContainerKey, +) -> dict: + """ + Generate a dictionary document suitable for ingestion into a search engine + like Meilisearch or Elasticsearch, so that the given container can be + found using faceted search. + + If no container is found for the given container key, the returned document + will contain only basic information derived from the container key, and no + Fields.type value will be included in the returned dict. + """ + doc = { + Fields.id: meili_id_from_opaque_key(container_key), + Fields.context_key: str(container_key.context_key), + Fields.org: str(container_key.org), + # In the future, this may be either course_container or library_container + Fields.type: DocType.library_container, + # To check if it is "unit", "section", "subsection", etc.. + Fields.block_type: container_key.container_type, + Fields.usage_key: str(container_key), # Field name isn't exact but this is the closest match + Fields.block_id: container_key.container_id, # Field name isn't exact but this is the closest match + Fields.access_id: _meili_access_id_from_context_key(container_key.context_key), + Fields.publish_status: PublishStatus.never, + Fields.last_published: None, + } + + try: + container = lib_api.get_container(container_key) + except lib_api.ContentLibraryContainerNotFound: + # Container not found, so we can only return the base doc + log.error(f"Container {container_key} not found") + return doc + + draft_children = lib_api.get_container_children( + container_key, + published=False, + ) + publish_status = PublishStatus.published + if container.last_published is None: + publish_status = PublishStatus.never + elif container.has_unpublished_changes: + publish_status = PublishStatus.modified + + container_type = lib_api.ContainerType(container_key.container_type) + + def get_child_keys(children) -> list[str]: + match container_type: + case lib_api.ContainerType.Unit: + return [ + str(child.usage_key) + for child in children + ] + case lib_api.ContainerType.Subsection | lib_api.ContainerType.Section: + return [ + str(child.container_key) + for child in children + ] + + def get_child_names(children) -> list[str]: + return [child.display_name for child in children] + + doc.update({ + Fields.display_name: container.display_name, + Fields.created: container.created.timestamp(), + Fields.modified: container.modified.timestamp(), + Fields.num_children: len(draft_children), + Fields.content: { + Fields.child_usage_keys: get_child_keys(draft_children), + Fields.child_display_names: get_child_names(draft_children), + }, + Fields.publish_status: publish_status, + Fields.last_published: container.last_published.timestamp() if container.last_published else None, + }) + library = lib_api.get_library(container_key.context_key) + if library: + doc[Fields.breadcrumbs] = [{"display_name": library.title}] + + if container.published_version_num is not None: + published_children = lib_api.get_container_children( + container_key, + published=True, + ) + doc[Fields.published] = { + Fields.published_display_name: container.published_display_name, + Fields.published_num_children: len(published_children), + Fields.published_content: { + Fields.child_usage_keys: get_child_keys(published_children), + Fields.child_display_names: get_child_names(published_children), + }, + } + + return doc diff --git a/openedx/core/djangoapps/content/search/handlers.py b/openedx/core/djangoapps/content/search/handlers.py index 24add6748d..1b0f3f1996 100644 --- a/openedx/core/djangoapps/content/search/handlers.py +++ b/openedx/core/djangoapps/content/search/handlers.py @@ -8,12 +8,13 @@ from django.db.models.signals import post_delete from django.dispatch import receiver from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import UsageKey -from opaque_keys.edx.locator import LibraryCollectionLocator +from opaque_keys.edx.locator import LibraryCollectionLocator, LibraryContainerLocator from openedx_events.content_authoring.data import ( ContentLibraryData, ContentObjectChangedData, LibraryBlockData, LibraryCollectionData, + LibraryContainerData, XBlockData, ) from openedx_events.content_authoring.signals import ( @@ -22,9 +23,14 @@ from openedx_events.content_authoring.signals import ( LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_DELETED, LIBRARY_BLOCK_UPDATED, + LIBRARY_BLOCK_PUBLISHED, LIBRARY_COLLECTION_CREATED, LIBRARY_COLLECTION_DELETED, LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_CREATED, + LIBRARY_CONTAINER_DELETED, + LIBRARY_CONTAINER_UPDATED, + LIBRARY_CONTAINER_PUBLISHED, XBLOCK_CREATED, XBLOCK_DELETED, XBLOCK_UPDATED, @@ -33,18 +39,21 @@ from openedx_events.content_authoring.signals import ( from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.search.models import SearchAccess +from openedx.core.djangoapps.content_libraries import api as lib_api from .api import ( only_if_meilisearch_enabled, - upsert_block_collections_index_docs, - upsert_block_tags_index_docs, - upsert_collection_tags_index_docs, + upsert_content_object_tags_index_doc, + upsert_item_collections_index_docs, + upsert_item_containers_index_docs, ) from .tasks import ( delete_library_block_index_doc, + delete_library_container_index_doc, delete_xblock_index_doc, update_content_library_index_docs, update_library_collection_index_doc, + update_library_container_index_doc, upsert_library_block_index_doc, upsert_xblock_index_doc, ) @@ -130,6 +139,32 @@ def library_block_updated_handler(**kwargs) -> None: upsert_library_block_index_doc.apply(args=[str(library_block_data.usage_key)]) +@receiver(LIBRARY_BLOCK_PUBLISHED) +@only_if_meilisearch_enabled +def library_block_published_handler(**kwargs) -> None: + """ + Update the index for the content library block when its published version + has changed. + """ + library_block_data = kwargs.get("library_block", None) + if not library_block_data or not isinstance(library_block_data, LibraryBlockData): # pragma: no cover + log.error("Received null or incorrect data for event") + return + + # The PUBLISHED event is sent for any change to the published version including deletes, so check if it exists: + try: + lib_api.get_library_block(library_block_data.usage_key) + except lib_api.ContentLibraryBlockNotFound: + log.info(f"Observed published deletion of library block {str(library_block_data.usage_key)}.") + # The document should already have been deleted from the search index + # via the DELETED handler, so there's nothing to do now. + return + + # Update content library index synchronously to make sure that search index is updated before + # the frontend invalidates/refetches results. This is only a single document update so is very fast. + upsert_library_block_index_doc.apply(args=[str(library_block_data.usage_key)]) + + @receiver(LIBRARY_BLOCK_DELETED) @only_if_meilisearch_enabled def library_block_deleted(**kwargs) -> None: @@ -156,14 +191,14 @@ def content_library_updated_handler(**kwargs) -> None: if not content_library_data or not isinstance(content_library_data, ContentLibraryData): # pragma: no cover log.error("Received null or incorrect data for event") return + library_key = content_library_data.library_key - # Update content library index synchronously to make sure that search index is updated before - # the frontend invalidates/refetches index. - # Currently, this is only required to make sure that removed/discarded components are removed - # from the search index and displayed to user properly. If it becomes a performance bottleneck - # for other update operations other than discard, we can update CONTENT_LIBRARY_UPDATED event - # to include a parameter which can help us decide if the task needs to run sync or async. - update_content_library_index_docs.apply(args=[str(content_library_data.library_key)]) + # For now we assume the library has been renamed. Few other things will trigger this event. + + # Update ALL items in the library, because their breadcrumbs will be outdated. + # TODO: just patch the "breadcrumbs" field? It's the same on every one. + # TODO: check if the library display_name has actually changed before updating all items? + update_content_library_index_docs.apply(args=[str(library_key)]) @receiver(LIBRARY_COLLECTION_CREATED) @@ -181,16 +216,14 @@ def library_collection_updated_handler(**kwargs) -> None: if library_collection.background: update_library_collection_index_doc.delay( - str(library_collection.library_key), - library_collection.collection_key, + str(library_collection.collection_key), ) else: # Update collection index synchronously to make sure that search index is updated before # the frontend invalidates/refetches index. # See content_library_updated_handler for more details. update_library_collection_index_doc.apply(args=[ - str(library_collection.library_key), - library_collection.collection_key, + str(library_collection.collection_key), ]) @@ -206,22 +239,91 @@ def content_object_associations_changed_handler(**kwargs) -> None: return try: - # Check if valid if course or library block - usage_key = UsageKey.from_string(str(content_object.object_id)) + # Check if valid course or library block + opaque_key = UsageKey.from_string(str(content_object.object_id)) except InvalidKeyError: try: - # Check if valid if library collection - usage_key = LibraryCollectionLocator.from_string(str(content_object.object_id)) + # Check if valid library collection + opaque_key = LibraryCollectionLocator.from_string(str(content_object.object_id)) except InvalidKeyError: - log.error("Received invalid content object id") - return + try: + # Check if valid library container + opaque_key = LibraryContainerLocator.from_string(str(content_object.object_id)) + except InvalidKeyError: + # Invalid content object id + log.error("Received invalid content object id") + return # This event's changes may contain both "tags" and "collections", but this will happen rarely, if ever. # So we allow a potential double "upsert" here. if not content_object.changes or "tags" in content_object.changes: - if isinstance(usage_key, LibraryCollectionLocator): - upsert_collection_tags_index_docs(usage_key) - else: - upsert_block_tags_index_docs(usage_key) + upsert_content_object_tags_index_doc(opaque_key) if not content_object.changes or "collections" in content_object.changes: - upsert_block_collections_index_docs(usage_key) + upsert_item_collections_index_docs(opaque_key) + if not content_object.changes or "units" in content_object.changes: + upsert_item_containers_index_docs(opaque_key, "units") + if not content_object.changes or "sections" in content_object.changes: + upsert_item_containers_index_docs(opaque_key, "sections") + if not content_object.changes or "subsections" in content_object.changes: + upsert_item_containers_index_docs(opaque_key, "subsections") + + +@receiver(LIBRARY_CONTAINER_CREATED) +@receiver(LIBRARY_CONTAINER_UPDATED) +@only_if_meilisearch_enabled +def library_container_updated_handler(**kwargs) -> None: + """ + Create or update the index for the content library container + """ + library_container = kwargs.get("library_container", None) + if not library_container or not isinstance(library_container, LibraryContainerData): # pragma: no cover + log.error("Received null or incorrect data for event") + return + + update_library_container_index_doc.apply(args=[ + str(library_container.container_key), + ]) + + +@receiver(LIBRARY_CONTAINER_PUBLISHED) +@only_if_meilisearch_enabled +def library_container_published_handler(**kwargs) -> None: + """ + Update the index for the content library container when its published + version has changed. + """ + library_container = kwargs.get("library_container", None) + if not library_container or not isinstance(library_container, LibraryContainerData): # pragma: no cover + log.error("Received null or incorrect data for event") + return + # The PUBLISHED event is sent for any change to the published version including deletes, so check if it exists: + try: + lib_api.get_container(library_container.container_key) + except lib_api.ContentLibraryContainerNotFound: + log.info(f"Observed published deletion of container {str(library_container.container_key)}.") + # The document should already have been deleted from the search index + # via the DELETED handler, so there's nothing to do now. + return + + update_library_container_index_doc.apply(args=[ + str(library_container.container_key), + ]) + + +@receiver(LIBRARY_CONTAINER_DELETED) +@only_if_meilisearch_enabled +def library_container_deleted(**kwargs) -> None: + """ + Delete the index for the content library container + """ + library_container = kwargs.get("library_container", None) + if not library_container or not isinstance(library_container, LibraryContainerData): # pragma: no cover + log.error("Received null or incorrect data for event") + return + + # Update content library index synchronously to make sure that search index is updated before + # the frontend invalidates/refetches results. This is only a single document update so is very fast. + delete_library_container_index_doc.apply(args=[str(library_container.container_key)]) + # TODO: post-Teak, move all the celery tasks directly inline into this handlers? Because now the + # events are emitted in an [async] worker, so it doesn't matter if the handlers are synchronous. + # See https://github.com/openedx/edx-platform/pull/36640 discussion. diff --git a/openedx/core/djangoapps/content/search/index_config.py b/openedx/core/djangoapps/content/search/index_config.py new file mode 100644 index 0000000000..f0a6eb9ca3 --- /dev/null +++ b/openedx/core/djangoapps/content/search/index_config.py @@ -0,0 +1,71 @@ +"""Configuration for the search index.""" +from .documents import Fields + + +INDEX_DISTINCT_ATTRIBUTE = "usage_key" + +# Mark which attributes can be used for filtering/faceted search: +INDEX_FILTERABLE_ATTRIBUTES = [ + # Get specific block/collection using combination of block_id and context_key + Fields.block_id, + Fields.block_type, + Fields.context_key, + Fields.usage_key, + Fields.org, + Fields.tags, + Fields.tags + "." + Fields.tags_taxonomy, + Fields.tags + "." + Fields.tags_level0, + Fields.tags + "." + Fields.tags_level1, + Fields.tags + "." + Fields.tags_level2, + Fields.tags + "." + Fields.tags_level3, + Fields.collections, + Fields.collections + "." + Fields.collections_display_name, + Fields.collections + "." + Fields.collections_key, + Fields.type, + Fields.access_id, + Fields.last_published, + Fields.content + "." + Fields.problem_types, + Fields.publish_status, +] + +# Mark which attributes are used for keyword search, in order of importance: +INDEX_SEARCHABLE_ATTRIBUTES = [ + # Keyword search does _not_ search the course name, course ID, breadcrumbs, block type, or other fields. + Fields.display_name, + Fields.block_id, + Fields.content, + Fields.description, + Fields.tags, + Fields.collections, + # If we don't list the following sub-fields _explicitly_, they're only sometimes searchable - that is, they + # are searchable only if at least one document in the index has a value. If we didn't list them here and, + # say, there were no tags.level3 tags in the index, the client would get an error if trying to search for + # these sub-fields: "Attribute `tags.level3` is not searchable." + Fields.tags + "." + Fields.tags_taxonomy, + Fields.tags + "." + Fields.tags_level0, + Fields.tags + "." + Fields.tags_level1, + Fields.tags + "." + Fields.tags_level2, + Fields.tags + "." + Fields.tags_level3, + Fields.collections + "." + Fields.collections_display_name, + Fields.collections + "." + Fields.collections_key, + Fields.published + "." + Fields.display_name, + Fields.published + "." + Fields.published_description, +] + +# Mark which attributes can be used for sorting search results: +INDEX_SORTABLE_ATTRIBUTES = [ + Fields.display_name, + Fields.created, + Fields.modified, + Fields.last_published, +] + +# Update the search ranking rules to let the (optional) "sort" parameter take precedence over keyword relevance. +INDEX_RANKING_RULES = [ + "sort", + "words", + "typo", + "proximity", + "attribute", + "exactness", +] diff --git a/openedx/core/djangoapps/content/search/management/commands/reindex_studio.py b/openedx/core/djangoapps/content/search/management/commands/reindex_studio.py index 3767ebcba6..2d8bb29f7a 100644 --- a/openedx/core/djangoapps/content/search/management/commands/reindex_studio.py +++ b/openedx/core/djangoapps/content/search/management/commands/reindex_studio.py @@ -18,8 +18,11 @@ class Command(BaseCommand): """ def add_arguments(self, parser): - parser.add_argument('--experimental', action='store_true') - parser.set_defaults(experimental=False) + parser.add_argument("--experimental", action="store_true") + parser.add_argument("--reset", action="store_true") + parser.add_argument("--init", action="store_true") + parser.add_argument("--incremental", action="store_true") + parser.set_defaults(experimental=False, reset=False, init=False, incremental=False) def handle(self, *args, **options): """ @@ -34,4 +37,11 @@ class Command(BaseCommand): "Use the --experimental argument to acknowledge and run it." ) - api.rebuild_index(self.stdout.write) + if options["reset"]: + api.reset_index(self.stdout.write) + elif options["init"]: + api.init_index(self.stdout.write, self.stderr.write) + elif options["incremental"]: + api.rebuild_index(self.stdout.write, incremental=True) + else: + api.rebuild_index(self.stdout.write) diff --git a/openedx/core/djangoapps/content/search/migrations/0002_incrementalindexcompleted.py b/openedx/core/djangoapps/content/search/migrations/0002_incrementalindexcompleted.py new file mode 100644 index 0000000000..a316c35a7d --- /dev/null +++ b/openedx/core/djangoapps/content/search/migrations/0002_incrementalindexcompleted.py @@ -0,0 +1,21 @@ +# Generated by Django 4.2.16 on 2024-11-15 12:40 + +from django.db import migrations, models +import opaque_keys.edx.django.models + + +class Migration(migrations.Migration): + + dependencies = [ + ('search', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='IncrementalIndexCompleted', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('context_key', opaque_keys.edx.django.models.LearningContextKeyField(max_length=255, unique=True)), + ], + ), + ] diff --git a/openedx/core/djangoapps/content/search/models.py b/openedx/core/djangoapps/content/search/models.py index 711c493ff8..6fa53ef17b 100644 --- a/openedx/core/djangoapps/content/search/models.py +++ b/openedx/core/djangoapps/content/search/models.py @@ -65,3 +65,15 @@ def get_access_ids_for_request(request: Request, omit_orgs: list[str] = None) -> course_clause | library_clause ).order_by('-id').values_list("id", flat=True) ) + + +class IncrementalIndexCompleted(models.Model): + """ + Stores the contex keys of aleady indexed courses and libraries for incremental indexing. + """ + + context_key = LearningContextKeyField( + max_length=255, + unique=True, + null=False, + ) diff --git a/openedx/core/djangoapps/content/search/plain_text_math.py b/openedx/core/djangoapps/content/search/plain_text_math.py new file mode 100644 index 0000000000..70f6c3fd2c --- /dev/null +++ b/openedx/core/djangoapps/content/search/plain_text_math.py @@ -0,0 +1,161 @@ +""" +Helper class to convert mathjax equations to plain text. +""" + +import re + +import unicodeit + + +class InvalidMathEquation(Exception): + """Raised when mathjax equation is invalid. This is used to skip all transformations.""" + + +class EqnPatternNotFound(Exception): + """Raised when a pattern is not found in equation. This is used to skip a specific transformation.""" + + +class PlainTextMath: + """ + Converts mathjax equations to plain text using unicodeit and some preprocessing. + """ + equation_pattern = re.compile( + r'\[mathjaxinline\](.*?)\[\/mathjaxinline\]|\[mathjax\](.*?)\[\/mathjax\]|\\\((.*?)\\\)|\\\[(.*?)\\\]' + ) + eqn_replacements = ( + # just remove prefix `\` + ("\\sin", "sin"), + ("\\cos", "cos"), + ("\\tan", "tan"), + ("\\arcsin", "arcsin"), + ("\\arccos", "arccos"), + ("\\arctan", "arctan"), + ("\\cot", "cot"), + ("\\sec", "sec"), + ("\\csc", "csc"), + # Is used for matching brackets in mathjax, should not be required in plain text. + ("\\left", ""), + ("\\right", ""), + ) + regex_replacements = ( + # Makes text bold, so not required in plain text. + (re.compile(r'{\\bf (.*?)}'), r"\1"), + ) + extract_inner_texts = ( + # Replaces any eqn: `\name{inner_text}` with `inner_text` + "\\mathbf{", + "\\bm{", + ) + frac_open_close_pattern = re.compile(r"}\s*{") + + @staticmethod + def _nested_bracket_matcher(equation: str, opening_pattern: str) -> str: + r""" + Matches opening and closing brackets in given string. + + Args: + equation: string + opening_pattern: for example, `\mathbf{` + + Returns: + String inside the eqn brackets + """ + start = equation.find(opening_pattern) + if start == -1: + raise EqnPatternNotFound() + open_count = 0 + inner_start = start + len(opening_pattern) + for i, char in enumerate(equation[inner_start:]): + if char == "{": + open_count += 1 + if char == "}": + if open_count == 0: + break + open_count -= 1 + else: + raise InvalidMathEquation() + # In below example `|` symbol is used to denote index position + # |\mathbf{, \mathbf{|, \mathbf{some_text|}, \mathbf{some_text}| + return (start, inner_start, inner_start + i, inner_start + i + 1) + + def _fraction_handler(self, equation: str) -> str: + r""" + Converts `\frac{x}{y}` to `(x/y)` while handling nested `{}`. + + For example: `\frac{2}{\sqrt{1+y}}` is converted to `(2/\sqrt{1+y})`. + + Args: + equation: string + + Returns: + String with `\frac` replaced by normal `/` symbol. + """ + try: + n_start, n_inner_start, n_inner_end, n_end = self._nested_bracket_matcher(equation, "\\frac{") + except EqnPatternNotFound: + return equation + + numerator = equation[n_inner_start:n_inner_end] + # Handle nested fractions + numerator = self._fraction_handler(numerator) + + try: + _, d_inner_start, d_inner_end, d_end = self._nested_bracket_matcher(equation[n_end:], "{") + except EqnPatternNotFound: + return equation + + denominator = equation[n_end + d_inner_start:n_end + d_inner_end] + # Handle nested fractions + denominator = self._fraction_handler(denominator) + # Now re-create the equation with `(numerator / denominator)` + equation = equation[:n_start] + f"({numerator}/{denominator})" + equation[n_end + d_end:] + return equation + + def _nested_text_extractor(self, equation: str, pattern: str) -> str: + """ + Recursively extracts text from equation for given pattern + """ + try: + start, inner_start, inner_end, end = self._nested_bracket_matcher(equation, pattern) + inner_text = equation[inner_start:inner_end] + inner_text = self._nested_text_extractor(inner_text, pattern) + equation = equation[:start] + inner_text + equation[end:] + except EqnPatternNotFound: + pass + return equation + + def _handle_replacements(self, equation: str) -> str: + """ + Makes a bunch of replacements in equation string. + """ + for q, replacement in self.eqn_replacements: + equation = equation.replace(q, replacement) + for pattern in self.extract_inner_texts: + equation = self._nested_text_extractor(equation, pattern) + for pattern, replacement in self.regex_replacements: + equation = re.sub(pattern, replacement, equation) + return equation + + def run(self, eqn_matches: re.Match) -> str: + """ + Takes re.Match object and runs conversion process on each match group. + """ + groups = eqn_matches.groups() + for group in groups: + if not group: + continue + original = group + try: + group = self._handle_replacements(group) + group = self._fraction_handler(group) + return unicodeit.replace(group) + except Exception: # pylint: disable=broad-except + return original + return None + + +processor = PlainTextMath() + + +def process_mathjax(content: str) -> str: + return re.sub(processor.equation_pattern, processor.run, content) diff --git a/openedx/core/djangoapps/content/search/tasks.py b/openedx/core/djangoapps/content/search/tasks.py index 98390a12f3..5015f6912b 100644 --- a/openedx/core/djangoapps/content/search/tasks.py +++ b/openedx/core/djangoapps/content/search/tasks.py @@ -11,7 +11,12 @@ from celery_utils.logged_task import LoggedTask from edx_django_utils.monitoring import set_code_owner_attribute from meilisearch.errors import MeilisearchError from opaque_keys.edx.keys import UsageKey -from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 +from opaque_keys.edx.locator import ( + LibraryCollectionLocator, + LibraryContainerLocator, + LibraryLocatorV2, + LibraryUsageLocatorV2, +) from . import api @@ -81,32 +86,72 @@ def update_content_library_index_docs(library_key_str: str) -> None: log.info("Updating content index documents for library with id: %s", library_key) api.upsert_content_library_index_docs(library_key) - # Delete all documents in this library that were not published by above function - # as this task is also triggered on discard event. - api.delete_all_draft_docs_for_library(library_key) @shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError)) @set_code_owner_attribute -def update_library_collection_index_doc(library_key_str: str, collection_key: str) -> None: +def update_library_collection_index_doc(collection_key_str: str) -> None: """ Celery task to update the content index document for a library collection """ - library_key = LibraryLocatorV2.from_string(library_key_str) + collection_key = LibraryCollectionLocator.from_string(collection_key_str) + library_key = collection_key.lib_key log.info("Updating content index documents for collection %s in library%s", collection_key, library_key) - api.upsert_library_collection_index_doc(library_key, collection_key) + api.upsert_library_collection_index_doc(collection_key) @shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError)) @set_code_owner_attribute -def update_library_components_collections(library_key_str: str, collection_key: str) -> None: +def update_library_components_collections(collection_key_str: str) -> None: """ Celery task to update the "collections" field for components in the given content library collection. """ - library_key = LibraryLocatorV2.from_string(library_key_str) + collection_key = LibraryCollectionLocator.from_string(collection_key_str) + library_key = collection_key.lib_key log.info("Updating document.collections for library %s collection %s components", library_key, collection_key) - api.update_library_components_collections(library_key, collection_key) + api.update_library_components_collections(collection_key) + + +@shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError)) +@set_code_owner_attribute +def update_library_containers_collections(collection_key_str: str) -> None: + """ + Celery task to update the "collections" field for containers in the given content library collection. + """ + collection_key = LibraryCollectionLocator.from_string(collection_key_str) + library_key = collection_key.lib_key + + log.info("Updating document.collections for library %s collection %s containers", library_key, collection_key) + + api.update_library_containers_collections(collection_key) + + +@shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError)) +@set_code_owner_attribute +def update_library_container_index_doc(container_key_str: str) -> None: + """ + Celery task to update the content index document for a library container + """ + container_key = LibraryContainerLocator.from_string(container_key_str) + library_key = container_key.lib_key + + log.info("Updating content index documents for container %s in library%s", container_key, library_key) + + api.upsert_library_container_index_doc(container_key) + + +@shared_task(base=LoggedTask, autoretry_for=(MeilisearchError, ConnectionError)) +@set_code_owner_attribute +def delete_library_container_index_doc(container_key_str: str) -> None: + """ + Celery task to delete the content index document for a library block + """ + container_key = LibraryContainerLocator.from_string(container_key_str) + + log.info("Deleting content index document for library block with id: %s", container_key) + + api.delete_index_doc(container_key) diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index 990226f343..5c08340ecb 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -8,10 +8,13 @@ import copy from datetime import datetime, timezone from unittest.mock import MagicMock, Mock, call, patch from opaque_keys.edx.keys import UsageKey +from opaque_keys.edx.locator import LibraryCollectionLocator, LibraryContainerLocator import ddt +import pytest from django.test import override_settings from freezegun import freeze_time +from meilisearch.errors import MeilisearchApiError from openedx_learning.api import authoring as authoring_api from organizations.tests.factories import OrganizationFactory @@ -26,7 +29,7 @@ from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, try: # This import errors in the lms because content.search is not an installed app there. from .. import api - from ..models import SearchAccess + from ..models import SearchAccess, IncrementalIndexCompleted except RuntimeError: SearchAccess = {} @@ -44,7 +47,8 @@ class TestSearchApi(ModuleStoreTestCase): MODULESTORE = TEST_DATA_SPLIT_MODULESTORE - def setUp(self): + def setUp(self) -> None: + # pylint: disable=too-many-statements super().setUp() self.user = UserFactory.create() self.user_id = self.user.id @@ -58,19 +62,27 @@ class TestSearchApi(ModuleStoreTestCase): # Clear the Meilisearch client to avoid side effects from other tests api.clear_meilisearch_client() + modified_date = datetime(2024, 5, 6, 7, 8, 9, tzinfo=timezone.utc) # Create course - self.course = self.store.create_course( - "org1", - "test_course", - "test_run", - self.user_id, - fields={"display_name": "Test Course"}, - ) - course_access, _ = SearchAccess.objects.get_or_create(context_key=self.course.id) - self.course_block_key = "block-v1:org1+test_course+test_run+type@course+block@course" + with freeze_time(modified_date): + self.course = self.store.create_course( + "org1", + "test_course", + "test_run", + self.user_id, + fields={"display_name": "Test Course"}, + ) + course_access, _ = SearchAccess.objects.get_or_create(context_key=self.course.id) + self.course_block_key = "block-v1:org1+test_course+test_run+type@course+block@course" - # Create XBlocks - self.sequential = self.store.create_child(self.user_id, self.course.location, "sequential", "test_sequential") + # Create XBlocks + self.sequential = self.store.create_child( + self.user_id, + self.course.location, + "sequential", + "test_sequential" + ) + self.store.create_child(self.user_id, self.sequential.location, "vertical", "test_vertical") self.doc_sequential = { "id": "block-v1org1test_coursetest_runtypesequentialblocktest_sequential-f702c144", "type": "course_block", @@ -87,8 +99,8 @@ class TestSearchApi(ModuleStoreTestCase): ], "content": {}, "access_id": course_access.id, + "modified": modified_date.timestamp(), } - self.store.create_child(self.user_id, self.sequential.location, "vertical", "test_vertical") self.doc_vertical = { "id": "block-v1org1test_coursetest_runtypeverticalblocktest_vertical-e76a10a4", "type": "course_block", @@ -109,6 +121,7 @@ class TestSearchApi(ModuleStoreTestCase): ], "content": {}, "access_id": course_access.id, + "modified": modified_date.timestamp(), } # Make sure the CourseOverview for the course is created: CourseOverview.get_from_id(self.course.id) @@ -122,12 +135,11 @@ class TestSearchApi(ModuleStoreTestCase): lib_access, _ = SearchAccess.objects.get_or_create(context_key=self.library.key) # Populate it with 2 problems, freezing the date so we can verify created date serializes correctly. - created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc) - with freeze_time(created_date): + self.created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc) + with freeze_time(self.created_date): self.problem1 = library_api.create_library_block(self.library.key, "problem", "p1") self.problem2 = library_api.create_library_block(self.library.key, "problem", "p2") # Update problem1, freezing the date so we can verify modified date serializes correctly. - modified_date = datetime(2024, 5, 6, 7, 8, 9, tzinfo=timezone.utc) with freeze_time(modified_date): library_api.set_library_block_olx(self.problem1.usage_key, "") @@ -144,8 +156,9 @@ class TestSearchApi(ModuleStoreTestCase): "type": "library_block", "access_id": lib_access.id, "last_published": None, - "created": created_date.timestamp(), + "created": self.created_date.timestamp(), "modified": modified_date.timestamp(), + "publish_status": "never", } self.doc_problem2 = { "id": "lborg1libproblemp2-b2f65e29", @@ -160,8 +173,9 @@ class TestSearchApi(ModuleStoreTestCase): "type": "library_block", "access_id": lib_access.id, "last_published": None, - "created": created_date.timestamp(), - "modified": created_date.timestamp(), + "created": self.created_date.timestamp(), + "modified": self.created_date.timestamp(), + "publish_status": "never", } # Create a couple of taxonomies with tags @@ -176,7 +190,7 @@ class TestSearchApi(ModuleStoreTestCase): # Create a collection: self.learning_package = authoring_api.get_learning_package_by_key(self.library.key) - with freeze_time(created_date): + with freeze_time(self.created_date): self.collection = authoring_api.create_collection( learning_package_id=self.learning_package.id, key="MYCOL", @@ -184,32 +198,144 @@ class TestSearchApi(ModuleStoreTestCase): created_by=None, description="my collection description" ) - self.collection_usage_key = "lib-collection:org1:lib:MYCOL" + self.collection_key = LibraryCollectionLocator.from_string( + "lib-collection:org1:lib:MYCOL", + ) self.collection_dict = { "id": "lib-collectionorg1libmycol-5b647617", "block_id": self.collection.key, - "usage_key": self.collection_usage_key, + "usage_key": str(self.collection_key), "type": "collection", "display_name": "my_collection", "description": "my collection description", "num_children": 0, "context_key": "lib:org1:lib", "org": "org1", - "created": created_date.timestamp(), - "modified": created_date.timestamp(), + "created": self.created_date.timestamp(), + "modified": self.created_date.timestamp(), "access_id": lib_access.id, + "published": { + "num_children": 0 + }, "breadcrumbs": [{"display_name": "Library"}], } + # Create a container: + with freeze_time(self.created_date): + self.unit = library_api.create_container( + library_key=self.library.key, + container_type=library_api.ContainerType.Unit, + slug="unit-1", + title="Unit 1", + user_id=None, + ) + self.unit_key = "lct:org1:lib:unit:unit-1" + self.subsection = library_api.create_container( + self.library.key, + container_type=library_api.ContainerType.Subsection, + slug="subsection-1", + title="Subsection 1", + user_id=None, + ) + library_api.update_container_children( + self.subsection.container_key, + [self.unit.container_key], + None, + ) + self.subsection_key = "lct:org1:lib:subsection:subsection-1" + self.section = library_api.create_container( + self.library.key, + container_type=library_api.ContainerType.Section, + slug="section-1", + title="Section 1", + user_id=None, + ) + self.section_key = "lct:org1:lib:section:section-1" + library_api.update_container_children( + self.section.container_key, + [self.subsection.container_key], + None, + ) + + self.unit_dict = { + "id": "lctorg1libunitunit-1-e4527f7c", + "block_id": "unit-1", + "block_type": "unit", + "usage_key": self.unit_key, + "type": "library_container", + "display_name": "Unit 1", + # description is not set for containers + "num_children": 0, + "content": { + "child_usage_keys": [], + "child_display_names": [], + }, + "publish_status": "never", + "context_key": "lib:org1:lib", + "org": "org1", + "created": self.created_date.timestamp(), + "modified": self.created_date.timestamp(), + "last_published": None, + "access_id": lib_access.id, + "breadcrumbs": [{"display_name": "Library"}], + # "published" is not set since we haven't published it yet + } + self.subsection_dict = { + "id": "lctorg1libsubsectionsubsection-1-cf808309", + "block_id": "subsection-1", + "block_type": "subsection", + "usage_key": self.subsection_key, + "type": "library_container", + "display_name": "Subsection 1", + # description is not set for containers + "num_children": 1, + "content": { + "child_usage_keys": ["lct:org1:lib:unit:unit-1"], + "child_display_names": ["Unit 1"], + }, + "publish_status": "never", + "context_key": "lib:org1:lib", + "org": "org1", + "created": self.created_date.timestamp(), + "modified": self.created_date.timestamp(), + "last_published": None, + "access_id": lib_access.id, + "breadcrumbs": [{"display_name": "Library"}], + # "published" is not set since we haven't published it yet + } + self.section_dict = { + "id": "lctorg1libsectionsection-1-dc4791a4", + "block_id": "section-1", + "block_type": "section", + "usage_key": self.section_key, + "type": "library_container", + "display_name": "Section 1", + # description is not set for containers + "num_children": 1, + "content": { + "child_usage_keys": ["lct:org1:lib:subsection:subsection-1"], + "child_display_names": ["Subsection 1"], + }, + "publish_status": "never", + "context_key": "lib:org1:lib", + "org": "org1", + "created": self.created_date.timestamp(), + "modified": self.created_date.timestamp(), + "last_published": None, + "access_id": lib_access.id, + "breadcrumbs": [{"display_name": "Library"}], + # "published" is not set since we haven't published it yet + } + @override_settings(MEILISEARCH_ENABLED=False) - def test_reindex_meilisearch_disabled(self, mock_meilisearch): + def test_reindex_meilisearch_disabled(self, mock_meilisearch) -> None: with self.assertRaises(RuntimeError): api.rebuild_index() mock_meilisearch.return_value.swap_indexes.assert_not_called() @override_settings(MEILISEARCH_ENABLED=True) - def test_reindex_meilisearch(self, mock_meilisearch): + def test_reindex_meilisearch(self, mock_meilisearch) -> None: # Add tags field to doc, since reindex calls includes tags doc_sequential = copy.deepcopy(self.doc_sequential) @@ -219,29 +345,138 @@ class TestSearchApi(ModuleStoreTestCase): doc_problem1 = copy.deepcopy(self.doc_problem1) doc_problem1["tags"] = {} doc_problem1["collections"] = {'display_name': [], 'key': []} + doc_problem1["units"] = {'display_name': [], 'key': []} doc_problem2 = copy.deepcopy(self.doc_problem2) doc_problem2["tags"] = {} doc_problem2["collections"] = {'display_name': [], 'key': []} + doc_problem2["units"] = {'display_name': [], 'key': []} doc_collection = copy.deepcopy(self.collection_dict) doc_collection["tags"] = {} + doc_unit = copy.deepcopy(self.unit_dict) + doc_unit["tags"] = {} + doc_unit["collections"] = {'display_name': [], 'key': []} + doc_unit["subsections"] = {'display_name': ['Subsection 1'], 'key': ['lct:org1:lib:subsection:subsection-1']} + doc_subsection = copy.deepcopy(self.subsection_dict) + doc_subsection["tags"] = {} + doc_subsection["collections"] = {'display_name': [], 'key': []} + doc_subsection["sections"] = {'display_name': ['Section 1'], 'key': ['lct:org1:lib:section:section-1']} + doc_section = copy.deepcopy(self.section_dict) + doc_section["tags"] = {} + doc_section["collections"] = {'display_name': [], 'key': []} api.rebuild_index() - assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 3 + assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 4 mock_meilisearch.return_value.index.return_value.add_documents.assert_has_calls( [ call([doc_sequential, doc_vertical]), call([doc_problem1, doc_problem2]), call([doc_collection]), + call([doc_unit, doc_subsection, doc_section]), ], any_order=True, ) + @override_settings(MEILISEARCH_ENABLED=True) + def test_reindex_meilisearch_incremental(self, mock_meilisearch) -> None: + + # Add tags field to doc, since reindex calls includes tags + doc_sequential = copy.deepcopy(self.doc_sequential) + doc_sequential["tags"] = {} + doc_vertical = copy.deepcopy(self.doc_vertical) + doc_vertical["tags"] = {} + doc_problem1 = copy.deepcopy(self.doc_problem1) + doc_problem1["tags"] = {} + doc_problem1["collections"] = {"display_name": [], "key": []} + doc_problem1["units"] = {'display_name': [], 'key': []} + doc_problem2 = copy.deepcopy(self.doc_problem2) + doc_problem2["tags"] = {} + doc_problem2["collections"] = {"display_name": [], "key": []} + doc_problem2["units"] = {'display_name': [], 'key': []} + doc_collection = copy.deepcopy(self.collection_dict) + doc_collection["tags"] = {} + doc_unit = copy.deepcopy(self.unit_dict) + doc_unit["tags"] = {} + doc_unit["collections"] = {"display_name": [], "key": []} + doc_unit["subsections"] = {'display_name': ['Subsection 1'], 'key': ['lct:org1:lib:subsection:subsection-1']} + doc_subsection = copy.deepcopy(self.subsection_dict) + doc_subsection["tags"] = {} + doc_subsection["collections"] = {'display_name': [], 'key': []} + doc_subsection["sections"] = {'display_name': ['Section 1'], 'key': ['lct:org1:lib:section:section-1']} + doc_section = copy.deepcopy(self.section_dict) + doc_section["tags"] = {} + doc_section["collections"] = {'display_name': [], 'key': []} + + api.rebuild_index(incremental=True) + assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 4 + mock_meilisearch.return_value.index.return_value.add_documents.assert_has_calls( + [ + call([doc_sequential, doc_vertical]), + call([doc_problem1, doc_problem2]), + call([doc_collection]), + call([doc_unit, doc_subsection, doc_section]), + ], + any_order=True, + ) + + # Now we simulate interruption by passing this function to the status_cb argument + def simulated_interruption(message): + # this exception prevents courses from being indexed + if "Indexing courses" in message: + raise Exception("Simulated interruption") + + with pytest.raises(Exception, match="Simulated interruption"): + api.rebuild_index(simulated_interruption, incremental=True) + + # three more calls due to collections and containers + assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 7 + assert IncrementalIndexCompleted.objects.all().count() == 1 + api.rebuild_index(incremental=True) + assert IncrementalIndexCompleted.objects.all().count() == 0 + # one missing course indexed + assert mock_meilisearch.return_value.index.return_value.add_documents.call_count == 8 + + @override_settings(MEILISEARCH_ENABLED=True) + def test_reset_meilisearch_index(self, mock_meilisearch) -> None: + api.reset_index() + mock_meilisearch.return_value.swap_indexes.assert_called_once() + mock_meilisearch.return_value.create_index.assert_called_once() + mock_meilisearch.return_value.delete_index.call_count = 2 + api.reset_index() + mock_meilisearch.return_value.delete_index.call_count = 4 + + @override_settings(MEILISEARCH_ENABLED=True) + def test_init_meilisearch_index(self, mock_meilisearch) -> None: + # Test index already exists + api.init_index() + mock_meilisearch.return_value.swap_indexes.assert_not_called() + mock_meilisearch.return_value.create_index.assert_not_called() + mock_meilisearch.return_value.delete_index.assert_not_called() + + # Test index already exists and has no documents + mock_meilisearch.return_value.get_stats.return_value = 0 + api.init_index() + mock_meilisearch.return_value.swap_indexes.assert_not_called() + mock_meilisearch.return_value.create_index.assert_not_called() + mock_meilisearch.return_value.delete_index.assert_not_called() + + mock_meilisearch.return_value.get_index.side_effect = [ + MeilisearchApiError("Testing reindex", Mock(text='{"code":"index_not_found"}')), + MeilisearchApiError("Testing reindex", Mock(text='{"code":"index_not_found"}')), + Mock(created_at=1), + Mock(created_at=1), + Mock(created_at=1), + ] + api.init_index() + mock_meilisearch.return_value.swap_indexes.assert_called_once() + mock_meilisearch.return_value.create_index.assert_called_once() + mock_meilisearch.return_value.delete_index.call_count = 2 + @override_settings(MEILISEARCH_ENABLED=True) @patch( "openedx.core.djangoapps.content.search.api.searchable_doc_for_collection", Mock(side_effect=Exception("Failed to generate document")), ) - def test_reindex_meilisearch_collection_error(self, mock_meilisearch): + def test_reindex_meilisearch_collection_error(self, mock_meilisearch) -> None: mock_logger = Mock() api.rebuild_index(mock_logger) @@ -253,7 +488,23 @@ class TestSearchApi(ModuleStoreTestCase): ) @override_settings(MEILISEARCH_ENABLED=True) - def test_reindex_meilisearch_library_block_error(self, mock_meilisearch): + @patch( + "openedx.core.djangoapps.content.search.api.searchable_doc_for_container", + Mock(side_effect=Exception("Failed to generate document")), + ) + def test_reindex_meilisearch_container_error(self, mock_meilisearch) -> None: + + mock_logger = Mock() + api.rebuild_index(mock_logger) + assert call( + [self.unit_dict] + ) not in mock_meilisearch.return_value.index.return_value.add_documents.mock_calls + mock_logger.assert_any_call( + "Error indexing container unit-1: Failed to generate document" + ) + + @override_settings(MEILISEARCH_ENABLED=True) + def test_reindex_meilisearch_library_block_error(self, mock_meilisearch) -> None: # Add tags field to doc, since reindex calls includes tags doc_sequential = copy.deepcopy(self.doc_sequential) @@ -263,6 +514,7 @@ class TestSearchApi(ModuleStoreTestCase): doc_problem2 = copy.deepcopy(self.doc_problem2) doc_problem2["tags"] = {} doc_problem2["collections"] = {'display_name': [], 'key': []} + doc_problem2["units"] = {'display_name': [], 'key': []} orig_from_component = library_api.LibraryXBlockMetadata.from_component @@ -310,7 +562,7 @@ class TestSearchApi(ModuleStoreTestCase): False ) @override_settings(MEILISEARCH_ENABLED=True) - def test_index_xblock_metadata(self, recursive, mock_meilisearch): + def test_index_xblock_metadata(self, recursive, mock_meilisearch) -> None: """ Test indexing an XBlock. """ @@ -324,18 +576,17 @@ class TestSearchApi(ModuleStoreTestCase): mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with(expected_docs) @override_settings(MEILISEARCH_ENABLED=True) - def test_no_index_excluded_xblocks(self, mock_meilisearch): + def test_no_index_excluded_xblocks(self, mock_meilisearch) -> None: api.upsert_xblock_index_doc(UsageKey.from_string(self.course_block_key)) mock_meilisearch.return_value.index.return_value.update_document.assert_not_called() @override_settings(MEILISEARCH_ENABLED=True) - def test_index_xblock_tags(self, mock_meilisearch): + def test_index_xblock_tags(self, mock_meilisearch) -> None: """ Test indexing an XBlock with tags. """ - - # Tag XBlock (these internally call `upsert_block_tags_index_docs`) + # Tag XBlock (these internally call `upsert_content_object_tags_index_doc`) tagging_api.tag_object(str(self.sequential.usage_key), self.taxonomyA, ["one", "two"]) tagging_api.tag_object(str(self.sequential.usage_key), self.taxonomyB, ["three", "four"]) @@ -365,7 +616,7 @@ class TestSearchApi(ModuleStoreTestCase): ) @override_settings(MEILISEARCH_ENABLED=True) - def test_delete_index_xblock(self, mock_meilisearch): + def test_delete_index_xblock(self, mock_meilisearch) -> None: """ Test deleting an XBlock doc from the index. """ @@ -376,7 +627,7 @@ class TestSearchApi(ModuleStoreTestCase): ) @override_settings(MEILISEARCH_ENABLED=True) - def test_index_library_block_metadata(self, mock_meilisearch): + def test_index_library_block_metadata(self, mock_meilisearch) -> None: """ Test indexing a Library Block. """ @@ -385,7 +636,7 @@ class TestSearchApi(ModuleStoreTestCase): mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with([self.doc_problem1]) @override_settings(MEILISEARCH_ENABLED=True) - def test_index_library_block_tags(self, mock_meilisearch): + def test_index_library_block_tags(self, mock_meilisearch) -> None: """ Test indexing an Library Block with tags. """ @@ -420,7 +671,7 @@ class TestSearchApi(ModuleStoreTestCase): ) @override_settings(MEILISEARCH_ENABLED=True) - def test_index_library_block_and_collections(self, mock_meilisearch): + def test_index_library_block_and_collections(self, mock_meilisearch) -> None: """ Test indexing an Library Block and the Collections it's in. """ @@ -443,16 +694,16 @@ class TestSearchApi(ModuleStoreTestCase): description="Second Collection", ) - # Add Problem1 to both Collections (these internally call `upsert_block_collections_index_docs` and + # Add Problem1 to both Collections (these internally call `upsert_item_collections_index_docs` and # `upsert_library_collection_index_doc`) # (adding in reverse order to test sorting of collection tag) updated_date = datetime(2023, 6, 7, 8, 9, 10, tzinfo=timezone.utc) with freeze_time(updated_date): for collection in (collection2, collection1): - library_api.update_library_collection_components( + library_api.update_library_collection_items( self.library.key, collection_key=collection.key, - usage_keys=[ + opaque_keys=[ self.problem1.usage_key, ], ) @@ -472,6 +723,9 @@ class TestSearchApi(ModuleStoreTestCase): "created": created_date.timestamp(), "modified": created_date.timestamp(), "access_id": lib_access.id, + "published": { + "num_children": 0 + }, "breadcrumbs": [{"display_name": "Library"}], } doc_collection2_created = { @@ -487,6 +741,9 @@ class TestSearchApi(ModuleStoreTestCase): "created": created_date.timestamp(), "modified": created_date.timestamp(), "access_id": lib_access.id, + "published": { + "num_children": 0 + }, "breadcrumbs": [{"display_name": "Library"}], } doc_collection2_updated = { @@ -502,6 +759,9 @@ class TestSearchApi(ModuleStoreTestCase): "created": created_date.timestamp(), "modified": updated_date.timestamp(), "access_id": lib_access.id, + "published": { + "num_children": 0 + }, "breadcrumbs": [{"display_name": "Library"}], } doc_collection1_updated = { @@ -517,6 +777,9 @@ class TestSearchApi(ModuleStoreTestCase): "created": created_date.timestamp(), "modified": updated_date.timestamp(), "access_id": lib_access.id, + "published": { + "num_children": 0 + }, "breadcrumbs": [{"display_name": "Library"}], } doc_problem_with_collection1 = { @@ -548,7 +811,7 @@ class TestSearchApi(ModuleStoreTestCase): ) @override_settings(MEILISEARCH_ENABLED=True) - def test_delete_index_library_block(self, mock_meilisearch): + def test_delete_index_library_block(self, mock_meilisearch) -> None: """ Test deleting a Library Block doc from the index. """ @@ -559,7 +822,7 @@ class TestSearchApi(ModuleStoreTestCase): ) @override_settings(MEILISEARCH_ENABLED=True) - def test_index_content_library_metadata(self, mock_meilisearch): + def test_index_content_library_metadata(self, mock_meilisearch) -> None: """ Test indexing a whole content library. """ @@ -570,25 +833,10 @@ class TestSearchApi(ModuleStoreTestCase): ) @override_settings(MEILISEARCH_ENABLED=True) - def test_delete_all_drafts(self, mock_meilisearch): - """ - Test deleting all draft documents from the index. - """ - api.delete_all_draft_docs_for_library(self.library.key) - - delete_filter = [ - f'context_key="{self.library.key}"', - ['last_published IS EMPTY', 'last_published IS NULL'], - ] - mock_meilisearch.return_value.index.return_value.delete_documents.assert_called_once_with( - filter=delete_filter - ) - - @override_settings(MEILISEARCH_ENABLED=True) - def test_index_tags_in_collections(self, mock_meilisearch): + def test_index_tags_in_collections(self, mock_meilisearch) -> None: # Tag collection - tagging_api.tag_object(self.collection_usage_key, self.taxonomyA, ["one", "two"]) - tagging_api.tag_object(self.collection_usage_key, self.taxonomyB, ["three", "four"]) + tagging_api.tag_object(str(self.collection_key), self.taxonomyA, ["one", "two"]) + tagging_api.tag_object(str(self.collection_key), self.taxonomyB, ["three", "four"]) # Build expected docs with tags at each stage doc_collection_with_tags1 = { @@ -616,23 +864,24 @@ class TestSearchApi(ModuleStoreTestCase): ) @override_settings(MEILISEARCH_ENABLED=True) - def test_delete_collection(self, mock_meilisearch): + def test_delete_collection(self, mock_meilisearch) -> None: """ Test soft-deleting, restoring, and hard-deleting a collection. """ # Add a component to the collection updated_date = datetime(2023, 6, 7, 8, 9, 10, tzinfo=timezone.utc) with freeze_time(updated_date): - library_api.update_library_collection_components( + library_api.update_library_collection_items( self.library.key, collection_key=self.collection.key, - usage_keys=[ + opaque_keys=[ self.problem1.usage_key, + self.unit.container_key ], ) doc_collection = copy.deepcopy(self.collection_dict) - doc_collection["num_children"] = 1 + doc_collection["num_children"] = 2 doc_collection["modified"] = updated_date.timestamp() doc_problem_with_collection = { "id": self.doc_problem1["id"], @@ -641,13 +890,21 @@ class TestSearchApi(ModuleStoreTestCase): "key": [self.collection.key], }, } + doc_unit_with_collection = { + "id": self.unit_dict["id"], + "collections": { + "display_name": [self.collection.title], + "key": [self.collection.key], + }, + } # Should update the collection and its component - assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 2 + assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 3 mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( [ call([doc_collection]), call([doc_problem_with_collection]), + call([doc_unit_with_collection]), ], any_order=True, ) @@ -663,15 +920,23 @@ class TestSearchApi(ModuleStoreTestCase): "id": self.doc_problem1["id"], "collections": {'display_name': [], 'key': []}, } + doc_unit_without_collection = { + "id": self.unit_dict["id"], + "collections": {'display_name': [], 'key': []}, + } # Should delete the collection document mock_meilisearch.return_value.index.return_value.delete_document.assert_called_once_with( self.collection_dict["id"], ) # ...and update the component's "collections" field - mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with([ - doc_problem_without_collection, - ]) + mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( + [ + call([doc_problem_without_collection]), + call([doc_unit_without_collection]), + ], + any_order=True, + ) mock_meilisearch.return_value.index.reset_mock() # We need to mock get_document here so that when we restore the collection below, meilisearch knows the @@ -687,15 +952,16 @@ class TestSearchApi(ModuleStoreTestCase): ) doc_collection = copy.deepcopy(self.collection_dict) - doc_collection["num_children"] = 1 + doc_collection["num_children"] = 2 doc_collection["modified"] = restored_date.timestamp() # Should update the collection and its component's "collections" field - assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 2 + assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 3 mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( [ call([doc_collection]), call([doc_problem_with_collection]), + call([doc_unit_with_collection]), ], any_order=True, ) @@ -713,6 +979,242 @@ class TestSearchApi(ModuleStoreTestCase): self.collection_dict["id"], ) # ...and cascade delete updates the "collections" field for the associated components - mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with([ - doc_problem_without_collection, - ]) + mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( + [ + call([doc_problem_without_collection]), + call([doc_unit_without_collection]), + ], + any_order=True, + ) + + @ddt.data( + "unit", + "subsection", + "section", + ) + @override_settings(MEILISEARCH_ENABLED=True) + def test_delete_index_container(self, container_type, mock_meilisearch) -> None: + """ + Test delete a container index. + """ + container = getattr(self, container_type) + container_dict = getattr(self, f"{container_type}_dict") + update_doc_calls = [] + + def clear_contents(data: dict): + return { + **data, + "num_children": 0, + "content": { + "child_usage_keys": [], + "child_display_names": [], + }, + } + if container_type == "unit": + update_doc_calls.append(call([clear_contents(self.subsection_dict)])) + elif container_type == "subsection": + update_doc_calls.append(call([clear_contents(self.section_dict)])) + update_doc_calls.append(call([{ + 'id': self.unit_dict['id'], + 'subsections': {'display_name': [], 'key': []}, + }])) + elif container_type == "section": + update_doc_calls.append(call([{ + 'id': self.subsection_dict['id'], + 'sections': {'display_name': [], 'key': []}, + }])) + + library_api.delete_container(container.container_key) + + mock_meilisearch.return_value.index.return_value.delete_document.assert_called_once_with( + container_dict["id"], + ) + # Parent containers index data is updated. + if update_doc_calls: + mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( + update_doc_calls, + any_order=True, + ) + + # Restore + library_api.restore_container(container.container_key) + if container_type == "unit": + update_doc_calls.append(call([self.subsection_dict])) + elif container_type == "subsection": + update_doc_calls.append(call([self.section_dict])) + update_doc_calls.append(call([{ + 'id': self.unit_dict['id'], + 'subsections': { + 'display_name': [self.subsection_dict['display_name']], + 'key': [self.subsection_key], + }, + }])) + elif container_type == "section": + update_doc_calls.append(call([{ + 'id': self.subsection_dict['id'], + 'sections': { + 'display_name': [self.section_dict['display_name']], + 'key': [self.section_key], + }, + }])) + # Parent containers index data is updated on restore again. + if update_doc_calls: + mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( + update_doc_calls, + any_order=True, + ) + + @ddt.data( + "unit", + "subsection", + "section", + ) + @override_settings(MEILISEARCH_ENABLED=True) + def test_index_library_container_metadata(self, container_type, mock_meilisearch) -> None: + """ + Test indexing a Library Container. + """ + container = getattr(self, container_type) + container_dict = getattr(self, f"{container_type}_dict") + api.upsert_library_container_index_doc(container.container_key) + + mock_meilisearch.return_value.index.return_value.update_documents.assert_called_once_with([container_dict]) + + @ddt.data( + ("unit", "lctorg1libunitunit-1-e4527f7c"), + ("subsection", "lctorg1libsubsectionsubsection-1-cf808309"), + ("section", "lctorg1libsectionsection-1-dc4791a4"), + ) + @ddt.unpack + @override_settings(MEILISEARCH_ENABLED=True) + def test_index_tags_in_containers(self, container_type, container_id, mock_meilisearch) -> None: + container_key = getattr(self, f"{container_type}_key") + + # Tag container + tagging_api.tag_object(container_key, self.taxonomyA, ["one", "two"]) + tagging_api.tag_object(container_key, self.taxonomyB, ["three", "four"]) + + # Build expected docs with tags at each stage + doc_unit_with_tags1 = { + "id": container_id, + "tags": { + 'taxonomy': ['A'], + 'level0': ['A > one', 'A > two'] + } + } + doc_unit_with_tags2 = { + "id": container_id, + "tags": { + 'taxonomy': ['A', 'B'], + 'level0': ['A > one', 'A > two', 'B > four', 'B > three'] + } + } + + assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 2 + mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( + [ + call([doc_unit_with_tags1]), + call([doc_unit_with_tags2]), + ], + any_order=True, + ) + + @override_settings(MEILISEARCH_ENABLED=True) + def test_block_in_units(self, mock_meilisearch) -> None: + with freeze_time(self.created_date): + library_api.update_container_children( + LibraryContainerLocator.from_string(self.unit_key), + [self.problem1.usage_key], + None, + ) + + doc_block_with_units = { + "id": self.doc_problem1["id"], + "units": { + "display_name": [self.unit.display_name], + "key": [self.unit_key], + }, + } + new_unit_dict = { + **self.unit_dict, + "num_children": 1, + 'content': { + 'child_usage_keys': [self.doc_problem1["usage_key"]], + 'child_display_names': [self.doc_problem1["display_name"]], + } + } + + assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 2 + mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( + [ + call([doc_block_with_units]), + call([new_unit_dict]), + ], + any_order=True, + ) + + @override_settings(MEILISEARCH_ENABLED=True) + def test_units_in_subsection(self, mock_meilisearch) -> None: + with freeze_time(self.created_date): + library_api.update_container_children( + LibraryContainerLocator.from_string(self.subsection_key), + [LibraryContainerLocator.from_string(self.unit_key)], + None, + ) + + doc_block_with_subsections = { + "id": self.unit_dict["id"], + "subsections": { + "display_name": [self.subsection.display_name], + "key": [self.subsection_key], + }, + } + new_subsection_dict = { + **self.subsection_dict, + "num_children": 1, + 'content': { + 'child_usage_keys': [self.unit_key], + 'child_display_names': [self.unit.display_name] + } + } + assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 2 + mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( + [ + call([doc_block_with_subsections]), + call([new_subsection_dict]), + ], + any_order=True, + ) + + @override_settings(MEILISEARCH_ENABLED=True) + def test_section_in_usbsections(self, mock_meilisearch) -> None: + with freeze_time(self.created_date): + library_api.update_container_children( + LibraryContainerLocator.from_string(self.section_key), + [LibraryContainerLocator.from_string(self.subsection_key)], + None, + ) + + doc_block_with_sections = { + "id": self.subsection_dict["id"], + "sections": { + "display_name": [self.section.display_name], + "key": [self.section_key], + }, + } + new_section_dict = { + **self.section_dict, + "num_children": 1, + 'content': { + 'child_usage_keys': [self.subsection_key], + 'child_display_names': [self.subsection.display_name], + } + } + assert mock_meilisearch.return_value.index.return_value.update_documents.call_count == 2 + mock_meilisearch.return_value.index.return_value.update_documents.assert_has_calls( + [ + call([doc_block_with_sections]), + call([new_section_dict]), + ], + any_order=True, + ) diff --git a/openedx/core/djangoapps/content/search/tests/test_documents.py b/openedx/core/djangoapps/content/search/tests/test_documents.py index e0a604c24f..5a8a2231dc 100644 --- a/openedx/core/djangoapps/content/search/tests/test_documents.py +++ b/openedx/core/djangoapps/content/search/tests/test_documents.py @@ -1,13 +1,17 @@ """ Tests for the Studio content search documents (what gets stored in the index) """ +import ddt +from dataclasses import replace from datetime import datetime, timezone -from organizations.models import Organization from freezegun import freeze_time +from opaque_keys.edx.locator import LibraryCollectionLocator, LibraryContainerLocator +from openedx_learning.api import authoring as authoring_api +from organizations.models import Organization -from openedx.core.djangoapps.content_tagging import api as tagging_api from openedx.core.djangoapps.content_libraries import api as library_api +from openedx.core.djangoapps.content_tagging import api as tagging_api from openedx.core.djangolib.testing.utils import skip_unless_cms from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase @@ -16,19 +20,19 @@ from xmodule.modulestore.tests.factories import BlockFactory, ToyCourseFactory try: # This import errors in the lms because content.search is not an installed app there. from ..documents import ( - searchable_doc_for_course_block, - searchable_doc_tags, - searchable_doc_tags_for_collection, searchable_doc_collections, searchable_doc_for_collection, + searchable_doc_for_container, + searchable_doc_for_course_block, searchable_doc_for_library_block, + searchable_doc_tags, ) from ..models import SearchAccess except RuntimeError: searchable_doc_for_course_block = lambda x: x searchable_doc_tags = lambda x: x - searchable_doc_tags_for_collection = lambda x: x searchable_doc_for_collection = lambda x: x + searchable_doc_for_container = lambda x: x searchable_doc_for_library_block = lambda x: x SearchAccess = {} @@ -37,6 +41,7 @@ STUDIO_SEARCH_ENDPOINT_URL = "/api/content_search/v2/studio/" @skip_unless_cms +@ddt.ddt class StudioDocumentsTest(SharedModuleStoreTestCase): """ Tests for the Studio content search documents (what gets stored in the @@ -47,23 +52,23 @@ class StudioDocumentsTest(SharedModuleStoreTestCase): def setUpClass(cls): super().setUpClass() cls.store = modulestore() - cls.org = Organization.objects.create(name="edX", short_name="edX") - cls.toy_course = ToyCourseFactory.create() # See xmodule/modulestore/tests/sample_courses.py - cls.toy_course_key = cls.toy_course.id - - # Get references to some blocks in the toy course - cls.html_block_key = cls.toy_course_key.make_usage_key("html", "toyjumpto") - # Create a problem in course - cls.problem_block = BlockFactory.create( - category="problem", - parent_location=cls.toy_course_key.make_usage_key("vertical", "vertical_test"), - display_name='Test Problem', - data="What is a test?", - ) - # Create a library and collection with a block - created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc) - with freeze_time(created_date): + cls.created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc) + with freeze_time(cls.created_date): + # Get references to some blocks in the toy course + cls.org = Organization.objects.create(name="edX", short_name="edX") + cls.toy_course = ToyCourseFactory.create() # See xmodule/modulestore/tests/sample_courses.py + cls.toy_course_key = cls.toy_course.id + + cls.html_block_key = cls.toy_course_key.make_usage_key("html", "toyjumpto") + # Create a problem in course + cls.problem_block = BlockFactory.create( + category="problem", + parent_location=cls.toy_course_key.make_usage_key("vertical", "vertical_test"), + display_name='Test Problem', + data="What is a test?", + ) + cls.library = library_api.create_library( org=cls.org, slug="2012_Fall", @@ -77,18 +82,50 @@ class StudioDocumentsTest(SharedModuleStoreTestCase): created_by=None, description="my toy collection description" ) - cls.collection_usage_key = "lib-collection:edX:2012_Fall:TOY_COLLECTION" + cls.collection_key = LibraryCollectionLocator.from_string( + "lib-collection:edX:2012_Fall:TOY_COLLECTION", + ) cls.library_block = library_api.create_library_block( cls.library.key, "html", "text2", ) + cls.container = library_api.create_container( + cls.library.key, + container_type=library_api.ContainerType.Unit, + slug="unit1", + title="A Unit in the Search Index", + user_id=None, + ) + cls.container_key = LibraryContainerLocator.from_string( + "lct:edX:2012_Fall:unit:unit1", + ) + cls.subsection = library_api.create_container( + cls.library.key, + container_type=library_api.ContainerType.Subsection, + slug="subsection1", + title="A Subsection in the Search Index", + user_id=None, + ) + cls.subsection_key = LibraryContainerLocator.from_string( + "lct:edX:2012_Fall:subsection:subsection1", + ) + cls.section = library_api.create_container( + cls.library.key, + container_type=library_api.ContainerType.Section, + slug="section1", + title="A Section in the Search Index", + user_id=None, + ) + cls.section_key = LibraryContainerLocator.from_string( + "lct:edX:2012_Fall:section:section1", + ) # Add the problem block to the collection - library_api.update_library_collection_components( + library_api.update_library_collection_items( cls.library.key, collection_key="TOY_COLLECTION", - usage_keys=[ + opaque_keys=[ cls.library_block.usage_key, ] ) @@ -111,7 +148,10 @@ class StudioDocumentsTest(SharedModuleStoreTestCase): tagging_api.tag_object(str(cls.html_block_key), cls.subject_tags, tags=["Chinese", "Jump Links"]) tagging_api.tag_object(str(cls.html_block_key), cls.difficulty_tags, tags=["Normal"]) tagging_api.tag_object(str(cls.library_block.usage_key), cls.difficulty_tags, tags=["Normal"]) - tagging_api.tag_object(cls.collection_usage_key, cls.difficulty_tags, tags=["Normal"]) + tagging_api.tag_object(str(cls.collection_key), cls.difficulty_tags, tags=["Normal"]) + tagging_api.tag_object(str(cls.container_key), cls.difficulty_tags, tags=["Normal"]) + tagging_api.tag_object(str(cls.subsection_key), cls.difficulty_tags, tags=["Normal"]) + tagging_api.tag_object(str(cls.section_key), cls.difficulty_tags, tags=["Normal"]) @property def toy_course_access_id(self): @@ -172,6 +212,7 @@ class StudioDocumentsTest(SharedModuleStoreTestCase): 'usage_key': 'block-v1:edX+toy+2012_Fall+type@vertical+block@vertical_test', }, ], + "modified": self.created_date.timestamp(), "content": { "capa_content": "What is a test?", "problem_types": ["multiplechoiceresponse"], @@ -205,6 +246,7 @@ class StudioDocumentsTest(SharedModuleStoreTestCase): "display_name": "Text", "description": "This is a link to another page and some Chinese 四節比分和七年前 Some " "more Chinese 四節比分和七年前 ", + "modified": self.created_date.timestamp(), "breadcrumbs": [ { 'display_name': 'Toy Course', @@ -258,6 +300,7 @@ class StudioDocumentsTest(SharedModuleStoreTestCase): }, ], "content": {}, + "modified": self.created_date.timestamp(), # This video has no tags. } @@ -298,6 +341,7 @@ class StudioDocumentsTest(SharedModuleStoreTestCase): "taxonomy": ["Difficulty"], "level0": ["Difficulty > Normal"], }, + "publish_status": "never", } def test_html_published_library_block(self): @@ -337,6 +381,7 @@ class StudioDocumentsTest(SharedModuleStoreTestCase): "level0": ["Difficulty > Normal"], }, 'published': {'display_name': 'Text'}, + "publish_status": "published", } # Update library block to create a draft @@ -378,6 +423,7 @@ class StudioDocumentsTest(SharedModuleStoreTestCase): "level0": ["Difficulty > Normal"], }, "published": {"display_name": "Text"}, + "publish_status": "published", } # Publish new changes @@ -420,16 +466,28 @@ class StudioDocumentsTest(SharedModuleStoreTestCase): "display_name": "Text 2", "description": "This is a Test", }, + "publish_status": "published", } + # Verify publish status is set to modified + library_block_modified = replace( + self.library_block, + modified=datetime(2024, 4, 5, 6, 7, 8, tzinfo=timezone.utc), + last_published=datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc), + ) + doc = searchable_doc_for_library_block(library_block_modified) + doc.update(searchable_doc_tags(library_block_modified.usage_key)) + doc.update(searchable_doc_collections(library_block_modified.usage_key)) + assert doc["publish_status"] == "modified" + def test_collection_with_library(self): - doc = searchable_doc_for_collection(self.library.key, self.collection.key) - doc.update(searchable_doc_tags_for_collection(self.library.key, self.collection.key)) + doc = searchable_doc_for_collection(self.collection_key) + doc.update(searchable_doc_tags(self.collection_key)) assert doc == { "id": "lib-collectionedx2012_falltoy_collection-d1d907a4", "block_id": self.collection.key, - "usage_key": self.collection_usage_key, + "usage_key": str(self.collection_key), "type": "collection", "org": "edX", "display_name": "Toy Collection", @@ -443,5 +501,330 @@ class StudioDocumentsTest(SharedModuleStoreTestCase): 'tags': { 'taxonomy': ['Difficulty'], 'level0': ['Difficulty > Normal'] + }, + "published": { + "num_children": 0 } } + + def test_collection_with_published_library(self): + library_api.publish_changes(self.library.key) + + doc = searchable_doc_for_collection(self.collection_key) + doc.update(searchable_doc_tags(self.collection_key)) + + assert doc == { + "id": "lib-collectionedx2012_falltoy_collection-d1d907a4", + "block_id": self.collection.key, + "usage_key": str(self.collection_key), + "type": "collection", + "org": "edX", + "display_name": "Toy Collection", + "description": "my toy collection description", + "num_children": 1, + "context_key": "lib:edX:2012_Fall", + "access_id": self.library_access_id, + "breadcrumbs": [{"display_name": "some content_library"}], + "created": 1680674828.0, + "modified": 1680674828.0, + 'tags': { + 'taxonomy': ['Difficulty'], + 'level0': ['Difficulty > Normal'] + }, + "published": { + "num_children": 1 + } + } + + @ddt.data( + ("container", "unit1", "unit", "edd13a0c"), + ("subsection", "subsection1", "subsection", "c6c172be"), + ("section", "section1", "section", "79ee8fa2"), + ) + @ddt.unpack + def test_draft_container(self, container_name, container_slug, container_type, doc_id): + """ + Test creating a search document for a draft-only container + """ + container = getattr(self, container_name) + doc = searchable_doc_for_container(container.container_key) + doc.update(searchable_doc_tags(container.container_key)) + + assert doc == { + "id": f"lctedx2012_fall{container_type}{container_slug}-{doc_id}", + "block_id": container_slug, + "block_type": container_type, + "usage_key": f"lct:edX:2012_Fall:{container_type}:{container_slug}", + "type": "library_container", + "org": "edX", + "display_name": container.display_name, + # description is not set for containers + "num_children": 0, + "content": { + "child_usage_keys": [], + "child_display_names": [], + }, + "publish_status": "never", + "context_key": "lib:edX:2012_Fall", + "access_id": self.library_access_id, + "breadcrumbs": [{"display_name": "some content_library"}], + "created": 1680674828.0, + "modified": 1680674828.0, + "last_published": None, + "tags": { + "taxonomy": ["Difficulty"], + "level0": ["Difficulty > Normal"] + }, + # "published" is not set since we haven't published it yet + } + + def test_published_container(self): + """ + Test creating a search document for a published container + """ + with freeze_time(self.container.created): + # Create a container with a block in it + library_api.update_container_children( + self.container.container_key, + [self.library_block.usage_key], + user_id=None, + ) + library_api.publish_changes(self.library.key) + + doc = searchable_doc_for_container(self.container.container_key) + doc.update(searchable_doc_tags(self.container.container_key)) + + assert doc == { + "id": "lctedx2012_fallunitunit1-edd13a0c", + "block_id": "unit1", + "block_type": "unit", + "usage_key": "lct:edX:2012_Fall:unit:unit1", + "type": "library_container", + "org": "edX", + "display_name": "A Unit in the Search Index", + # description is not set for containers + "num_children": 1, + "content": { + "child_usage_keys": [ + "lb:edX:2012_Fall:html:text2", + ], + "child_display_names": [ + "Text", + ], + }, + "publish_status": "published", + "context_key": "lib:edX:2012_Fall", + "access_id": self.library_access_id, + "breadcrumbs": [{"display_name": "some content_library"}], + "created": 1680674828.0, + "modified": 1680674828.0, + "last_published": 1680674828.0, + "tags": { + "taxonomy": ["Difficulty"], + "level0": ["Difficulty > Normal"] + }, + "published": { + "num_children": 1, + "display_name": "A Unit in the Search Index", + "content": { + "child_usage_keys": [ + "lb:edX:2012_Fall:html:text2", + ], + "child_display_names": [ + "Text", + ], + }, + }, + } + + def test_published_container_with_changes(self): + """ + Test creating a search document for a published container + """ + library_api.update_container_children( + self.container.container_key, + [self.library_block.usage_key], + user_id=None, + ) + with freeze_time(self.container.created): + library_api.publish_changes(self.library.key) + block_2 = library_api.create_library_block( + self.library.key, + "html", + "text3", + ) + + # Add another component after publish + with freeze_time(self.container.created): + library_api.update_container_children( + self.container.container_key, + [block_2.usage_key], + user_id=None, + entities_action=authoring_api.ChildrenEntitiesAction.APPEND, + ) + + doc = searchable_doc_for_container(self.container.container_key) + doc.update(searchable_doc_tags(self.container.container_key)) + + assert doc == { + "id": "lctedx2012_fallunitunit1-edd13a0c", + "block_id": "unit1", + "block_type": "unit", + "usage_key": "lct:edX:2012_Fall:unit:unit1", + "type": "library_container", + "org": "edX", + "display_name": "A Unit in the Search Index", + # description is not set for containers + "num_children": 2, + "content": { + "child_usage_keys": [ + "lb:edX:2012_Fall:html:text2", + "lb:edX:2012_Fall:html:text3", + ], + "child_display_names": [ + "Text", + "Text", + ], + }, + "publish_status": "modified", + "context_key": "lib:edX:2012_Fall", + "access_id": self.library_access_id, + "breadcrumbs": [{"display_name": "some content_library"}], + "created": 1680674828.0, + "modified": 1680674828.0, + "last_published": 1680674828.0, + "tags": { + "taxonomy": ["Difficulty"], + "level0": ["Difficulty > Normal"] + }, + "published": { + "num_children": 1, + "display_name": "A Unit in the Search Index", + "content": { + "child_usage_keys": [ + "lb:edX:2012_Fall:html:text2", + ], + "child_display_names": [ + "Text", + ], + }, + }, + } + + def test_mathjax_plain_text_conversion_for_search(self): + """ + Test how an HTML block with mathjax equations gets converted to plain text in search description. + """ + # pylint: disable=line-too-long + eqns = [ + # (input, expected output) + ('Simple addition: \\( 2 + 3 \\)', 'Simple addition: 2 + 3'), + ('Simple subtraction: \\( 5 - 2 \\)', 'Simple subtraction: 5 − 2'), + ('Simple multiplication: \\( 4 * 6 \\)', 'Simple multiplication: 4 * 6'), + ('Simple division: \\( 8 / 2 \\)', 'Simple division: 8 / 2'), + ('Mixed arithmetic: \\( 2 + 3 4 \\)', 'Mixed arithmetic: 2 + 3 4'), + ('Simple exponentiation: \\[ 2^3 \\]', 'Simple exponentiation: 2³'), + ('Root extraction: \\[ 16^{1/2} \\]', 'Root extraction: 16¹^/²'), + ('Exponent with multiple terms: \\[ (2 + 3)^2 \\]', 'Exponent with multiple terms: (2 + 3)²'), + ('Nested exponents: \\[ 2^(3^2) \\]', 'Nested exponents: 2⁽3²)'), + ('Mixed roots: \\[ 8^{1/2} 3^2 \\]', 'Mixed roots: 8¹^/² 3²'), + ('Simple fraction: [mathjaxinline] 3/4 [/mathjaxinline]', 'Simple fraction: 3/4'), + ( + 'Decimal to fraction conversion: [mathjaxinline] 0.75 = 3/4 [/mathjaxinline]', + 'Decimal to fraction conversion: 0.75 = 3/4', + ), + ('Mixed fractions: [mathjaxinline] 1 1/2 = 3/2 [/mathjaxinline]', 'Mixed fractions: 1 1/2 = 3/2'), + ( + 'Converting decimals to mixed fractions: [mathjaxinline] 2.5 = 5/2 [/mathjaxinline]', + 'Converting decimals to mixed fractions: 2.5 = 5/2', + ), + ( + 'Trig identities: [mathjaxinline] \\sin(x + y) = \\sin(x) \\cos(y) + \\cos(x) \\sin(y) [/mathjaxinline]', + 'Trig identities: sin(x + y) = sin(x) cos(y) + cos(x) sin(y)', + ), + ( + 'Sine, cosine, and tangent: [mathjaxinline] \\sin(x) [/mathjaxinline] [mathjaxinline] \\cos(x) [/mathjaxinline] [mathjaxinline] \\tan(x) [/mathjaxinline]', + 'Sine, cosine, and tangent: sin(x) cos(x) tan(x)', + ), + ( + 'Hyperbolic trig functions: [mathjaxinline] \\sinh(x) [/mathjaxinline] [mathjaxinline] \\cosh(x) [/mathjaxinline]', + 'Hyperbolic trig functions: sinh(x) cosh(x)', + ), + ( + "Simple derivative: [mathjax] f(x) = x^2, f'(x) = 2x [/mathjax]", + "Simple derivative: f(x) = x², f'(x) = 2x", + ), + ('Double integral: [mathjax] int\\int (x + y) dxdy [/mathjax]', 'Double integral: int∫ (x + y) dxdy'), + ( + 'Partial derivatives: [mathjax] f(x,y) = xy, \\frac{\\partial f}{\\partial x} = y [/mathjax] [mathjax] \\frac{\\partial f}{\\partial y} = x [/mathjax]', + 'Partial derivatives: f(x,y) = xy, (∂ f/∂ x) = y (∂ f/∂ y) = x', + ), + ( + 'Mean and standard deviation: [mathjax] mu = 2, \\sigma = 1 [/mathjax]', + 'Mean and standard deviation: mu = 2, σ = 1', + ), + ( + 'Binomial probability: [mathjax] P(X = k) = (\\binom{n}{k} p^k (1-p)^{n-k}) [/mathjax]', + 'Binomial probability: P(X = k) = (\\binom{n}{k} pᵏ (1−p)ⁿ⁻ᵏ)', + ), + ('Gaussian distribution: [mathjax] N(\\mu, \\sigma^2) [/mathjax]', 'Gaussian distribution: N(μ, σ²)'), + ( + 'Greek letters: [mathjaxinline] \\alpha [/mathjaxinline] [mathjaxinline] \\beta [/mathjaxinline] [mathjaxinline] \\gamma [/mathjaxinline]', + 'Greek letters: α β γ', + ), + ( + 'Subscripted variables: [mathjaxinline] x_i [/mathjaxinline] [mathjaxinline] y_j [/mathjaxinline]', + 'Subscripted variables: xᵢ yⱼ', + ), + ('Superscripted variables: [mathjaxinline] x^{i} [/mathjaxinline]', 'Superscripted variables: xⁱ'), + ( + 'Not supported: \\( \\begin{bmatrix} 1 & 0 \\ 0 & 1 \\end{bmatrix} = I \\)', + 'Not supported: \\begin{bmatrix} 1 & 0 \\ 0 & 1 \\end{bmatrix} = I', + ), + ( + 'Bold text: \\( {\\bf a} \\cdot {\\bf b} = |{\\bf a}| |{\\bf b}| \\cos(\\theta) \\)', + 'Bold text: a ⋅ b = |a| |b| cos(θ)', + ), + ('Bold text: \\( \\frac{\\sqrt{\\mathbf{2}+3}}{\\sqrt{4}} \\)', 'Bold text: (√{2+3}/√{4})'), + ('Nested Bold text 1: \\( \\mathbf{ \\frac{1}{2} } \\)', 'Nested Bold text 1: (1/2)'), + ( + 'Nested Bold text 2: \\( \\mathbf{a \\cdot (a \\mathbf{\\times} b)} \\)', + 'Nested Bold text 2: a ⋅ (a × b)' + ), + ( + 'Nested Bold text 3: \\( \\mathbf{a \\cdot (a \\bm{\\times} b)} \\)', + 'Nested Bold text 3: a ⋅ (a × b)' + ), + ('Sqrt test 1: \\(\\sqrt\\)', 'Sqrt test 1: √'), + ('Sqrt test 2: \\(x^2 + \\sqrt(y)\\)', 'Sqrt test 2: x² + √(y)'), + ('Sqrt test 3: [mathjaxinline]x^2 + \\sqrt(y)[/mathjaxinline]', 'Sqrt test 3: x² + √(y)'), + ('Fraction test 1: \\( \\frac{2} {3} \\)', 'Fraction test 1: (2/3)'), + ('Fraction test 2: \\( \\frac{2}{3} \\)', 'Fraction test 2: (2/3)'), + ('Fraction test 3: \\( \\frac{\\frac{2}{3}}{4} \\)', 'Fraction test 3: ((2/3)/4)'), + ('Fraction test 4: \\( \\frac{\\frac{2} {3}}{4} \\)', 'Fraction test 4: ((2/3)/4)'), + ('Fraction test 5: \\( \\frac{\\frac{2} {3}}{\\frac{4}{3}} \\)', 'Fraction test 5: ((2/3)/(4/3))'), + # Invalid equations. + ('Fraction error: \\( \\frac{2} \\)', 'Fraction error: \\frac{2}'), + ('Fraction error 2: \\( \\frac{\\frac{2}{3}{4} \\)', 'Fraction error 2: \\frac{\\frac{2}{3}{4}'), + ('Unclosed: [mathjaxinline]x^2', 'Unclosed: [mathjaxinline]x^2'), + ( + 'Missing closing bracket: \\( \\frac{\\frac{2} {3}{\\frac{4}{3}} \\)', + 'Missing closing bracket: \\frac{\\frac{2} {3}{\\frac{4}{3}}' + ), + ('No equation: normal text', 'No equation: normal text'), + ] + # pylint: enable=line-too-long + block = BlockFactory.create( + parent_location=self.toy_course.location, + category="html", + display_name="Non-default HTML Block", + editor="raw", + use_latex_compiler=True, + data="|||".join(e[0] for e in eqns), + ) + doc = {} + doc.update(searchable_doc_for_course_block(block)) + doc.update(searchable_doc_tags(block.usage_key)) + result = doc['description'].split('|||') + for i, eqn in enumerate(result): + assert eqn.strip() == eqns[i][1] diff --git a/openedx/core/djangoapps/content/search/tests/test_handlers.py b/openedx/core/djangoapps/content/search/tests/test_handlers.py index 3577cbfc56..33d0e4db83 100644 --- a/openedx/core/djangoapps/content/search/tests/test_handlers.py +++ b/openedx/core/djangoapps/content/search/tests/test_handlers.py @@ -59,7 +59,9 @@ class TestUpdateIndexHandlers(ModuleStoreTestCase, LiveServerTestCase): course_access, _ = SearchAccess.objects.get_or_create(context_key=course.id) # Create XBlocks - sequential = self.store.create_child(self.user_id, course.location, "sequential", "test_sequential") + created_date = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc) + with freeze_time(created_date): + sequential = self.store.create_child(self.user_id, course.location, "sequential", "test_sequential") doc_sequential = { "id": "block-v1orgatest_coursetest_runtypesequentialblocktest_sequential-0cdb9395", "type": "course_block", @@ -76,10 +78,11 @@ class TestUpdateIndexHandlers(ModuleStoreTestCase, LiveServerTestCase): ], "content": {}, "access_id": course_access.id, - + "modified": created_date.timestamp(), } meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([doc_sequential]) - vertical = self.store.create_child(self.user_id, sequential.location, "vertical", "test_vertical") + with freeze_time(created_date): + vertical = self.store.create_child(self.user_id, sequential.location, "vertical", "test_vertical") doc_vertical = { "id": "block-v1orgatest_coursetest_runtypeverticalblocktest_vertical-011f143b", "type": "course_block", @@ -100,6 +103,7 @@ class TestUpdateIndexHandlers(ModuleStoreTestCase, LiveServerTestCase): ], "content": {}, "access_id": course_access.id, + "modified": created_date.timestamp(), } meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([doc_vertical]) @@ -107,11 +111,14 @@ class TestUpdateIndexHandlers(ModuleStoreTestCase, LiveServerTestCase): # Update the XBlock sequential = self.store.get_item(sequential.location, self.user_id) # Refresh the XBlock sequential.display_name = "Updated Sequential" - self.store.update_item(sequential, self.user_id) + modified_date = datetime(2024, 5, 6, 7, 8, 9, tzinfo=timezone.utc) + with freeze_time(modified_date): + self.store.update_item(sequential, self.user_id) # The display name and the child's breadcrumbs should be updated doc_sequential["display_name"] = "Updated Sequential" doc_vertical["breadcrumbs"][1]["display_name"] = "Updated Sequential" + doc_sequential["modified"] = modified_date.timestamp() meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([ doc_sequential, doc_vertical, @@ -153,6 +160,7 @@ class TestUpdateIndexHandlers(ModuleStoreTestCase, LiveServerTestCase): "last_published": None, "created": created_date.timestamp(), "modified": created_date.timestamp(), + "publish_status": "never", } meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([doc_problem]) @@ -177,6 +185,7 @@ class TestUpdateIndexHandlers(ModuleStoreTestCase, LiveServerTestCase): library_api.publish_changes(library.key) doc_problem["last_published"] = published_date.timestamp() doc_problem["published"] = {"display_name": "Blank Problem"} + doc_problem["publish_status"] = "published" meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([doc_problem]) # Delete the Library Block @@ -185,3 +194,13 @@ class TestUpdateIndexHandlers(ModuleStoreTestCase, LiveServerTestCase): meilisearch_client.return_value.index.return_value.delete_document.assert_called_with( "lborgalib_aproblemproblem1-ca3186e9" ) + + # Restore the Library Block + library_api.restore_library_block(problem.usage_key) + meilisearch_client.return_value.index.return_value.update_documents.assert_any_call([doc_problem]) + meilisearch_client.return_value.index.return_value.update_documents.assert_any_call( + [{'id': doc_problem['id'], 'collections': {'display_name': [], 'key': []}}] + ) + meilisearch_client.return_value.index.return_value.update_documents.assert_any_call( + [{'id': doc_problem['id'], 'tags': {}}] + ) diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py deleted file mode 100644 index 3a4b9535bb..0000000000 --- a/openedx/core/djangoapps/content_libraries/api.py +++ /dev/null @@ -1,1851 +0,0 @@ -""" -Python API for content libraries -================================ - -Via ``views.py``, most of these API methods are also exposed as a REST API. - -The API methods in this file are focused on authoring and specific to content -libraries; they wouldn't necessarily apply or work in other learning contexts -such as courses, blogs, "pathways," etc. - -** As this is an authoring-focused API, all API methods in this file deal with -the DRAFT version of the content library.** - -Some of these methods will work and may be used from the LMS if needed (mostly -for test setup; other use is discouraged), but some of the implementation -details rely on Studio so other methods will raise errors if called from the -LMS. (The REST API is not available at all from the LMS.) - -Any APIs that use/affect content libraries but are generic enough to work in -other learning contexts too are in the core XBlock python/REST API at -``openedx.core.djangoapps.xblock.api/rest_api``. - -For example, to render a content library XBlock as HTML, one can use the -generic: - - render_block_view(block, view_name, user) - -That is an API in ``openedx.core.djangoapps.xblock.api`` (use it from Studio for -the draft version, from the LMS for published version). - -There are one or two methods in this file that have some overlap with the core -XBlock API; for example, this content library API provides a -``get_library_block()`` which returns metadata about an XBlock; it's in this API -because it also returns data about whether or not the XBlock has unpublished -edits, which is an authoring-only concern. Likewise, APIs for getting/setting -an individual XBlock's OLX directly seem more appropriate for small, reusable -components in content libraries and may not be appropriate for other learning -contexts so they are implemented here in the library API only. In the future, -if we find a need for these in most other learning contexts then those methods -could be promoted to the core XBlock API and made generic. - -Import from Courseware ----------------------- - -Content Libraries can import blocks from Courseware (Modulestore). The import -can be done per-course, by listing its content, and supports both access to -remote platform instances as well as local modulestore APIs. Additionally, -there are Celery-based interfaces suitable for background processing controlled -through RESTful APIs (see :mod:`.views`). -""" -from __future__ import annotations - -import abc -import collections -from datetime import datetime, timezone -import base64 -import hashlib -import logging -import mimetypes - - -import attr -import requests - -from django.conf import settings -from django.contrib.auth.models import AbstractUser, Group -from django.core.exceptions import ObjectDoesNotExist, PermissionDenied -from django.core.validators import validate_unicode_slug -from django.db import IntegrityError, transaction -from django.db.models import Q, QuerySet -from django.utils.translation import gettext as _ -from edx_rest_api_client.client import OAuthAPIClient -from django.urls import reverse -from lxml import etree -from opaque_keys.edx.keys import BlockTypeKey, UsageKey, UsageKeyV2 -from opaque_keys.edx.locator import ( - LibraryLocatorV2, - LibraryUsageLocatorV2, - LibraryLocator as LibraryLocatorV1, - LibraryCollectionLocator, -) -from openedx_events.content_authoring.data import ( - ContentLibraryData, - LibraryBlockData, - LibraryCollectionData, -) -from openedx_events.content_authoring.signals import ( - CONTENT_LIBRARY_CREATED, - CONTENT_LIBRARY_DELETED, - CONTENT_LIBRARY_UPDATED, - LIBRARY_BLOCK_CREATED, - LIBRARY_BLOCK_DELETED, - LIBRARY_BLOCK_UPDATED, - LIBRARY_COLLECTION_UPDATED, -) -from openedx_learning.api import authoring as authoring_api -from openedx_learning.api.authoring_models import ( - Collection, - Component, - ComponentVersion, - MediaType, - LearningPackage, - PublishableEntity, -) -from organizations.models import Organization -from xblock.core import XBlock -from xblock.exceptions import XBlockNotFoundError - -from openedx.core.djangoapps.xblock.api import ( - get_component_from_usage_key, - get_xblock_app_config, - xblock_type_display_name, -) -from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_for_learning_core -from xmodule.modulestore.django import modulestore - -from . import permissions, tasks -from .constants import ALL_RIGHTS_RESERVED -from .models import ContentLibrary, ContentLibraryPermission, ContentLibraryBlockImportTask - -log = logging.getLogger(__name__) - - -# Exceptions -# ========== - - -ContentLibraryNotFound = ContentLibrary.DoesNotExist - -ContentLibraryCollectionNotFound = Collection.DoesNotExist - - -class ContentLibraryBlockNotFound(XBlockNotFoundError): - """ XBlock not found in the content library """ - - -class LibraryAlreadyExists(KeyError): - """ A library with the specified slug already exists """ - - -class LibraryCollectionAlreadyExists(IntegrityError): - """ A Collection with that key already exists in the library """ - - -class LibraryBlockAlreadyExists(KeyError): - """ An XBlock with that ID already exists in the library """ - - -class BlockLimitReachedError(Exception): - """ Maximum number of allowed XBlocks in the library reached """ - - -class IncompatibleTypesError(Exception): - """ Library type constraint violated """ - - -class InvalidNameError(ValueError): - """ The specified name/identifier is not valid """ - - -class LibraryPermissionIntegrityError(IntegrityError): - """ Thrown when an operation would cause insane permissions. """ - - -# Models -# ====== - - -@attr.s -class ContentLibraryMetadata: - """ - Class that represents the metadata about a content library. - """ - key = attr.ib(type=LibraryLocatorV2) - learning_package = attr.ib(type=LearningPackage) - title = attr.ib("") - description = attr.ib("") - num_blocks = attr.ib(0) - version = attr.ib(0) - last_published = attr.ib(default=None, type=datetime) - last_draft_created = attr.ib(default=None, type=datetime) - last_draft_created_by = attr.ib(default=None, type=datetime) - published_by = attr.ib("") - has_unpublished_changes = attr.ib(False) - # has_unpublished_deletes will be true when the draft version of the library's bundle - # contains deletes of any XBlocks that were in the most recently published version - has_unpublished_deletes = attr.ib(False) - allow_lti = attr.ib(False) - # Allow any user (even unregistered users) to view and interact directly - # with this library's content in the LMS - allow_public_learning = attr.ib(False) - # Allow any user with Studio access to view this library's content in - # Studio, use it in their courses, and copy content out of this library. - allow_public_read = attr.ib(False) - license = attr.ib("") - created = attr.ib(default=None, type=datetime) - updated = attr.ib(default=None, type=datetime) - - -class AccessLevel: - """ Enum defining library access levels/permissions """ - ADMIN_LEVEL = ContentLibraryPermission.ADMIN_LEVEL - AUTHOR_LEVEL = ContentLibraryPermission.AUTHOR_LEVEL - READ_LEVEL = ContentLibraryPermission.READ_LEVEL - NO_ACCESS = None - - -@attr.s -class ContentLibraryPermissionEntry: - """ - A user or group granted permission to use a content library. - """ - user = attr.ib(type=AbstractUser, default=None) - group = attr.ib(type=Group, default=None) - access_level = attr.ib(AccessLevel.NO_ACCESS) - - -@attr.s -class CollectionMetadata: - """ - Class to represent collection metadata in a content library. - """ - key = attr.ib(type=str) - title = attr.ib(type=str) - - -@attr.s -class LibraryXBlockMetadata: - """ - Class that represents the metadata about an XBlock in a content library. - """ - usage_key = attr.ib(type=LibraryUsageLocatorV2) - created = attr.ib(type=datetime) - modified = attr.ib(type=datetime) - draft_version_num = attr.ib(type=int) - published_version_num = attr.ib(default=None, type=int) - display_name = attr.ib("") - last_published = attr.ib(default=None, type=datetime) - last_draft_created = attr.ib(default=None, type=datetime) - last_draft_created_by = attr.ib("") - published_by = attr.ib("") - has_unpublished_changes = attr.ib(False) - created = attr.ib(default=None, type=datetime) - collections = attr.ib(type=list[CollectionMetadata], factory=list) - - @classmethod - def from_component(cls, library_key, component, associated_collections=None): - """ - Construct a LibraryXBlockMetadata from a Component object. - """ - last_publish_log = component.versioning.last_publish_log - - published_by = None - if last_publish_log and last_publish_log.published_by: - published_by = last_publish_log.published_by.username - - draft = component.versioning.draft - published = component.versioning.published - last_draft_created = draft.created if draft else None - last_draft_created_by = draft.publishable_entity_version.created_by if draft else None - - return cls( - usage_key=library_component_usage_key( - library_key, - component, - ), - display_name=draft.title, - created=component.created, - modified=draft.created, - draft_version_num=draft.version_num, - published_version_num=published.version_num if published else None, - last_published=None if last_publish_log is None else last_publish_log.published_at, - published_by=published_by, - last_draft_created=last_draft_created, - last_draft_created_by=last_draft_created_by, - has_unpublished_changes=component.versioning.has_unpublished_changes, - collections=associated_collections or [], - ) - - -@attr.s -class LibraryXBlockStaticFile: - """ - Class that represents a static file in a content library, associated with - a particular XBlock. - """ - # File path e.g. "diagram.png" - # In some rare cases it might contain a folder part, e.g. "en/track1.srt" - path = attr.ib("") - # Publicly accessible URL where the file can be downloaded - url = attr.ib("") - # Size in bytes - size = attr.ib(0) - - -@attr.s -class LibraryXBlockType: - """ - An XBlock type that can be added to a content library - """ - block_type = attr.ib("") - display_name = attr.ib("") - - -# General APIs -# ============ - - -def get_libraries_for_user(user, org=None, text_search=None, order=None): - """ - Return content libraries that the user has permission to view. - """ - filter_kwargs = {} - if org: - filter_kwargs['org__short_name'] = org - qs = ContentLibrary.objects.filter(**filter_kwargs) \ - .select_related('learning_package', 'org') \ - .order_by('org__short_name', 'slug') - - if text_search: - qs = qs.filter( - Q(slug__icontains=text_search) | - Q(org__short_name__icontains=text_search) | - Q(learning_package__title__icontains=text_search) | - Q(learning_package__description__icontains=text_search) - ) - - filtered = permissions.perms[permissions.CAN_VIEW_THIS_CONTENT_LIBRARY].filter(user, qs) - - if order: - order_query = 'learning_package__' - valid_order_fields = ['title', 'created', 'updated'] - # If order starts with a -, that means order descending (default is ascending) - if order.startswith('-'): - order_query = f"-{order_query}" - order = order[1:] - - if order in valid_order_fields: - return filtered.order_by(f"{order_query}{order}") - else: - log.exception(f"Error ordering libraries by {order}: Invalid order field") - - return filtered - - -def get_metadata(queryset, text_search=None): - """ - Take a list of ContentLibrary objects and return metadata from Learning Core. - """ - if text_search: - queryset = queryset.filter(org__short_name__icontains=text_search) - - libraries = [ - # TODO: Do we really need these fields for the library listing view? - # It's actually going to be pretty expensive to compute this over a - # large list. If we do need it, it might need to go into a denormalized - # form, e.g. a new table for stats that it can join to, even if we don't - # guarantee accuracy (because of possible race conditions). - ContentLibraryMetadata( - key=lib.library_key, - title=lib.learning_package.title if lib.learning_package else "", - description="", - version=0, - allow_public_learning=lib.allow_public_learning, - allow_public_read=lib.allow_public_read, - - # These are currently dummy values to maintain the REST API contract - # while we shift to Learning Core models. - num_blocks=0, - last_published=None, - has_unpublished_changes=False, - has_unpublished_deletes=False, - license=lib.license, - learning_package=lib.learning_package, - ) - for lib in queryset - ] - return libraries - - -def require_permission_for_library_key(library_key, user, permission) -> ContentLibrary: - """ - Given any of the content library permission strings defined in - openedx.core.djangoapps.content_libraries.permissions, - check if the given user has that permission for the library with the - specified library ID. - - Raises django.core.exceptions.PermissionDenied if the user doesn't have - permission. - """ - library_obj = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] - if not user.has_perm(permission, obj=library_obj): - raise PermissionDenied - - return library_obj - - -def get_library(library_key): - """ - Get the library with the specified key. Does not check permissions. - returns a ContentLibraryMetadata instance. - - Raises ContentLibraryNotFound if the library doesn't exist. - """ - ref = ContentLibrary.objects.get_by_key(library_key) - learning_package = ref.learning_package - num_blocks = authoring_api.get_all_drafts(learning_package.id).count() - last_publish_log = authoring_api.get_last_publish(learning_package.id) - last_draft_log = authoring_api.get_entities_with_unpublished_changes(learning_package.id) \ - .order_by('-created').first() - last_draft_created = last_draft_log.created if last_draft_log else None - last_draft_created_by = last_draft_log.created_by.username if last_draft_log and last_draft_log.created_by else None - has_unpublished_changes = last_draft_log is not None - - # TODO: I'm doing this one to match already-existing behavior, but this is - # something that we should remove. It exists to accomodate some complexities - # with how Blockstore staged changes, but Learning Core works differently, - # and has_unpublished_changes should be sufficient. - # Ref: https://github.com/openedx/edx-platform/issues/34283 - has_unpublished_deletes = authoring_api.get_entities_with_unpublished_deletes(learning_package.id) \ - .exists() - - # Learning Core doesn't really have a notion of a global version number,but - # we can sort of approximate it by using the primary key of the last publish - # log entry, in the sense that it will be a monotonically increasing - # integer, though there will be large gaps. We use 0 to denote that nothing - # has been done, since that will never be a valid value for a PublishLog pk. - # - # That being said, we should figure out if we really even want to keep a top - # level version indicator for the Library as a whole. In the v1 libs - # implemention, this served as a way to know whether or not there was an - # updated version of content that a course could pull in. But more recently, - # we've decided to do those version references at the level of the - # individual blocks being used, since a Learning Core backed library is - # intended to be referenced in multiple course locations and not 1:1 like v1 - # libraries. The top level version stays for now because LegacyLibraryContentBlock - # uses it, but that should hopefully change before the Redwood release. - version = 0 if last_publish_log is None else last_publish_log.pk - published_by = None - if last_publish_log and last_publish_log.published_by: - published_by = last_publish_log.published_by.username - - return ContentLibraryMetadata( - key=library_key, - title=learning_package.title, - description=ref.learning_package.description, - num_blocks=num_blocks, - version=version, - last_published=None if last_publish_log is None else last_publish_log.published_at, - published_by=published_by, - last_draft_created=last_draft_created, - last_draft_created_by=last_draft_created_by, - allow_lti=ref.allow_lti, - allow_public_learning=ref.allow_public_learning, - allow_public_read=ref.allow_public_read, - has_unpublished_changes=has_unpublished_changes, - has_unpublished_deletes=has_unpublished_deletes, - license=ref.license, - created=learning_package.created, - updated=learning_package.updated, - learning_package=learning_package - ) - - -def create_library( - org, - slug, - title, - description="", - allow_public_learning=False, - allow_public_read=False, - library_license=ALL_RIGHTS_RESERVED, -): - """ - Create a new content library. - - org: an organizations.models.Organization instance - - slug: a slug for this library like 'physics-problems' - - title: title for this library - - description: description of this library - - allow_public_learning: Allow anyone to read/learn from blocks in the LMS - - allow_public_read: Allow anyone to view blocks (including source) in Studio? - - Returns a ContentLibraryMetadata instance. - """ - assert isinstance(org, Organization) - validate_unicode_slug(slug) - try: - with transaction.atomic(): - ref = ContentLibrary.objects.create( - org=org, - slug=slug, - allow_public_learning=allow_public_learning, - allow_public_read=allow_public_read, - license=library_license, - ) - learning_package = authoring_api.create_learning_package( - key=str(ref.library_key), - title=title, - description=description, - ) - ref.learning_package = learning_package - ref.save() - - except IntegrityError: - raise LibraryAlreadyExists(slug) # lint-amnesty, pylint: disable=raise-missing-from - - CONTENT_LIBRARY_CREATED.send_event( - content_library=ContentLibraryData( - library_key=ref.library_key - ) - ) - return ContentLibraryMetadata( - key=ref.library_key, - title=title, - description=description, - num_blocks=0, - version=0, - last_published=None, - allow_public_learning=ref.allow_public_learning, - allow_public_read=ref.allow_public_read, - license=library_license, - learning_package=ref.learning_package - ) - - -def get_library_team(library_key): - """ - Get the list of users/groups granted permission to use this library. - """ - ref = ContentLibrary.objects.get_by_key(library_key) - return [ - ContentLibraryPermissionEntry(user=entry.user, group=entry.group, access_level=entry.access_level) - for entry in ref.permission_grants.all() - ] - - -def get_library_user_permissions(library_key, user): - """ - Fetch the specified user's access information. Will return None if no - permissions have been granted. - """ - ref = ContentLibrary.objects.get_by_key(library_key) - grant = ref.permission_grants.filter(user=user).first() - if grant is None: - return None - return ContentLibraryPermissionEntry( - user=grant.user, - group=grant.group, - access_level=grant.access_level, - ) - - -def set_library_user_permissions(library_key, user, access_level): - """ - Change the specified user's level of access to this library. - - access_level should be one of the AccessLevel values defined above. - """ - ref = ContentLibrary.objects.get_by_key(library_key) - current_grant = get_library_user_permissions(library_key, user) - if current_grant and current_grant.access_level == AccessLevel.ADMIN_LEVEL: - if not ref.permission_grants.filter(access_level=AccessLevel.ADMIN_LEVEL).exclude(user_id=user.id).exists(): - raise LibraryPermissionIntegrityError(_('Cannot change or remove the access level for the only admin.')) - - if access_level is None: - ref.permission_grants.filter(user=user).delete() - else: - ContentLibraryPermission.objects.update_or_create( - library=ref, - user=user, - defaults={"access_level": access_level}, - ) - - -def set_library_group_permissions(library_key, group, access_level): - """ - Change the specified group's level of access to this library. - - access_level should be one of the AccessLevel values defined above. - """ - ref = ContentLibrary.objects.get_by_key(library_key) - - if access_level is None: - ref.permission_grants.filter(group=group).delete() - else: - ContentLibraryPermission.objects.update_or_create( - library=ref, - group=group, - defaults={"access_level": access_level}, - ) - - -def update_library( - library_key, - title=None, - description=None, - allow_public_learning=None, - allow_public_read=None, - library_license=None, -): - """ - Update a library's metadata - (Slug cannot be changed as it would break IDs throughout the system.) - - A value of None means "don't change". - """ - lib_obj_fields = [ - allow_public_learning, allow_public_read, library_license - ] - lib_obj_changed = any(field is not None for field in lib_obj_fields) - learning_pkg_changed = any(field is not None for field in [title, description]) - - # If nothing's changed, just return early. - if (not lib_obj_changed) and (not learning_pkg_changed): - return - - content_lib = ContentLibrary.objects.get_by_key(library_key) - - with transaction.atomic(): - # We need to make updates to both the ContentLibrary and its linked - # LearningPackage. - if lib_obj_changed: - if allow_public_learning is not None: - content_lib.allow_public_learning = allow_public_learning - if allow_public_read is not None: - content_lib.allow_public_read = allow_public_read - if library_license is not None: - content_lib.library_license = library_license - content_lib.save() - - if learning_pkg_changed: - authoring_api.update_learning_package( - content_lib.learning_package_id, - title=title, - description=description, - ) - - CONTENT_LIBRARY_UPDATED.send_event( - content_library=ContentLibraryData( - library_key=content_lib.library_key - ) - ) - - return content_lib - - -def delete_library(library_key): - """ - Delete a content library - """ - with transaction.atomic(): - content_lib = ContentLibrary.objects.get_by_key(library_key) - learning_package = content_lib.learning_package - content_lib.delete() - - # TODO: Move the LearningPackage delete() operation to an API call - # TODO: We should eventually detach the LearningPackage and delete it - # asynchronously, especially if we need to delete a bunch of stuff - # on the filesystem for it. - learning_package.delete() - - CONTENT_LIBRARY_DELETED.send_event( - content_library=ContentLibraryData( - library_key=library_key - ) - ) - - -def _get_library_component_tags_count(library_key) -> dict: - """ - Get the count of tags that are applied to each component in this library, as a dict. - """ - # Import content_tagging.api here to avoid circular imports - from openedx.core.djangoapps.content_tagging.api import get_object_tag_counts - - # Create a pattern to match the IDs of the library components, e.g. "lb:org:id*" - library_key_pattern = str(library_key).replace("lib:", "lb:", 1) + "*" - return get_object_tag_counts(library_key_pattern, count_implicit=True) - - -def get_library_components(library_key, text_search=None, block_types=None) -> QuerySet[Component]: - """ - Get the library components and filter. - - TODO: Full text search needs to be implemented as a custom lookup for MySQL, - but it should have a fallback to still work in SQLite. - """ - lib = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] - learning_package = lib.learning_package - components = authoring_api.get_components( - learning_package.id, - draft=True, - namespace='xblock.v1', - type_names=block_types, - draft_title=text_search, - ) - return components - - -def get_library_block(usage_key, include_collections=False) -> LibraryXBlockMetadata: - """ - Get metadata about (the draft version of) one specific XBlock in a library. - - This will raise ContentLibraryBlockNotFound if there is no draft version of - this block (i.e. it's been soft-deleted from Studio), even if there is a - live published version of it in the LMS. - """ - try: - component = get_component_from_usage_key(usage_key) - except ObjectDoesNotExist as exc: - raise ContentLibraryBlockNotFound(usage_key) from exc - - # The component might have existed at one point, but no longer does because - # the draft was soft-deleted. This is actually a weird edge case and I'm not - # clear on what the proper behavior should be, since (a) the published - # version still exists; and (b) we might want to make some queries on the - # block even after it's been removed, since there might be versioned - # references to it. - draft_version = component.versioning.draft - if not draft_version: - raise ContentLibraryBlockNotFound(usage_key) - - if include_collections: - associated_collections = authoring_api.get_entity_collections( - component.learning_package_id, - component.key, - ).values('key', 'title') - else: - associated_collections = None - xblock_metadata = LibraryXBlockMetadata.from_component( - library_key=usage_key.context_key, - component=component, - associated_collections=associated_collections, - ) - return xblock_metadata - - -def set_library_block_olx(usage_key, new_olx_str) -> ComponentVersion: - """ - Replace the OLX source of the given XBlock. - - This is only meant for use by developers or API client applications, as - very little validation is done and this can easily result in a broken XBlock - that won't load. - - Returns the version number of the newly created ComponentVersion. - """ - # because this old pylint can't understand attr.ib() objects, pylint: disable=no-member - assert isinstance(usage_key, LibraryUsageLocatorV2) - - # HTMLBlock uses CDATA to preserve HTML inside the XML, so make sure we - # don't strip that out. - parser = etree.XMLParser(strip_cdata=False) - - # Verify that the OLX parses, at least as generic XML, and the root tag is correct: - node = etree.fromstring(new_olx_str, parser=parser) - if node.tag != usage_key.block_type: - raise ValueError( - f"Tried to set the OLX of a {usage_key.block_type} block to a <{node.tag}> node. " - f"{usage_key=!s}, {new_olx_str=}" - ) - - # We're intentionally NOT checking if the XBlock type is installed, since - # this is one of the only tools you can reach for to edit content for an - # XBlock that's broken or missing. - component = get_component_from_usage_key(usage_key) - - # Get the title from the new OLX (or default to the default specified on the - # XBlock's display_name field. - new_title = node.attrib.get( - "display_name", - xblock_type_display_name(usage_key.block_type), - ) - - # Libraries don't use the url_name attribute, because they encode that into - # the Component key. Normally this is stripped out by the XBlockSerializer, - # but we're not actually creating the XBlock when it's coming from the - # clipboard right now. - if "url_name" in node.attrib: - del node.attrib["url_name"] - new_olx_str = etree.tostring(node, encoding='unicode') - - now = datetime.now(tz=timezone.utc) - - with transaction.atomic(): - new_content = authoring_api.get_or_create_text_content( - component.learning_package_id, - get_or_create_olx_media_type(usage_key.block_type).id, - text=new_olx_str, - created=now, - ) - new_component_version = authoring_api.create_next_component_version( - component.pk, - title=new_title, - content_to_replace={ - 'block.xml': new_content.pk, - }, - created=now, - ) - - LIBRARY_BLOCK_UPDATED.send_event( - library_block=LibraryBlockData( - library_key=usage_key.context_key, - usage_key=usage_key - ) - ) - - return new_component_version - - -def library_component_usage_key( - library_key: LibraryLocatorV2, - component: Component, -) -> LibraryUsageLocatorV2: - """ - Returns a LibraryUsageLocatorV2 for the given library + component. - """ - return LibraryUsageLocatorV2( # type: ignore[abstract] - library_key, - block_type=component.component_type.name, - usage_id=component.local_key, - ) - - -def validate_can_add_block_to_library( - library_key: LibraryLocatorV2, - block_type: str, - block_id: str, -) -> tuple[ContentLibrary, LibraryUsageLocatorV2]: - """ - Perform checks to validate whether a new block with `block_id` and type `block_type` can be added to - the library with key `library_key`. - - Returns the ContentLibrary that has the passed in `library_key` and newly created LibraryUsageLocatorV2 if - validation successful, otherwise raises errors. - """ - assert isinstance(library_key, LibraryLocatorV2) - content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] - - # If adding a component would take us over our max, return an error. - component_count = authoring_api.get_all_drafts(content_library.learning_package.id).count() - if component_count + 1 > settings.MAX_BLOCKS_PER_CONTENT_LIBRARY: - raise BlockLimitReachedError( - _("Library cannot have more than {} Components").format( - settings.MAX_BLOCKS_PER_CONTENT_LIBRARY - ) - ) - - # Make sure the proposed ID will be valid: - validate_unicode_slug(block_id) - # Ensure the XBlock type is valid and installed: - XBlock.load_class(block_type) # Will raise an exception if invalid - # Make sure the new ID is not taken already: - usage_key = LibraryUsageLocatorV2( # type: ignore[abstract] - lib_key=library_key, - block_type=block_type, - usage_id=block_id, - ) - - if _component_exists(usage_key): - raise LibraryBlockAlreadyExists(f"An XBlock with ID '{usage_key}' already exists") - - return content_library, usage_key - - -def create_library_block(library_key, block_type, definition_id, user_id=None): - """ - Create a new XBlock in this library of the specified type (e.g. "html"). - """ - # It's in the serializer as ``definition_id``, but for our purposes, it's - # the block_id. See the comments in ``LibraryXBlockCreationSerializer`` for - # more details. TODO: Change the param name once we change the serializer. - block_id = definition_id - - content_library, usage_key = validate_can_add_block_to_library(library_key, block_type, block_id) - - _create_component_for_block(content_library, usage_key, user_id) - - # Now return the metadata about the new block: - LIBRARY_BLOCK_CREATED.send_event( - library_block=LibraryBlockData( - library_key=content_library.library_key, - usage_key=usage_key - ) - ) - - return get_library_block(usage_key) - - -def _component_exists(usage_key: UsageKeyV2) -> bool: - """ - Does a Component exist for this usage key? - - This is a lower-level function that will return True if a Component object - exists, even if it was soft-deleted, and there is no active draft version. - """ - try: - get_component_from_usage_key(usage_key) - except ObjectDoesNotExist: - return False - return True - - -def import_staged_content_from_user_clipboard(library_key: LibraryLocatorV2, user, block_id) -> XBlock: - """ - Create a new library block and populate it with staged content from clipboard - - Returns the newly created library block - """ - from openedx.core.djangoapps.content_staging import api as content_staging_api - if not content_staging_api: - raise RuntimeError("The required content_staging app is not installed") - - user_clipboard = content_staging_api.get_user_clipboard(user) - if not user_clipboard: - return None - - staged_content_id = user_clipboard.content.id - olx_str = content_staging_api.get_staged_content_olx(staged_content_id) - staged_content_files = content_staging_api.get_staged_content_static_files(staged_content_id) - - content_library, usage_key = validate_can_add_block_to_library( - library_key, - user_clipboard.content.block_type, - block_id - ) - - # content_library.learning_package is technically a nullable field because - # it was added in a later migration, but we can't actually make a Library - # without one at the moment. TODO: fix this at the model level. - learning_package: LearningPackage = content_library.learning_package # type: ignore - - now = datetime.now(tz=timezone.utc) - - # Create component for block then populate it with clipboard data - with transaction.atomic(): - # First create the Component, but do not initialize it to anything (i.e. - # no ComponentVersion). - component_type = authoring_api.get_or_create_component_type( - "xblock.v1", usage_key.block_type - ) - component = authoring_api.create_component( - learning_package.id, - component_type=component_type, - local_key=usage_key.block_id, - created=now, - created_by=user.id, - ) - - # This will create the first component version and set the OLX/title - # appropriately. It will not publish. Once we get the newly created - # ComponentVersion back from this, we can attach all our files to it. - component_version = set_library_block_olx(usage_key, olx_str) - - for staged_content_file_data in staged_content_files: - # The ``data`` attribute is going to be None because the clipboard - # is optimized to not do redundant file copying when copying/pasting - # within the same course (where all the Files and Uploads are - # shared). Learning Core backed content Components will always store - # a Component-local "copy" of the data, and rely on lower-level - # deduplication to happen in the ``contents`` app. - filename = staged_content_file_data.filename - - # Grab our byte data for the file... - file_data = content_staging_api.get_staged_content_static_file_data( - staged_content_id, - filename, - ) - if not file_data: - log.error( - f"Staged content {staged_content_id} included referenced " - f"file {filename}, but no file data was found." - ) - continue - - # Courses don't support having assets that are local to a specific - # component, and instead store all their content together in a - # shared Files and Uploads namespace. If we're pasting that into a - # Learning Core backed data model (v2 Libraries), then we want to - # prepend "static/" to the filename. This will need to get updated - # when we start moving courses over to Learning Core, or if we start - # storing course component assets in sub-directories of Files and - # Uploads. - # - # The reason we don't just search for a "static/" prefix is that - # Learning Core components can store other kinds of files if they - # wish (though none currently do). - source_assumes_global_assets = not isinstance( - user_clipboard.source_context_key, LibraryLocatorV2 - ) - if source_assumes_global_assets: - filename = f"static/{filename}" - - # Now construct the Learning Core data models for it... - # TODO: more of this logic should be pushed down to openedx-learning - media_type_str, _encoding = mimetypes.guess_type(filename) - if not media_type_str: - media_type_str = "application/octet-stream" - - media_type = authoring_api.get_or_create_media_type(media_type_str) - content = authoring_api.get_or_create_file_content( - learning_package.id, - media_type.id, - data=file_data, - created=now, - ) - authoring_api.create_component_version_content( - component_version.pk, - content.id, - key=filename, - ) - - # Emit library block created event - LIBRARY_BLOCK_CREATED.send_event( - library_block=LibraryBlockData( - library_key=content_library.library_key, - usage_key=usage_key - ) - ) - - # Now return the metadata about the new block - return get_library_block(usage_key) - - -def get_or_create_olx_media_type(block_type: str) -> MediaType: - """ - Get or create a MediaType for the block type. - - Learning Core stores all Content with a Media Type (a.k.a. MIME type). For - OLX, we use the "application/vnd.*" convention, per RFC 6838. - """ - return authoring_api.get_or_create_media_type( - f"application/vnd.openedx.xblock.v1.{block_type}+xml" - ) - - -def _create_component_for_block(content_lib, usage_key, user_id=None): - """ - Create a Component for an XBlock type, initialize it, and return the ComponentVersion. - - This will create a Component, along with its first ComponentVersion. The tag - in the OLX will have no attributes, e.g. ``. This first version - will be set as the current draft. This function does not publish the - Component. - - TODO: We should probably shift this to openedx.core.djangoapps.xblock.api - (along with its caller) since it gives runtime storage specifics. The - Library-specific logic stays in this module, so "create a block for my lib" - should stay here, but "making a block means creating a component with - text data like X" goes in xblock.api. - """ - display_name = xblock_type_display_name(usage_key.block_type) - now = datetime.now(tz=timezone.utc) - xml_text = f'<{usage_key.block_type} />' - - learning_package = content_lib.learning_package - - with transaction.atomic(): - component_type = authoring_api.get_or_create_component_type( - "xblock.v1", usage_key.block_type - ) - component, component_version = authoring_api.create_component_and_version( - learning_package.id, - component_type=component_type, - local_key=usage_key.block_id, - title=display_name, - created=now, - created_by=user_id, - ) - content = authoring_api.get_or_create_text_content( - learning_package.id, - get_or_create_olx_media_type(usage_key.block_type).id, - text=xml_text, - created=now, - ) - authoring_api.create_component_version_content( - component_version.pk, - content.id, - key="block.xml", - ) - - return component_version - - -def delete_library_block(usage_key, remove_from_parent=True): - """ - Delete the specified block from this library (soft delete). - """ - component = get_component_from_usage_key(usage_key) - authoring_api.soft_delete_draft(component.pk) - - LIBRARY_BLOCK_DELETED.send_event( - library_block=LibraryBlockData( - library_key=usage_key.context_key, - usage_key=usage_key - ) - ) - - -def get_library_block_static_asset_files(usage_key) -> list[LibraryXBlockStaticFile]: - """ - Given an XBlock in a content library, list all the static asset files - associated with that XBlock. - - Returns a list of LibraryXBlockStaticFile objects, sorted by path. - - TODO: Should this be in the general XBlock API rather than the libraries API? - """ - component = get_component_from_usage_key(usage_key) - component_version = component.versioning.draft - - # If there is no Draft version, then this was soft-deleted - if component_version is None: - return [] - - # cvc = the ComponentVersionContent through table - cvc_set = ( - component_version - .componentversioncontent_set - .filter(content__has_file=True) - .order_by('key') - .select_related('content') - ) - - site_root_url = get_xblock_app_config().get_site_root_url() - - return [ - LibraryXBlockStaticFile( - path=cvc.key, - size=cvc.content.size, - url=site_root_url + reverse( - 'content_libraries:library-assets', - kwargs={ - 'component_version_uuid': component_version.uuid, - 'asset_path': cvc.key, - } - ), - ) - for cvc in cvc_set - ] - - -def add_library_block_static_asset_file(usage_key, file_path, file_content, user=None) -> LibraryXBlockStaticFile: - """ - Upload a static asset file into the library, to be associated with the - specified XBlock. Will silently overwrite an existing file of the same name. - - file_path should be a name like "doc.pdf". It may optionally contain slashes - like 'en/doc.pdf' - file_content should be a binary string. - - Returns a LibraryXBlockStaticFile object. - - Sends a LIBRARY_BLOCK_UPDATED event. - - Example: - video_block = UsageKey.from_string("lb:VideoTeam:python-intro:video:1") - add_library_block_static_asset_file(video_block, "subtitles-en.srt", subtitles.encode('utf-8')) - """ - # File path validations copied over from v1 library logic. This can't really - # hurt us inside our system because we never use these paths in an actual - # file system–they're just string keys that point to hash-named data files - # in a common library (learning package) level directory. But it might - # become a security issue during import/export serialization. - if file_path != file_path.strip().strip('/'): - raise InvalidNameError("file_path cannot start/end with / or whitespace.") - if '//' in file_path or '..' in file_path: - raise InvalidNameError("Invalid sequence (// or ..) in file_path.") - - component = get_component_from_usage_key(usage_key) - - with transaction.atomic(): - component_version = authoring_api.create_next_component_version( - component.pk, - content_to_replace={file_path: file_content}, - created=datetime.now(tz=timezone.utc), - created_by=user.id if user else None, - ) - transaction.on_commit( - lambda: LIBRARY_BLOCK_UPDATED.send_event( - library_block=LibraryBlockData( - library_key=usage_key.context_key, - usage_key=usage_key, - ) - ) - ) - - # Now figure out the URL for the newly created asset... - site_root_url = get_xblock_app_config().get_site_root_url() - local_path = reverse( - 'content_libraries:library-assets', - kwargs={ - 'component_version_uuid': component_version.uuid, - 'asset_path': file_path, - } - ) - - return LibraryXBlockStaticFile( - path=file_path, - url=site_root_url + local_path, - size=len(file_content), - ) - - -def delete_library_block_static_asset_file(usage_key, file_path, user=None): - """ - Delete a static asset file from the library. - - Sends a LIBRARY_BLOCK_UPDATED event. - - Example: - video_block = UsageKey.from_string("lb:VideoTeam:python-intro:video:1") - delete_library_block_static_asset_file(video_block, "subtitles-en.srt") - """ - component = get_component_from_usage_key(usage_key) - now = datetime.now(tz=timezone.utc) - - with transaction.atomic(): - component_version = authoring_api.create_next_component_version( - component.pk, - content_to_replace={file_path: None}, - created=now, - created_by=user.id if user else None, - ) - transaction.on_commit( - lambda: LIBRARY_BLOCK_UPDATED.send_event( - library_block=LibraryBlockData( - library_key=usage_key.context_key, - usage_key=usage_key, - ) - ) - ) - - -def get_allowed_block_types(library_key): # pylint: disable=unused-argument - """ - Get a list of XBlock types that can be added to the specified content - library. - """ - # This import breaks in the LMS so keep it here. The LMS doesn't generally - # use content libraries APIs directly but some tests may want to use them to - # create libraries and then test library learning or course-library integration. - from cms.djangoapps.contentstore import helpers as studio_helpers - # TODO: return support status and template options - # See cms/djangoapps/contentstore/views/component.py - block_types = sorted(name for name, class_ in XBlock.load_classes()) - - info = [] - for block_type in block_types: - # TODO: unify the contentstore helper with the xblock.api version of - # xblock_type_display_name - display_name = studio_helpers.xblock_type_display_name(block_type, None) - # For now as a crude heuristic, we exclude blocks that don't have a display_name - if display_name: - info.append(LibraryXBlockType(block_type=block_type, display_name=display_name)) - return info - - -def publish_changes(library_key, user_id=None): - """ - Publish all pending changes to the specified library. - """ - learning_package = ContentLibrary.objects.get_by_key(library_key).learning_package - - authoring_api.publish_all_drafts(learning_package.id, published_by=user_id) - - CONTENT_LIBRARY_UPDATED.send_event( - content_library=ContentLibraryData( - library_key=library_key, - update_blocks=True - ) - ) - - -def publish_component_changes(usage_key: LibraryUsageLocatorV2, user): - """ - Publish all pending changes in a single component. - """ - content_library = require_permission_for_library_key( - usage_key.lib_key, - user, - permissions.CAN_EDIT_THIS_CONTENT_LIBRARY - ) - learning_package = content_library.learning_package - - assert learning_package - component = get_component_from_usage_key(usage_key) - drafts_to_publish = authoring_api.get_all_drafts(learning_package.id).filter( - entity__key=component.key - ) - authoring_api.publish_from_drafts(learning_package.id, draft_qset=drafts_to_publish, published_by=user.id) - LIBRARY_BLOCK_UPDATED.send_event( - library_block=LibraryBlockData( - library_key=usage_key.lib_key, - usage_key=usage_key, - ) - ) - - -def revert_changes(library_key): - """ - Revert all pending changes to the specified library, restoring it to the - last published version. - """ - learning_package = ContentLibrary.objects.get_by_key(library_key).learning_package - authoring_api.reset_drafts_to_published(learning_package.id) - - CONTENT_LIBRARY_UPDATED.send_event( - content_library=ContentLibraryData( - library_key=library_key, - update_blocks=True - ) - ) - - -def create_library_collection( - library_key: LibraryLocatorV2, - collection_key: str, - title: str, - *, - description: str = "", - created_by: int | None = None, - # As an optimization, callers may pass in a pre-fetched ContentLibrary instance - content_library: ContentLibrary | None = None, -) -> Collection: - """ - Creates a Collection in the given ContentLibrary. - - If you've already fetched a ContentLibrary for the given library_key, pass it in here to avoid refetching. - """ - if not content_library: - content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] - assert content_library - assert content_library.learning_package_id - assert content_library.library_key == library_key - - try: - collection = authoring_api.create_collection( - learning_package_id=content_library.learning_package_id, - key=collection_key, - title=title, - description=description, - created_by=created_by, - ) - except IntegrityError as err: - raise LibraryCollectionAlreadyExists from err - - return collection - - -def update_library_collection( - library_key: LibraryLocatorV2, - collection_key: str, - *, - title: str | None = None, - description: str | None = None, - # As an optimization, callers may pass in a pre-fetched ContentLibrary instance - content_library: ContentLibrary | None = None, -) -> Collection: - """ - Updates a Collection in the given ContentLibrary. - """ - if not content_library: - content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] - assert content_library - assert content_library.learning_package_id - assert content_library.library_key == library_key - - try: - collection = authoring_api.update_collection( - learning_package_id=content_library.learning_package_id, - key=collection_key, - title=title, - description=description, - ) - except Collection.DoesNotExist as exc: - raise ContentLibraryCollectionNotFound from exc - - return collection - - -def update_library_collection_components( - library_key: LibraryLocatorV2, - collection_key: str, - *, - usage_keys: list[UsageKeyV2], - created_by: int | None = None, - remove=False, - # As an optimization, callers may pass in a pre-fetched ContentLibrary instance - content_library: ContentLibrary | None = None, -) -> Collection: - """ - Associates the Collection with Components for the given UsageKeys. - - By default the Components are added to the Collection. - If remove=True, the Components are removed from the Collection. - - If you've already fetched the ContentLibrary, pass it in to avoid refetching. - - Raises: - * ContentLibraryCollectionNotFound if no Collection with the given pk is found in the given library. - * ContentLibraryBlockNotFound if any of the given usage_keys don't match Components in the given library. - - Returns the updated Collection. - """ - if not content_library: - content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] - assert content_library - assert content_library.learning_package_id - assert content_library.library_key == library_key - - # Fetch the Component.key values for the provided UsageKeys. - component_keys = [] - for usage_key in usage_keys: - # Parse the block_family from the key to use as namespace. - block_type = BlockTypeKey.from_string(str(usage_key)) - - try: - component = authoring_api.get_component_by_key( - content_library.learning_package_id, - namespace=block_type.block_family, - type_name=usage_key.block_type, - local_key=usage_key.block_id, - ) - except Component.DoesNotExist as exc: - raise ContentLibraryBlockNotFound(usage_key) from exc - - component_keys.append(component.key) - - # Note: Component.key matches its PublishableEntity.key - entities_qset = PublishableEntity.objects.filter( - key__in=component_keys, - ) - - if remove: - collection = authoring_api.remove_from_collection( - content_library.learning_package_id, - collection_key, - entities_qset, - ) - else: - collection = authoring_api.add_to_collection( - content_library.learning_package_id, - collection_key, - entities_qset, - created_by=created_by, - ) - - return collection - - -def set_library_component_collections( - library_key: LibraryLocatorV2, - component: Component, - *, - collection_keys: list[str], - created_by: int | None = None, - # As an optimization, callers may pass in a pre-fetched ContentLibrary instance - content_library: ContentLibrary | None = None, -) -> Component: - """ - It Associates the component with collections for the given collection keys. - - Only collections in queryset are associated with component, all previous component-collections - associations are removed. - - If you've already fetched the ContentLibrary, pass it in to avoid refetching. - - Raises: - * ContentLibraryCollectionNotFound if any of the given collection_keys don't match Collections in the given library. - - Returns the updated Component. - """ - if not content_library: - content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] - assert content_library - assert content_library.learning_package_id - assert content_library.library_key == library_key - - # Note: Component.key matches its PublishableEntity.key - collection_qs = authoring_api.get_collections(content_library.learning_package_id).filter( - key__in=collection_keys - ) - - affected_collections = authoring_api.set_collections( - content_library.learning_package_id, - component, - collection_qs, - created_by=created_by, - ) - - # For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger - # collection indexing asynchronously. - for collection in affected_collections: - LIBRARY_COLLECTION_UPDATED.send_event( - library_collection=LibraryCollectionData( - library_key=library_key, - collection_key=collection.key, - background=True, - ) - ) - - return component - - -def get_library_collection_usage_key( - library_key: LibraryLocatorV2, - collection_key: str, -) -> LibraryCollectionLocator: - """ - Returns the LibraryCollectionLocator associated to a collection - """ - - return LibraryCollectionLocator(library_key, collection_key) - - -def get_library_collection_from_usage_key( - collection_usage_key: LibraryCollectionLocator, -) -> Collection: - """ - Return a Collection using the LibraryCollectionLocator - """ - - library_key = collection_usage_key.library_key - collection_key = collection_usage_key.collection_id - content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] - try: - return authoring_api.get_collection( - content_library.learning_package_id, - collection_key, - ) - except Collection.DoesNotExist as exc: - raise ContentLibraryCollectionNotFound from exc - - -# Import from Courseware -# ====================== - - -class BaseEdxImportClient(abc.ABC): - """ - Base class for all courseware import clients. - - Import clients are wrappers tailored to implement the steps used in the - import APIs and can leverage different backends. It is not aimed towards - being a generic API client for Open edX. - """ - - EXPORTABLE_BLOCK_TYPES = { - "drag-and-drop-v2", - "problem", - "html", - "video", - } - - def __init__(self, library_key=None, library=None, use_course_key_as_block_id_suffix=True): - """ - Initialize an import client for a library. - - The method accepts either a library object or a key to a library object. - """ - self.use_course_key_as_block_id_suffix = use_course_key_as_block_id_suffix - if bool(library_key) == bool(library): - raise ValueError('Provide at least one of `library_key` or ' - '`library`, but not both.') - if library is None: - library = ContentLibrary.objects.get_by_key(library_key) - self.library = library - - @abc.abstractmethod - def get_block_data(self, block_key): - """ - Get the block's OLX and static files, if any. - """ - - @abc.abstractmethod - def get_export_keys(self, course_key): - """ - Get all exportable block keys of a given course. - """ - - @abc.abstractmethod - def get_block_static_data(self, asset_file): - """ - Get the contents of an asset_file.. - """ - - def import_block(self, modulestore_key): - """ - Import a single modulestore block. - """ - block_data = self.get_block_data(modulestore_key) - - # Get or create the block in the library. - # - # To dedup blocks from different courses with the same ID, we hash the - # course key into the imported block id. - - course_key_id = base64.b32encode( - hashlib.blake2s( - str(modulestore_key.course_key).encode() - ).digest() - )[:16].decode().lower() - - # add the course_key_id if use_course_key_as_suffix is enabled to increase the namespace. - # The option exists to not use the course key as a suffix because - # in order to preserve learner state in the v1 to v2 libraries migration, - # the v2 and v1 libraries' child block ids must be the same. - block_id = ( - # Prepend 'c' to allow changing hash without conflicts. - f"{modulestore_key.block_id}_c{course_key_id}" - if self.use_course_key_as_block_id_suffix - else f"{modulestore_key.block_id}" - ) - - log.info('Importing to library block: id=%s', block_id) - try: - library_block = create_library_block( - self.library.library_key, - modulestore_key.block_type, - block_id, - ) - dest_key = library_block.usage_key - except LibraryBlockAlreadyExists: - dest_key = LibraryUsageLocatorV2( - lib_key=self.library.library_key, - block_type=modulestore_key.block_type, - usage_id=block_id, - ) - get_library_block(dest_key) - log.warning('Library block already exists: Appending static files ' - 'and overwriting OLX: %s', str(dest_key)) - - # Handle static files. - - files = [ - f.path for f in - get_library_block_static_asset_files(dest_key) - ] - for filename, static_file in block_data.get('static_files', {}).items(): - if filename in files: - # Files already added, move on. - continue - file_content = self.get_block_static_data(static_file) - add_library_block_static_asset_file(dest_key, filename, file_content) - files.append(filename) - - # Import OLX. - - set_library_block_olx(dest_key, block_data['olx']) - - def import_blocks_from_course(self, course_key, progress_callback): - """ - Import all eligible blocks from course key. - - Progress is reported through ``progress_callback``, guaranteed to be - called within an exception handler if ``exception is not None``. - """ - - # Query the course and rerieve all course blocks. - - export_keys = self.get_export_keys(course_key) - if not export_keys: - raise ValueError(f"The courseware course {course_key} does not have " - "any exportable content. No action taken.") - - # Import each block, skipping the ones that fail. - - for index, block_key in enumerate(export_keys): - try: - log.info('Importing block: %s/%s: %s', index + 1, len(export_keys), block_key) - self.import_block(block_key) - except Exception as exc: # pylint: disable=broad-except - log.exception("Error importing block: %s", block_key) - progress_callback(block_key, index + 1, len(export_keys), exc) - else: - log.info('Successfully imported: %s/%s: %s', index + 1, len(export_keys), block_key) - progress_callback(block_key, index + 1, len(export_keys), None) - - log.info("Publishing library: %s", self.library.library_key) - publish_changes(self.library.library_key) - - -class EdxModulestoreImportClient(BaseEdxImportClient): - """ - An import client based on the local instance of modulestore. - """ - - def __init__(self, modulestore_instance=None, **kwargs): - """ - Initialize the client with a modulestore instance. - """ - super().__init__(**kwargs) - self.modulestore = modulestore_instance or modulestore() - - def get_block_data(self, block_key): - """ - Get block OLX by serializing it from modulestore directly. - """ - block = self.modulestore.get_item(block_key) - data = serialize_modulestore_block_for_learning_core(block) - return {'olx': data.olx_str, - 'static_files': {s.name: s for s in data.static_files}} - - def get_export_keys(self, course_key): - """ - Retrieve the course from modulestore and traverse its content tree. - """ - course = self.modulestore.get_course(course_key) - if isinstance(course_key, LibraryLocatorV1): - course = self.modulestore.get_library(course_key) - export_keys = set() - blocks_q = collections.deque(course.get_children()) - while blocks_q: - block = blocks_q.popleft() - usage_id = block.scope_ids.usage_id - if usage_id in export_keys: - continue - if usage_id.block_type in self.EXPORTABLE_BLOCK_TYPES: - export_keys.add(usage_id) - if block.has_children: - blocks_q.extend(block.get_children()) - return list(export_keys) - - def get_block_static_data(self, asset_file): - """ - Get static content from its URL if available, otherwise from its data. - """ - if asset_file.data: - return asset_file.data - resp = requests.get(f"http://{settings.CMS_BASE}" + asset_file.url) - resp.raise_for_status() - return resp.content - - -class EdxApiImportClient(BaseEdxImportClient): - """ - An import client based on a remote Open Edx API interface. - - TODO: Look over this class. We'll probably need to completely re-implement - the import process. - """ - - URL_COURSES = "/api/courses/v1/courses/{course_key}" - - URL_MODULESTORE_BLOCK_OLX = "/api/olx-export/v1/xblock/{block_key}/" - - def __init__(self, lms_url, studio_url, oauth_key, oauth_secret, *args, **kwargs): - """ - Initialize the API client with URLs and OAuth keys. - """ - super().__init__(**kwargs) - self.lms_url = lms_url - self.studio_url = studio_url - self.oauth_client = OAuthAPIClient( - self.lms_url, - oauth_key, - oauth_secret, - ) - - def get_block_data(self, block_key): - """ - See parent's docstring. - """ - olx_path = self.URL_MODULESTORE_BLOCK_OLX.format(block_key=block_key) - resp = self._get(self.studio_url + olx_path) - return resp['blocks'][str(block_key)] - - def get_export_keys(self, course_key): - """ - See parent's docstring. - """ - course_blocks_url = self._get_course(course_key)['blocks_url'] - course_blocks = self._get( - course_blocks_url, - params={'all_blocks': True, 'depth': 'all'})['blocks'] - export_keys = [] - for block_info in course_blocks.values(): - if block_info['type'] in self.EXPORTABLE_BLOCK_TYPES: - export_keys.append(UsageKey.from_string(block_info['id'])) - return export_keys - - def get_block_static_data(self, asset_file): - """ - See parent's docstring. - """ - if (asset_file['url'].startswith(self.studio_url) - and 'export-file' in asset_file['url']): - # We must call download this file with authentication. But - # we only want to pass the auth headers if this is the same - # studio instance, or else we could leak credentials to a - # third party. - path = asset_file['url'][len(self.studio_url):] - resp = self._call('get', path) - else: - resp = requests.get(asset_file['url']) - resp.raise_for_status() - return resp.content - - def _get(self, *args, **kwargs): - """ - Perform a get request to the client. - """ - return self._json_call('get', *args, **kwargs) - - def _get_course(self, course_key): - """ - Request details for a course. - """ - course_url = self.lms_url + self.URL_COURSES.format(course_key=course_key) - return self._get(course_url) - - def _json_call(self, method, *args, **kwargs): - """ - Wrapper around request calls that ensures valid json responses. - """ - return self._call(method, *args, **kwargs).json() - - def _call(self, method, *args, **kwargs): - """ - Wrapper around request calls. - """ - response = getattr(self.oauth_client, method)(*args, **kwargs) - response.raise_for_status() - return response - - -def import_blocks_create_task(library_key, course_key, use_course_key_as_block_id_suffix=True): - """ - Create a new import block task. - - This API will schedule a celery task to perform the import, and it returns a - import task object for polling. - """ - library = ContentLibrary.objects.get_by_key(library_key) - import_task = ContentLibraryBlockImportTask.objects.create( - library=library, - course_id=course_key, - ) - result = tasks.import_blocks_from_course.apply_async( - args=(import_task.pk, str(course_key), use_course_key_as_block_id_suffix) - ) - log.info(f"Import block task created: import_task={import_task} " - f"celery_task={result.id}") - return import_task diff --git a/openedx/core/djangoapps/content_libraries/api/__init__.py b/openedx/core/djangoapps/content_libraries/api/__init__.py new file mode 100644 index 0000000000..6c5cbce2a2 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/api/__init__.py @@ -0,0 +1,11 @@ +""" +Python API for working with content libraries +""" +from .block_metadata import * +from .collections import * +from .containers import * +from .courseware_import import * +from .exceptions import * +from .libraries import * +from .blocks import * +from . import permissions diff --git a/openedx/core/djangoapps/content_libraries/api/block_metadata.py b/openedx/core/djangoapps/content_libraries/api/block_metadata.py new file mode 100644 index 0000000000..ec5c43b121 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/api/block_metadata.py @@ -0,0 +1,81 @@ +""" +Content libraries API methods related to XBlocks/Components. + +These methods don't enforce permissions (only the REST APIs do). +""" +from __future__ import annotations +from dataclasses import dataclass + +from django.utils.translation import gettext as _ +from opaque_keys.edx.locator import LibraryUsageLocatorV2 +from .libraries import ( + library_component_usage_key, + PublishableItem, +) + +# The public API is only the following symbols: +__all__ = [ + "LibraryXBlockMetadata", + "LibraryXBlockStaticFile", +] + + +@dataclass(frozen=True, kw_only=True) +class LibraryXBlockMetadata(PublishableItem): + """ + Class that represents the metadata about an XBlock in a content library. + """ + usage_key: LibraryUsageLocatorV2 + + @classmethod + def from_component(cls, library_key, component, associated_collections=None): + """ + Construct a LibraryXBlockMetadata from a Component object. + """ + # Import content_tagging.api here to avoid circular imports + from openedx.core.djangoapps.content_tagging.api import get_object_tag_counts + last_publish_log = component.versioning.last_publish_log + + published_by = None + if last_publish_log and last_publish_log.published_by: + published_by = last_publish_log.published_by.username + + draft = component.versioning.draft + published = component.versioning.published + last_draft_created = draft.created if draft else None + last_draft_created_by = draft.publishable_entity_version.created_by if draft else None + usage_key = library_component_usage_key(library_key, component) + tags = get_object_tag_counts(str(usage_key), count_implicit=True) + + return cls( + usage_key=usage_key, + display_name=draft.title, + created=component.created, + modified=draft.created, + draft_version_num=draft.version_num, + published_version_num=published.version_num if published else None, + published_display_name=published.title if published else None, + last_published=None if last_publish_log is None else last_publish_log.published_at, + published_by=published_by, + last_draft_created=last_draft_created, + last_draft_created_by=last_draft_created_by, + has_unpublished_changes=component.versioning.has_unpublished_changes, + collections=associated_collections or [], + tags_count=tags.get(str(usage_key), 0), + can_stand_alone=component.publishable_entity.can_stand_alone, + ) + + +@dataclass(frozen=True) +class LibraryXBlockStaticFile: + """ + Class that represents a static file in a content library, associated with + a particular XBlock. + """ + # File path e.g. "diagram.png" + # In some rare cases it might contain a folder part, e.g. "en/track1.srt" + path: str + # Publicly accessible URL where the file can be downloaded + url: str + # Size in bytes + size: int diff --git a/openedx/core/djangoapps/content_libraries/api/blocks.py b/openedx/core/djangoapps/content_libraries/api/blocks.py new file mode 100644 index 0000000000..3eff4c6c92 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/api/blocks.py @@ -0,0 +1,942 @@ +""" +Content libraries API methods related to XBlocks/Components. + +These methods don't enforce permissions (only the REST APIs do). +""" +from __future__ import annotations +import logging +import mimetypes +from datetime import datetime, timezone +from typing import TYPE_CHECKING +from uuid import uuid4 + +from django.conf import settings +from django.core.exceptions import ObjectDoesNotExist, ValidationError +from django.core.validators import validate_unicode_slug +from django.db import transaction +from django.db.models import QuerySet +from django.urls import reverse +from django.utils.text import slugify +from django.utils.translation import gettext as _ +from lxml import etree +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 +from opaque_keys.edx.keys import LearningContextKey, UsageKeyV2 +from openedx_events.content_authoring.data import ( + ContentObjectChangedData, + LibraryBlockData, + LibraryCollectionData, + LibraryContainerData +) +from openedx_events.content_authoring.signals import ( + CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + LIBRARY_BLOCK_CREATED, + LIBRARY_BLOCK_DELETED, + LIBRARY_BLOCK_UPDATED, + LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_UPDATED +) +from openedx_learning.api import authoring as authoring_api +from openedx_learning.api.authoring_models import Component, ComponentVersion, LearningPackage, MediaType +from xblock.core import XBlock + +from openedx.core.djangoapps.xblock.api import ( + get_component_from_usage_key, + get_xblock_app_config, + xblock_type_display_name +) +from openedx.core.types import User as UserType + +from ..models import ContentLibrary +from .exceptions import ( + BlockLimitReachedError, + ContentLibraryBlockNotFound, + IncompatibleTypesError, + InvalidNameError, + LibraryBlockAlreadyExists, +) +from .block_metadata import LibraryXBlockMetadata, LibraryXBlockStaticFile +from .containers import ( + create_container, + get_container, + get_containers_contains_item, + update_container_children, + ContainerMetadata, + ContainerType, +) +from .collections import library_collection_locator +from .libraries import PublishableItem +from .. import tasks + +# This content_libraries API is sometimes imported in the LMS (should we prevent that?), but the content_staging app +# cannot be. For now we only need this one type import at module scope, so only import it during type checks. +# To use the content_staging API or other CMS-only code, we import it within the functions below. +if TYPE_CHECKING: + from openedx.core.djangoapps.content_staging.api import StagedContentFileData + +log = logging.getLogger(__name__) + +# The public API is only the following symbols: +__all__ = [ + # API methods + "get_library_components", + "get_library_block", + "set_library_block_olx", + "get_component_from_usage_key", + "validate_can_add_block_to_library", + "create_library_block", + "import_staged_content_from_user_clipboard", + "get_or_create_olx_media_type", + "delete_library_block", + "restore_library_block", + "get_library_block_static_asset_files", + "add_library_block_static_asset_file", + "delete_library_block_static_asset_file", + "publish_component_changes", +] + + +def get_library_components( + library_key: LibraryLocatorV2, + text_search: str | None = None, + block_types: list[str] | None = None, +) -> QuerySet[Component]: + """ + Get the library components and filter. + + TODO: Full text search needs to be implemented as a custom lookup for MySQL, + but it should have a fallback to still work in SQLite. + """ + lib = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + learning_package = lib.learning_package + assert learning_package is not None + components = authoring_api.get_components( + learning_package.id, + draft=True, + namespace='xblock.v1', + type_names=block_types, + draft_title=text_search, + ) + + return components + + +def get_library_block(usage_key: LibraryUsageLocatorV2, include_collections=False) -> LibraryXBlockMetadata: + """ + Get metadata about (the draft version of) one specific XBlock in a library. + + This will raise ContentLibraryBlockNotFound if there is no draft version of + this block (i.e. it's been soft-deleted from Studio), even if there is a + live published version of it in the LMS. + """ + try: + component = get_component_from_usage_key(usage_key) + except ObjectDoesNotExist as exc: + raise ContentLibraryBlockNotFound(usage_key) from exc + + # The component might have existed at one point, but no longer does because + # the draft was soft-deleted. This is actually a weird edge case and I'm not + # clear on what the proper behavior should be, since (a) the published + # version still exists; and (b) we might want to make some queries on the + # block even after it's been removed, since there might be versioned + # references to it. + draft_version = component.versioning.draft + if not draft_version: + raise ContentLibraryBlockNotFound(usage_key) + + if include_collections: + associated_collections = authoring_api.get_entity_collections( + component.learning_package_id, + component.key, + ).values('key', 'title') + else: + associated_collections = None + xblock_metadata = LibraryXBlockMetadata.from_component( + library_key=usage_key.context_key, + component=component, + associated_collections=associated_collections, + ) + return xblock_metadata + + +def set_library_block_olx(usage_key: LibraryUsageLocatorV2, new_olx_str: str) -> ComponentVersion: + """ + Replace the OLX source of the given XBlock. + + This is only meant for use by developers or API client applications, as + very little validation is done and this can easily result in a broken XBlock + that won't load. + + Returns the version number of the newly created ComponentVersion. + """ + assert isinstance(usage_key, LibraryUsageLocatorV2) + + # HTMLBlock uses CDATA to preserve HTML inside the XML, so make sure we + # don't strip that out. + parser = etree.XMLParser(strip_cdata=False) + + # Verify that the OLX parses, at least as generic XML, and the root tag is correct: + node = etree.fromstring(new_olx_str, parser=parser) + if node.tag != usage_key.block_type: + raise ValueError( + f"Tried to set the OLX of a {usage_key.block_type} block to a <{node.tag}> node. " + f"{usage_key=!s}, {new_olx_str=}" + ) + + # We're intentionally NOT checking if the XBlock type is installed, since + # this is one of the only tools you can reach for to edit content for an + # XBlock that's broken or missing. + component = get_component_from_usage_key(usage_key) + + # Get the title from the new OLX (or default to the default specified on the + # XBlock's display_name field. + new_title = node.attrib.get( + "display_name", + xblock_type_display_name(usage_key.block_type), + ) + + # Libraries don't use the url_name attribute, because they encode that into + # the Component key. Normally this is stripped out by the XBlockSerializer, + # but we're not actually creating the XBlock when it's coming from the + # clipboard right now. + if "url_name" in node.attrib: + del node.attrib["url_name"] + new_olx_str = etree.tostring(node, encoding='unicode') + + now = datetime.now(tz=timezone.utc) + + with transaction.atomic(): + new_content = authoring_api.get_or_create_text_content( + component.learning_package_id, + get_or_create_olx_media_type(usage_key.block_type).id, + text=new_olx_str, + created=now, + ) + new_component_version = authoring_api.create_next_component_version( + component.pk, + title=new_title, + content_to_replace={ + 'block.xml': new_content.pk, + }, + created=now, + ) + + # .. event_implemented_name: LIBRARY_BLOCK_UPDATED + # .. event_type: org.openedx.content_authoring.library_block.updated.v1 + LIBRARY_BLOCK_UPDATED.send_event( + library_block=LibraryBlockData( + library_key=usage_key.context_key, + usage_key=usage_key + ) + ) + + # For each container, trigger LIBRARY_CONTAINER_UPDATED signal and set background=True to trigger + # container indexing asynchronously. + affected_containers = get_containers_contains_item(usage_key) + for container in affected_containers: + # .. event_implemented_name: LIBRARY_CONTAINER_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.container.updated.v1 + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData( + container_key=container.container_key, + background=True, + ) + ) + + return new_component_version + + +def validate_can_add_block_to_library( + library_key: LibraryLocatorV2, + block_type: str, + block_id: str, +) -> tuple[ContentLibrary, LibraryUsageLocatorV2]: + """ + Perform checks to validate whether a new block with `block_id` and type `block_type` can be added to + the library with key `library_key`. + + Returns the ContentLibrary that has the passed in `library_key` and newly created LibraryUsageLocatorV2 if + validation successful, otherwise raises errors. + """ + assert isinstance(library_key, LibraryLocatorV2) + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + + # If adding a component would take us over our max, return an error. + assert content_library.learning_package_id is not None + component_count = authoring_api.get_all_drafts(content_library.learning_package_id).count() + if component_count + 1 > settings.MAX_BLOCKS_PER_CONTENT_LIBRARY: + raise BlockLimitReachedError( + _("Library cannot have more than {} Components").format( + settings.MAX_BLOCKS_PER_CONTENT_LIBRARY + ) + ) + + # Make sure the proposed ID will be valid: + validate_unicode_slug(block_id) + # Ensure the XBlock type is valid and installed: + block_class = XBlock.load_class(block_type) # Will raise an exception if invalid + if block_class.has_children: + raise IncompatibleTypesError( + 'The "{block_type}" XBlock (ID: "{block_id}") has children, so it not supported in content libraries', + ) + # Make sure the new ID is not taken already: + usage_key = LibraryUsageLocatorV2( # type: ignore[abstract] + lib_key=library_key, + block_type=block_type, + usage_id=block_id, + ) + + if _component_exists(usage_key): + raise LibraryBlockAlreadyExists(f"An XBlock with ID '{usage_key}' already exists") + + return content_library, usage_key + + +def create_library_block( + library_key: LibraryLocatorV2, + block_type: str, + definition_id: str, + user_id: int | None = None, + can_stand_alone: bool = True, +): + """ + Create a new XBlock in this library of the specified type (e.g. "html"). + + Set can_stand_alone = False when a component is created under a container, like unit. + """ + # It's in the serializer as ``definition_id``, but for our purposes, it's + # the block_id. See the comments in ``LibraryXBlockCreationSerializer`` for + # more details. TODO: Change the param name once we change the serializer. + block_id = definition_id + + content_library, usage_key = validate_can_add_block_to_library(library_key, block_type, block_id) + + _create_component_for_block(content_library, usage_key, user_id, can_stand_alone) + + # Now return the metadata about the new block: + + # .. event_implemented_name: LIBRARY_BLOCK_CREATED + # .. event_type: org.openedx.content_authoring.library_block.created.v1 + LIBRARY_BLOCK_CREATED.send_event( + library_block=LibraryBlockData( + library_key=content_library.library_key, + usage_key=usage_key + ) + ) + + return get_library_block(usage_key) + + +def _title_from_olx_node(olx_node) -> str: + """ + Given an OLX XML node (etree node), find an appropriate title for that + XBlock. + """ + title = olx_node.attrib.get("display_name") + if not title: + # Find a localized default title if none was set: + from cms.djangoapps.contentstore import helpers as studio_helpers + title = studio_helpers.xblock_type_display_name(olx_node.tag) + return title + + +def _import_staged_block( + block_type: str, + olx_str: str, + library_key: LibraryLocatorV2, + source_context_key: LearningContextKey, + user, + staged_content_id: int, + staged_content_files: list[StagedContentFileData], + now: datetime, +) -> LibraryXBlockMetadata: + """ + Create a new library block and populate it with staged content from clipboard + + Returns the newly created library block + """ + from openedx.core.djangoapps.content_staging import api as content_staging_api + + # Generate a block_id: + try: + olx_node = etree.fromstring(olx_str) + title = _title_from_olx_node(olx_node) + # Slugify the title and append some random numbers to make a unique slug + block_id = slugify(title, allow_unicode=True) + '-' + uuid4().hex[-6:] + except Exception: # pylint: disable=broad-except + # Just generate a random block_id if we can't make a nice slug. + block_id = uuid4().hex[-12:] + + content_library, usage_key = validate_can_add_block_to_library( + library_key, + block_type, + block_id + ) + + # content_library.learning_package is technically a nullable field because + # it was added in a later migration, but we can't actually make a Library + # without one at the moment. TODO: fix this at the model level. + learning_package: LearningPackage = content_library.learning_package # type: ignore + + # Create component for block then populate it with clipboard data + with transaction.atomic(savepoint=False): + # First create the Component, but do not initialize it to anything (i.e. + # no ComponentVersion). + component_type = authoring_api.get_or_create_component_type( + "xblock.v1", usage_key.block_type + ) + component = authoring_api.create_component( + learning_package.id, + component_type=component_type, + local_key=usage_key.block_id, + created=now, + created_by=user.id, + ) + + # This will create the first component version and set the OLX/title + # appropriately. It will not publish. Once we get the newly created + # ComponentVersion back from this, we can attach all our files to it. + component_version = set_library_block_olx(usage_key, olx_str) + + for staged_content_file_data in staged_content_files: + # The ``data`` attribute is going to be None because the clipboard + # is optimized to not do redundant file copying when copying/pasting + # within the same course (where all the Files and Uploads are + # shared). Learning Core backed content Components will always store + # a Component-local "copy" of the data, and rely on lower-level + # deduplication to happen in the ``contents`` app. + filename = staged_content_file_data.filename + + # Grab our byte data for the file... + file_data = content_staging_api.get_staged_content_static_file_data( + staged_content_id, + filename, + ) + if not file_data: + log.error( + f"Staged content {staged_content_id} included referenced " + f"file {filename}, but no file data was found." + ) + continue + + # Courses don't support having assets that are local to a specific + # component, and instead store all their content together in a + # shared Files and Uploads namespace. If we're pasting that into a + # Learning Core backed data model (v2 Libraries), then we want to + # prepend "static/" to the filename. This will need to get updated + # when we start moving courses over to Learning Core, or if we start + # storing course component assets in sub-directories of Files and + # Uploads. + # + # The reason we don't just search for a "static/" prefix is that + # Learning Core components can store other kinds of files if they + # wish (though none currently do). + source_assumes_global_assets = not isinstance( + source_context_key, LibraryLocatorV2 + ) + if source_assumes_global_assets: + filename = f"static/{filename}" + + # Now construct the Learning Core data models for it... + # TODO: more of this logic should be pushed down to openedx-learning + media_type_str, _encoding = mimetypes.guess_type(filename) + if not media_type_str: + media_type_str = "application/octet-stream" + + media_type = authoring_api.get_or_create_media_type(media_type_str) + content = authoring_api.get_or_create_file_content( + learning_package.id, + media_type.id, + data=file_data, + created=now, + ) + authoring_api.create_component_version_content( + component_version.pk, + content.id, + key=filename, + ) + + # Emit library block created event + # .. event_implemented_name: LIBRARY_BLOCK_CREATED + # .. event_type: org.openedx.content_authoring.library_block.created.v1 + LIBRARY_BLOCK_CREATED.send_event( + library_block=LibraryBlockData( + library_key=content_library.library_key, + usage_key=usage_key + ) + ) + + # Now return the metadata about the new block + return get_library_block(usage_key) + + +def _import_staged_block_as_container( + olx_str: str, + library_key: LibraryLocatorV2, + source_context_key: LearningContextKey, + user, + staged_content_id: int, + staged_content_files: list[StagedContentFileData], + now: datetime, +) -> ContainerMetadata: + """ + Convert the given XBlock (e.g. "vertical") to a Container (e.g. Unit) and + import it into the library, along with all its child XBlocks. + """ + olx_node = etree.fromstring(olx_str) + if olx_node.tag != "vertical": + raise ValueError("This method is only designed to work with XBlocks (units).") + # The olx_str looks like this: + # ...[XML]......[XML]...... + # Ideally we could split it up and preserve the strings, but that is difficult to do correctly, so we'll split + # it up using the XML nodes. This will unfortunately remove any custom comments or formatting in the XML, but that's + # OK since Studio-edited blocks won't have that anyways (hand-edited and library blocks can and do). + + title = _title_from_olx_node(olx_node) + + # Start an atomic section so the whole paste succeeds or fails together: + with transaction.atomic(): + container = create_container( + library_key=library_key, + container_type=ContainerType.Unit, + slug=None, # auto-generate slug from title + title=title, + user_id=user.id, + ) + new_child_keys: list[LibraryUsageLocatorV2] = [] + for child_node in olx_node: + try: + child_metadata = _import_staged_block( + block_type=child_node.tag, + olx_str=etree.tostring(child_node, encoding='unicode'), + library_key=library_key, + source_context_key=source_context_key, + user=user, + staged_content_id=staged_content_id, + staged_content_files=staged_content_files, + now=now, + ) + new_child_keys.append(child_metadata.usage_key) + except IncompatibleTypesError: + continue # Skip blocks that won't work in libraries + update_container_children(container.container_key, new_child_keys, user_id=user.id) + # Re-fetch the container because the 'last_draft_created' will have changed when we added children + container = get_container(container.container_key) + return container + + +def import_staged_content_from_user_clipboard(library_key: LibraryLocatorV2, user) -> PublishableItem: + """ + Create a new library item from the staged content from clipboard. + Can create containers (e.g. units) or XBlocks. + + Returns the newly created item metadata + """ + from openedx.core.djangoapps.content_staging import api as content_staging_api + + user_clipboard = content_staging_api.get_user_clipboard(user) + if not user_clipboard: + raise ValidationError("The user's clipboard is empty") + + staged_content_id = user_clipboard.content.id + source_context_key: LearningContextKey = user_clipboard.source_context_key + + staged_content_files = content_staging_api.get_staged_content_static_files(staged_content_id) + + olx_str = content_staging_api.get_staged_content_olx(staged_content_id) + if olx_str is None: + raise RuntimeError("olx_str missing") # Shouldn't happen - mostly here for type checker + + now = datetime.now(tz=timezone.utc) + + if user_clipboard.content.block_type == "vertical": + # This is a Unit. To import it into a library, we have to create it as a container. + return _import_staged_block_as_container( + olx_str, + library_key, + source_context_key, + user, + staged_content_id, + staged_content_files, + now, + ) + else: + return _import_staged_block( + user_clipboard.content.block_type, + olx_str, + library_key, + source_context_key, + user, + staged_content_id, + staged_content_files, + now, + ) + + +def get_or_create_olx_media_type(block_type: str) -> MediaType: + """ + Get or create a MediaType for the block type. + + Learning Core stores all Content with a Media Type (a.k.a. MIME type). For + OLX, we use the "application/vnd.*" convention, per RFC 6838. + """ + return authoring_api.get_or_create_media_type( + f"application/vnd.openedx.xblock.v1.{block_type}+xml" + ) + + +def delete_library_block( + usage_key: LibraryUsageLocatorV2, + user_id: int | None = None, +) -> None: + """ + Delete the specified block from this library (soft delete). + """ + component = get_component_from_usage_key(usage_key) + library_key = usage_key.context_key + affected_collections = authoring_api.get_entity_collections(component.learning_package_id, component.key) + affected_containers = get_containers_contains_item(usage_key) + + authoring_api.soft_delete_draft(component.pk, deleted_by=user_id) + + # .. event_implemented_name: LIBRARY_BLOCK_DELETED + # .. event_type: org.openedx.content_authoring.library_block.deleted.v1 + LIBRARY_BLOCK_DELETED.send_event( + library_block=LibraryBlockData( + library_key=library_key, + usage_key=usage_key + ) + ) + + # For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger + # collection indexing asynchronously. + # + # To delete the component on collections + for collection in affected_collections: + # .. event_implemented_name: LIBRARY_COLLECTION_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.collection.updated.v1 + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData( + collection_key=library_collection_locator( + library_key=library_key, + collection_key=collection.key, + ), + background=True, + ) + ) + + # For each container, trigger LIBRARY_CONTAINER_UPDATED signal and set background=True to trigger + # container indexing asynchronously. + # + # To update the components count in containers + for container in affected_containers: + # .. event_implemented_name: LIBRARY_CONTAINER_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.container.updated.v1 + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData( + container_key=container.container_key, + background=True, + ) + ) + + +def restore_library_block(usage_key: LibraryUsageLocatorV2, user_id: int | None = None) -> None: + """ + Restore the specified library block. + """ + component = get_component_from_usage_key(usage_key) + library_key = usage_key.context_key + affected_collections = authoring_api.get_entity_collections(component.learning_package_id, component.key) + + # Set draft version back to the latest available component version id. + authoring_api.set_draft_version( + component.pk, + component.versioning.latest.pk, + set_by=user_id, + ) + + # .. event_implemented_name: LIBRARY_BLOCK_CREATED + # .. event_type: org.openedx.content_authoring.library_block.created.v1 + LIBRARY_BLOCK_CREATED.send_event( + library_block=LibraryBlockData( + library_key=library_key, + usage_key=usage_key + ) + ) + + # Add tags and collections back to index + # .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED + # .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1 + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( + content_object=ContentObjectChangedData( + object_id=str(usage_key), + changes=["collections", "tags", "units"], + ), + ) + + # For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger + # collection indexing asynchronously. + # + # To restore the component in the collections + for collection in affected_collections: + # .. event_implemented_name: LIBRARY_COLLECTION_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.collection.updated.v1 + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData( + collection_key=library_collection_locator( + library_key=library_key, + collection_key=collection.key, + ), + background=True, + ) + ) + + # For each container, trigger LIBRARY_CONTAINER_UPDATED signal and set background=True to trigger + # container indexing asynchronously. + # + # To update the components count in containers + affected_containers = get_containers_contains_item(usage_key) + for container in affected_containers: + # .. event_implemented_name: LIBRARY_CONTAINER_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.container.updated.v1 + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData( + container_key=container.container_key, + background=True, + ) + ) + + +def get_library_block_static_asset_files(usage_key: LibraryUsageLocatorV2) -> list[LibraryXBlockStaticFile]: + """ + Given an XBlock in a content library, list all the static asset files + associated with that XBlock. + + Returns a list of LibraryXBlockStaticFile objects, sorted by path. + + TODO: Should this be in the general XBlock API rather than the libraries API? + """ + component = get_component_from_usage_key(usage_key) + component_version = component.versioning.draft + + # If there is no Draft version, then this was soft-deleted + if component_version is None: + return [] + + # cvc = the ComponentVersionContent through table + cvc_set = ( + component_version + .componentversioncontent_set + .filter(content__has_file=True) + .order_by('key') + .select_related('content') + ) + + site_root_url = get_xblock_app_config().get_site_root_url() + + return [ + LibraryXBlockStaticFile( + path=cvc.key, + size=cvc.content.size, + url=site_root_url + reverse( + 'content_libraries:library-assets', + kwargs={ + 'component_version_uuid': component_version.uuid, + 'asset_path': cvc.key, + } + ), + ) + for cvc in cvc_set + ] + + +def add_library_block_static_asset_file( + usage_key: LibraryUsageLocatorV2, + file_path: str, + file_content: bytes, + user: UserType | None = None, +) -> LibraryXBlockStaticFile: + """ + Upload a static asset file into the library, to be associated with the + specified XBlock. Will silently overwrite an existing file of the same name. + + file_path should be a name like "doc.pdf". It may optionally contain slashes + like 'en/doc.pdf' + file_content should be a binary string. + + Returns a LibraryXBlockStaticFile object. + + Sends a LIBRARY_BLOCK_UPDATED event. + + Example: + video_block = UsageKey.from_string("lb:VideoTeam:python-intro:video:1") + add_library_block_static_asset_file(video_block, "subtitles-en.srt", subtitles.encode('utf-8')) + """ + # File path validations copied over from v1 library logic. This can't really + # hurt us inside our system because we never use these paths in an actual + # file system–they're just string keys that point to hash-named data files + # in a common library (learning package) level directory. But it might + # become a security issue during import/export serialization. + if file_path != file_path.strip().strip('/'): + raise InvalidNameError("file_path cannot start/end with / or whitespace.") + if '//' in file_path or '..' in file_path: + raise InvalidNameError("Invalid sequence (// or ..) in file_path.") + + component = get_component_from_usage_key(usage_key) + + with transaction.atomic(): + component_version = authoring_api.create_next_component_version( + component.pk, + content_to_replace={file_path: file_content}, + created=datetime.now(tz=timezone.utc), + created_by=user.id if user else None, + ) + transaction.on_commit( + # .. event_implemented_name: LIBRARY_BLOCK_UPDATED + # .. event_type: org.openedx.content_authoring.library_block.updated.v1 + lambda: LIBRARY_BLOCK_UPDATED.send_event( + library_block=LibraryBlockData( + library_key=usage_key.context_key, + usage_key=usage_key, + ) + ) + ) + + # Now figure out the URL for the newly created asset... + site_root_url = get_xblock_app_config().get_site_root_url() + local_path = reverse( + 'content_libraries:library-assets', + kwargs={ + 'component_version_uuid': component_version.uuid, + 'asset_path': file_path, + } + ) + + return LibraryXBlockStaticFile( + path=file_path, + url=site_root_url + local_path, + size=len(file_content), + ) + + +def delete_library_block_static_asset_file(usage_key, file_path, user=None): + """ + Delete a static asset file from the library. + + Sends a LIBRARY_BLOCK_UPDATED event. + + Example: + video_block = UsageKey.from_string("lb:VideoTeam:python-intro:video:1") + delete_library_block_static_asset_file(video_block, "subtitles-en.srt") + """ + component = get_component_from_usage_key(usage_key) + now = datetime.now(tz=timezone.utc) + + with transaction.atomic(): + component_version = authoring_api.create_next_component_version( + component.pk, + content_to_replace={file_path: None}, + created=now, + created_by=user.id if user else None, + ) + transaction.on_commit( + # .. event_implemented_name: LIBRARY_BLOCK_UPDATED + # .. event_type: org.openedx.content_authoring.library_block.updated.v1 + lambda: LIBRARY_BLOCK_UPDATED.send_event( + library_block=LibraryBlockData( + library_key=usage_key.context_key, + usage_key=usage_key, + ) + ) + ) + + +def publish_component_changes(usage_key: LibraryUsageLocatorV2, user: UserType): + """ + Publish all pending changes in a single component. + """ + component = get_component_from_usage_key(usage_key) + library_key = usage_key.context_key + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + learning_package = content_library.learning_package + assert learning_package + # The core publishing API is based on draft objects, so find the draft that corresponds to this component: + drafts_to_publish = authoring_api.get_all_drafts(learning_package.id).filter(entity__key=component.key) + # Publish the component and update anything that needs to be updated (e.g. search index): + publish_log = authoring_api.publish_from_drafts( + learning_package.id, draft_qset=drafts_to_publish, published_by=user.id, + ) + # Since this is a single component, it should be safe to process synchronously and in-process: + tasks.send_events_after_publish(publish_log.pk, str(library_key)) + # IF this is found to be a performance issue, we could instead make it async where necessary: + # tasks.wait_for_post_publish_events(publish_log, library_key=library_key) + + +def _component_exists(usage_key: UsageKeyV2) -> bool: + """ + Does a Component exist for this usage key? + + This is a lower-level function that will return True if a Component object + exists, even if it was soft-deleted, and there is no active draft version. + """ + try: + get_component_from_usage_key(usage_key) + except ObjectDoesNotExist: + return False + return True + + +def _create_component_for_block( + content_lib: ContentLibrary, + usage_key: LibraryUsageLocatorV2, + user_id: int | None = None, + can_stand_alone: bool = True, +): + """ + Create a Component for an XBlock type, initialize it, and return the ComponentVersion. + + This will create a Component, along with its first ComponentVersion. The tag + in the OLX will have no attributes, e.g. ``. This first version + will be set as the current draft. This function does not publish the + Component. + + Set can_stand_alone = False when a component is created under a container, like unit. + + TODO: We should probably shift this to openedx.core.djangoapps.xblock.api + (along with its caller) since it gives runtime storage specifics. The + Library-specific logic stays in this module, so "create a block for my lib" + should stay here, but "making a block means creating a component with + text data like X" goes in xblock.api. + """ + display_name = xblock_type_display_name(usage_key.block_type) + now = datetime.now(tz=timezone.utc) + xml_text = f'<{usage_key.block_type} />' + + learning_package = content_lib.learning_package + assert learning_package is not None # mostly for type checker + + with transaction.atomic(): + component_type = authoring_api.get_or_create_component_type( + "xblock.v1", usage_key.block_type + ) + component, component_version = authoring_api.create_component_and_version( + learning_package.id, + component_type=component_type, + local_key=usage_key.block_id, + title=display_name, + created=now, + created_by=user_id, + can_stand_alone=can_stand_alone, + ) + content = authoring_api.get_or_create_text_content( + learning_package.id, + get_or_create_olx_media_type(usage_key.block_type).id, + text=xml_text, + created=now, + ) + authoring_api.create_component_version_content( + component_version.pk, + content.id, + key="block.xml", + ) + + return component_version diff --git a/openedx/core/djangoapps/content_libraries/api/collections.py b/openedx/core/djangoapps/content_libraries/api/collections.py new file mode 100644 index 0000000000..3f8e33f444 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/api/collections.py @@ -0,0 +1,271 @@ +""" +Python API for library collections +================================== +""" +from django.db import IntegrityError +from opaque_keys import OpaqueKey +from opaque_keys.edx.keys import BlockTypeKey, UsageKeyV2 +from opaque_keys.edx.locator import LibraryCollectionLocator, LibraryContainerLocator, LibraryLocatorV2 +from openedx_events.content_authoring.data import LibraryCollectionData +from openedx_events.content_authoring.signals import LIBRARY_COLLECTION_UPDATED +from openedx_learning.api import authoring as authoring_api +from openedx_learning.api.authoring_models import Collection, Component, PublishableEntity + +from ..models import ContentLibrary +from .exceptions import ( + ContentLibraryBlockNotFound, + ContentLibraryCollectionNotFound, + ContentLibraryContainerNotFound, + LibraryCollectionAlreadyExists, +) + +# The public API is only the following symbols: +__all__ = [ + "create_library_collection", + "update_library_collection", + "update_library_collection_items", + "set_library_item_collections", + "library_collection_locator", + "get_library_collection_from_locator", +] + + +def create_library_collection( + library_key: LibraryLocatorV2, + collection_key: str, + title: str, + *, + description: str = "", + created_by: int | None = None, + # As an optimization, callers may pass in a pre-fetched ContentLibrary instance + content_library: ContentLibrary | None = None, +) -> Collection: + """ + Creates a Collection in the given ContentLibrary. + + If you've already fetched a ContentLibrary for the given library_key, pass it in here to avoid refetching. + """ + if not content_library: + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + assert content_library + assert content_library.learning_package_id + assert content_library.library_key == library_key + + try: + collection = authoring_api.create_collection( + learning_package_id=content_library.learning_package_id, + key=collection_key, + title=title, + description=description, + created_by=created_by, + ) + except IntegrityError as err: + raise LibraryCollectionAlreadyExists from err + + return collection + + +def update_library_collection( + library_key: LibraryLocatorV2, + collection_key: str, + *, + title: str | None = None, + description: str | None = None, + # As an optimization, callers may pass in a pre-fetched ContentLibrary instance + content_library: ContentLibrary | None = None, +) -> Collection: + """ + Updates a Collection in the given ContentLibrary. + """ + if not content_library: + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + assert content_library + assert content_library.learning_package_id + assert content_library.library_key == library_key + + try: + collection = authoring_api.update_collection( + learning_package_id=content_library.learning_package_id, + key=collection_key, + title=title, + description=description, + ) + except Collection.DoesNotExist as exc: + raise ContentLibraryCollectionNotFound from exc + + return collection + + +def update_library_collection_items( + library_key: LibraryLocatorV2, + collection_key: str, + *, + opaque_keys: list[OpaqueKey], + created_by: int | None = None, + remove=False, + # As an optimization, callers may pass in a pre-fetched ContentLibrary instance + content_library: ContentLibrary | None = None, +) -> Collection: + """ + Associates the Collection with items (XBlocks, Containers) for the given OpaqueKeys. + + By default the items are added to the Collection. + If remove=True, the items are removed from the Collection. + + If you've already fetched the ContentLibrary, pass it in to avoid refetching. + + Raises: + * ContentLibraryCollectionNotFound if no Collection with the given pk is found in the given library. + * ContentLibraryBlockNotFound if any of the given opaque_keys don't match Components in the given library. + * ContentLibraryContainerNotFound if any of the given opaque_keys don't match Containers in the given library. + + Returns the updated Collection. + """ + if not content_library: + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + assert content_library + assert content_library.learning_package_id + assert content_library.library_key == library_key + + # Fetch the Component.key values for the provided UsageKeys. + item_keys = [] + for opaque_key in opaque_keys: + if isinstance(opaque_key, LibraryContainerLocator): + try: + container = authoring_api.get_container_by_key( + content_library.learning_package_id, + key=opaque_key.container_id, + ) + except Collection.DoesNotExist as exc: + raise ContentLibraryContainerNotFound(opaque_key) from exc + + item_keys.append(container.key) + elif isinstance(opaque_key, UsageKeyV2): + # Parse the block_family from the key to use as namespace. + block_type = BlockTypeKey.from_string(str(opaque_key)) + try: + component = authoring_api.get_component_by_key( + content_library.learning_package_id, + namespace=block_type.block_family, + type_name=opaque_key.block_type, + local_key=opaque_key.block_id, + ) + except Component.DoesNotExist as exc: + raise ContentLibraryBlockNotFound(opaque_key) from exc + + item_keys.append(component.key) + else: + # This should never happen, but just in case. + raise ValueError(f"Invalid opaque_key: {opaque_key}") + + entities_qset = PublishableEntity.objects.filter( + key__in=item_keys, + ) + + if remove: + collection = authoring_api.remove_from_collection( + content_library.learning_package_id, + collection_key, + entities_qset, + ) + else: + collection = authoring_api.add_to_collection( + content_library.learning_package_id, + collection_key, + entities_qset, + created_by=created_by, + ) + + return collection + + +def set_library_item_collections( + library_key: LibraryLocatorV2, + entity_key: str, + *, + collection_keys: list[str], + created_by: int | None = None, + # As an optimization, callers may pass in a pre-fetched ContentLibrary instance + content_library: ContentLibrary | None = None, +) -> PublishableEntity: + """ + It Associates the publishable_entity with collections for the given collection keys. + + Only collections in queryset are associated with publishable_entity, all previous publishable_entity-collections + associations are removed. + + If you've already fetched the ContentLibrary, pass it in to avoid refetching. + + Raises: + * ContentLibraryCollectionNotFound if any of the given collection_keys don't match Collections in the given library. + + Returns the updated PublishableEntity. + """ + if not content_library: + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + assert content_library + assert content_library.learning_package_id + assert content_library.library_key == library_key + + publishable_entity = authoring_api.get_publishable_entity_by_key( + content_library.learning_package_id, + key=entity_key, + ) + + # Note: Component.key matches its PublishableEntity.key + collection_qs = authoring_api.get_collections(content_library.learning_package_id).filter( + key__in=collection_keys + ) + + affected_collections = authoring_api.set_collections( + publishable_entity, + collection_qs, + created_by=created_by, + ) + + # For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger + # collection indexing asynchronously. + for collection in affected_collections: + # .. event_implemented_name: LIBRARY_COLLECTION_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.collection.updated.v1 + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData( + collection_key=library_collection_locator( + library_key=library_key, + collection_key=collection.key, + ), + background=True, + ) + ) + + return publishable_entity + + +def library_collection_locator( + library_key: LibraryLocatorV2, + collection_key: str, +) -> LibraryCollectionLocator: + """ + Returns the LibraryCollectionLocator associated to a collection + """ + + return LibraryCollectionLocator(library_key, collection_key) + + +def get_library_collection_from_locator( + collection_locator: LibraryCollectionLocator, +) -> Collection: + """ + Return a Collection using the LibraryCollectionLocator + """ + library_key = collection_locator.lib_key + collection_key = collection_locator.collection_id + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + assert content_library.learning_package_id is not None # shouldn't happen but it's technically possible. + try: + return authoring_api.get_collection( + content_library.learning_package_id, + collection_key, + ) + except Collection.DoesNotExist as exc: + raise ContentLibraryCollectionNotFound from exc diff --git a/openedx/core/djangoapps/content_libraries/api/containers.py b/openedx/core/djangoapps/content_libraries/api/containers.py new file mode 100644 index 0000000000..5e576a92f1 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/api/containers.py @@ -0,0 +1,730 @@ +""" +API for containers (Sections, Subsections, Units) in Content Libraries +""" +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime, timezone +from enum import Enum +import logging +from uuid import uuid4 + +from django.utils.text import slugify +from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 +from openedx_events.content_authoring.data import ( + ContentObjectChangedData, + LibraryCollectionData, + LibraryContainerData, +) +from openedx_events.content_authoring.signals import ( + CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_CREATED, + LIBRARY_CONTAINER_DELETED, + LIBRARY_CONTAINER_UPDATED, +) +from openedx_learning.api import authoring as authoring_api +from openedx_learning.api.authoring_models import Container, ContainerVersion, Component +from openedx.core.djangoapps.content_libraries.api.collections import library_collection_locator +from openedx.core.djangoapps.content_tagging.api import get_object_tag_counts + +from openedx.core.djangoapps.xblock.api import get_component_from_usage_key + +from ..models import ContentLibrary +from .exceptions import ContentLibraryContainerNotFound +from .libraries import PublishableItem +from .block_metadata import LibraryXBlockMetadata +from .. import tasks + +# The public API is only the following symbols: +__all__ = [ + # Models + "ContainerMetadata", + "ContainerType", + # API methods + "get_container", + "create_container", + "get_container_children", + "get_container_children_count", + "library_container_locator", + "update_container", + "delete_container", + "restore_container", + "update_container_children", + "get_containers_contains_item", + "publish_container_changes", +] + +log = logging.getLogger(__name__) + + +class ContainerType(Enum): + """ + The container types supported by content_libraries, and logic to map them to OLX. + """ + Unit = "unit" + Subsection = "subsection" + Section = "section" + + @property + def olx_tag(self) -> str: + """ + Canonical XML tag to use when representing this container as OLX. + + For example, Units are encoded as .... + + These tag names are historical. We keep them around for the backwards compatibility of OLX + and for easier interaction with legacy modulestore-powered structural XBlocks + (e.g., copy-paste of Units between courses and V2 libraries). + """ + match self: + case self.Unit: + return "vertical" + case self.Subsection: + return "sequential" + case self.Section: + return "chapter" + raise TypeError(f"unexpected ContainerType: {self!r}") + + @classmethod + def from_source_olx_tag(cls, olx_tag: str) -> 'ContainerType': + """ + Get the ContainerType that this OLX tag maps to. + """ + if olx_tag == "unit": + # There is an alternative implementation to VerticalBlock called UnitBlock whose + # OLX tag is . When converting from OLX, we want to handle both + # and as Unit containers, although the canonical serialization is still . + return cls.Unit + try: + return next(ct for ct in cls if olx_tag == ct.olx_tag) + except StopIteration: + raise ValueError(f"no container_type for XML tag: <{olx_tag}>") from None + + +@dataclass(frozen=True, kw_only=True) +class ContainerMetadata(PublishableItem): + """ + Class that represents the metadata about a Container (e.g. Unit) in a content library. + """ + container_key: LibraryContainerLocator + container_type: ContainerType + container_pk: int + + @classmethod + def from_container(cls, library_key, container: Container, associated_collections=None): + """ + Construct a ContainerMetadata object from a Container object. + """ + last_publish_log = container.versioning.last_publish_log + container_key = library_container_locator( + library_key, + container=container, + ) + container_type = ContainerType(container_key.container_type) + published_by = "" + if last_publish_log and last_publish_log.published_by: + published_by = last_publish_log.published_by.username + + draft = container.versioning.draft + published = container.versioning.published + last_draft_created = draft.created if draft else None + if draft and draft.publishable_entity_version.created_by: + last_draft_created_by = draft.publishable_entity_version.created_by.username + else: + last_draft_created_by = "" + tags = get_object_tag_counts(str(container_key), count_implicit=True) + + return cls( + container_key=container_key, + container_type=container_type, + container_pk=container.pk, + display_name=draft.title, + created=container.created, + modified=draft.created, + draft_version_num=draft.version_num, + published_version_num=published.version_num if published else None, + published_display_name=published.title if published else None, + last_published=None if last_publish_log is None else last_publish_log.published_at, + published_by=published_by, + last_draft_created=last_draft_created, + last_draft_created_by=last_draft_created_by, + has_unpublished_changes=authoring_api.contains_unpublished_changes(container.pk), + tags_count=tags.get(str(container_key), 0), + collections=associated_collections or [], + ) + + +def library_container_locator( + library_key: LibraryLocatorV2, + container: Container, +) -> LibraryContainerLocator: + """ + Returns a LibraryContainerLocator for the given library + container. + """ + if hasattr(container, 'unit'): + container_type = ContainerType.Unit + elif hasattr(container, 'subsection'): + container_type = ContainerType.Subsection + elif hasattr(container, 'section'): + container_type = ContainerType.Section + + assert container_type is not None + + return LibraryContainerLocator( + library_key, + container_type=container_type.value, + container_id=container.publishable_entity.key, + ) + + +def _get_container_from_key(container_key: LibraryContainerLocator, isDeleted=False) -> Container: + """ + Internal method to fetch the Container object from its LibraryContainerLocator + + Raises ContentLibraryContainerNotFound if no container found, or if the container has been soft deleted. + """ + assert isinstance(container_key, LibraryContainerLocator) + content_library = ContentLibrary.objects.get_by_key(container_key.lib_key) + learning_package = content_library.learning_package + assert learning_package is not None + container = authoring_api.get_container_by_key( + learning_package.id, + key=container_key.container_id, + ) + if container and (isDeleted or container.versioning.draft): + return container + raise ContentLibraryContainerNotFound + + +def get_container( + container_key: LibraryContainerLocator, + *, + include_collections=False, +) -> ContainerMetadata: + """ + Get a container (a Section, Subsection, or Unit). + """ + container = _get_container_from_key(container_key) + if include_collections: + associated_collections = authoring_api.get_entity_collections( + container.publishable_entity.learning_package_id, + container_key.container_id, + ).values('key', 'title') + else: + associated_collections = None + container_meta = ContainerMetadata.from_container( + container_key.lib_key, + container, + associated_collections=associated_collections, + ) + assert container_meta.container_type.value == container_key.container_type + return container_meta + + +def create_container( + library_key: LibraryLocatorV2, + container_type: ContainerType, + slug: str | None, + title: str, + user_id: int | None, + created: datetime | None = None, +) -> ContainerMetadata: + """ + Create a container (a Section, Subsection, or Unit) in the specified content library. + + It will initially be empty. + """ + assert isinstance(library_key, LibraryLocatorV2) + content_library = ContentLibrary.objects.get_by_key(library_key) + assert content_library.learning_package_id # Should never happen but we made this a nullable field so need to check + if slug is None: + # Automatically generate a slug. Append a random suffix so it should be unique. + slug = slugify(title, allow_unicode=True) + '-' + uuid4().hex[-6:] + # Make sure the slug is valid by first creating a key for the new container: + container_key = LibraryContainerLocator( + library_key, + container_type=container_type.value, + container_id=slug, + ) + + if not created: + created = datetime.now(tz=timezone.utc) + + container: Container + _initial_version: ContainerVersion + + # Then try creating the actual container: + match container_type: + case ContainerType.Unit: + container, _initial_version = authoring_api.create_unit_and_version( + content_library.learning_package_id, + key=slug, + title=title, + created=created, + created_by=user_id, + ) + case ContainerType.Subsection: + container, _initial_version = authoring_api.create_subsection_and_version( + content_library.learning_package_id, + key=slug, + title=title, + created=created, + created_by=user_id, + ) + case ContainerType.Section: + container, _initial_version = authoring_api.create_section_and_version( + content_library.learning_package_id, + key=slug, + title=title, + created=created, + created_by=user_id, + ) + case _: + raise NotImplementedError(f"Library does not support {container_type} yet") + + # .. event_implemented_name: LIBRARY_CONTAINER_CREATED + # .. event_type: org.openedx.content_authoring.content_library.container.created.v1 + LIBRARY_CONTAINER_CREATED.send_event( + library_container=LibraryContainerData( + container_key=container_key, + ) + ) + + return ContainerMetadata.from_container(library_key, container) + + +def update_container( + container_key: LibraryContainerLocator, + display_name: str, + user_id: int | None, +) -> ContainerMetadata: + """ + Update a container (a Section, Subsection, or Unit) title. + """ + container = _get_container_from_key(container_key) + library_key = container_key.lib_key + created = datetime.now(tz=timezone.utc) + + container_type = ContainerType(container_key.container_type) + + version: ContainerVersion + affected_containers: list[ContainerMetadata] = [] + # Get children containers or components to update their index data + children = get_container_children( + container_key, + published=False, + ) + child_key_name = 'container_key' + + match container_type: + case ContainerType.Unit: + version = authoring_api.create_next_unit_version( + container.unit, + title=display_name, + created=created, + created_by=user_id, + ) + affected_containers = get_containers_contains_item(container_key) + # Components have usage_key instead of container_key + child_key_name = 'usage_key' + case ContainerType.Subsection: + version = authoring_api.create_next_subsection_version( + container.subsection, + title=display_name, + created=created, + created_by=user_id, + ) + affected_containers = get_containers_contains_item(container_key) + case ContainerType.Section: + version = authoring_api.create_next_section_version( + container.section, + title=display_name, + created=created, + created_by=user_id, + ) + + # The `affected_containers` are not obtained, because the sections are + # not contained in any container. + case _: + raise NotImplementedError(f"Library does not support {container_type} yet") + + # Send event related to the updated container + # .. event_implemented_name: LIBRARY_CONTAINER_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.container.updated.v1 + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData( + container_key=container_key, + ) + ) + + # Send events related to the containers that contains the updated container. + # This is to update the children display names used in the section/subsection previews. + for affected_container in affected_containers: + # .. event_implemented_name: LIBRARY_CONTAINER_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.container.updated.v1 + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData( + container_key=affected_container.container_key, + ) + ) + # Update children components and containers index data, for example, + # All subsections under a section have section key in index that needs to be updated. + # So if parent section name has been changed, it needs to be reflected in sections key of children + for child in children: + # .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED + # .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1 + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( + content_object=ContentObjectChangedData( + object_id=str(getattr(child, child_key_name)), + changes=[container_key.container_type + "s"], + ), + ) + + return ContainerMetadata.from_container(library_key, version.container) + + +def delete_container( + container_key: LibraryContainerLocator, +) -> None: + """ + Delete a container (a Section, Subsection, or Unit) (soft delete). + + No-op if container doesn't exist or has already been soft-deleted. + """ + library_key = container_key.lib_key + container = _get_container_from_key(container_key) + + # Fetch related collections and containers before soft-delete + affected_collections = authoring_api.get_entity_collections( + container.publishable_entity.learning_package_id, + container.key, + ) + affected_containers = get_containers_contains_item(container_key) + # Get children containers or components to update their index data + children = get_container_children( + container_key, + published=False, + ) + authoring_api.soft_delete_draft(container.pk) + + # .. event_implemented_name: LIBRARY_CONTAINER_DELETED + # .. event_type: org.openedx.content_authoring.content_library.container.deleted.v1 + LIBRARY_CONTAINER_DELETED.send_event( + library_container=LibraryContainerData( + container_key=container_key, + ) + ) + + # For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger + # collection indexing asynchronously. + # + # To delete the container on collections + for collection in affected_collections: + # .. event_implemented_name: LIBRARY_COLLECTION_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.collection.updated.v1 + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData( + collection_key=library_collection_locator( + library_key=library_key, + collection_key=collection.key, + ), + background=True, + ) + ) + # Send events related to the containers that contains the updated container. + # This is to update the children display names used in the section/subsection previews. + for affected_container in affected_containers: + # .. event_implemented_name: LIBRARY_CONTAINER_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.container.updated.v1 + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData( + container_key=affected_container.container_key, + ) + ) + container_type = ContainerType(container_key.container_type) + key_name = 'container_key' + if container_type == ContainerType.Unit: + # Components have usage_key instead of container_key + key_name = 'usage_key' + # Update children components and containers index data, for example, + # All subsections under a section have section key in index that needs to be updated. + # So if parent section is deleted, it needs to be removed from sections key of children + for child in children: + # .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED + # .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1 + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( + content_object=ContentObjectChangedData( + object_id=str(getattr(child, key_name)), + changes=[container_key.container_type + "s"], + ), + ) + + +def restore_container(container_key: LibraryContainerLocator) -> None: + """ + Restore the specified library container. + """ + library_key = container_key.lib_key + container = _get_container_from_key(container_key, isDeleted=True) + + affected_collections = authoring_api.get_entity_collections( + container.publishable_entity.learning_package_id, + container.key, + ) + + authoring_api.set_draft_version(container.pk, container.versioning.latest.pk) + # Fetch related containers after restore + affected_containers = get_containers_contains_item(container_key) + # Get children containers or components to update their index data + children = get_container_children( + container_key, + published=False, + ) + + # .. event_implemented_name: LIBRARY_CONTAINER_CREATED + # .. event_type: org.openedx.content_authoring.content_library.container.created.v1 + LIBRARY_CONTAINER_CREATED.send_event( + library_container=LibraryContainerData( + container_key=container_key, + ) + ) + + content_changes = ["collections", "tags"] + if affected_containers and len(affected_containers) > 0: + # Update parent key data in index. Eg. `sections` key in index for subsection + content_changes.append(str(affected_containers[0].container_type.value) + "s") + # Add tags, collections and parent data back to index + # .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED + # .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1 + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( + content_object=ContentObjectChangedData( + object_id=str(container_key), + changes=content_changes, + ), + ) + + # For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger + # collection indexing asynchronously. + # + # To restore the container on collections + for collection in affected_collections: + # .. event_implemented_name: LIBRARY_COLLECTION_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.collection.updated.v1 + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData( + collection_key=library_collection_locator( + library_key=library_key, + collection_key=collection.key, + ), + ) + ) + # Send events related to the containers that contains the updated container. + # This is to update the children display names used in the section/subsection previews. + for affected_container in affected_containers: + # .. event_implemented_name: LIBRARY_CONTAINER_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.container.updated.v1 + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData( + container_key=affected_container.container_key, + ) + ) + container_type = ContainerType(container_key.container_type) + key_name = 'container_key' + if container_type == ContainerType.Unit: + key_name = 'usage_key' + # Update children components and containers index data, for example, + # All subsections under a section have section key in index that needs to be updated. + # Should restore removed parent section in sections key of children subsections + for child in children: + # .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED + # .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1 + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( + content_object=ContentObjectChangedData( + object_id=str(getattr(child, key_name)), + changes=[container_key.container_type + "s"], + ), + ) + + +def get_container_children( + container_key: LibraryContainerLocator, + *, + published=False, +) -> list[LibraryXBlockMetadata | ContainerMetadata]: + """ + Get the entities contained in the given container + (e.g. the components/xblocks in a unit, units in a subsection, subsections in a section) + """ + container = _get_container_from_key(container_key) + container_type = ContainerType(container_key.container_type) + + match container_type: + case ContainerType.Unit: + child_components = authoring_api.get_components_in_unit(container.unit, published=published) + return [LibraryXBlockMetadata.from_component( + container_key.lib_key, + entry.component + ) for entry in child_components] + case ContainerType.Subsection: + child_units = authoring_api.get_units_in_subsection(container.subsection, published=published) + return [ContainerMetadata.from_container( + container_key.lib_key, + entry.unit + ) for entry in child_units] + case ContainerType.Section: + child_subsections = authoring_api.get_subsections_in_section(container.section, published=published) + return [ContainerMetadata.from_container( + container_key.lib_key, + entry.subsection, + ) for entry in child_subsections] + case _: + child_entities = authoring_api.get_entities_in_container(container, published=published) + return [ContainerMetadata.from_container( + container_key.lib_key, + entry.entity + ) for entry in child_entities] + + +def get_container_children_count( + container_key: LibraryContainerLocator, + published=False, +) -> int: + """ + Get the count of entities contained in the given container (e.g. the components/xblocks in a unit) + """ + container = _get_container_from_key(container_key) + return authoring_api.get_container_children_count(container, published=published) + + +def update_container_children( + container_key: LibraryContainerLocator, + children_ids: list[LibraryUsageLocatorV2] | list[LibraryContainerLocator], + user_id: int | None, + entities_action: authoring_api.ChildrenEntitiesAction = authoring_api.ChildrenEntitiesAction.REPLACE, +): + """ + Adds children components or containers to given container. + """ + library_key = container_key.lib_key + container_type = ContainerType(container_key.container_type) + container = _get_container_from_key(container_key) + created = datetime.now(tz=timezone.utc) + new_version: ContainerVersion + match container_type: + case ContainerType.Unit: + components = [get_component_from_usage_key(key) for key in children_ids] # type: ignore[arg-type] + new_version = authoring_api.create_next_unit_version( + container.unit, + components=components, # type: ignore[arg-type] + created=created, + created_by=user_id, + entities_action=entities_action, + ) + + for key in children_ids: + # .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED + # .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1 + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( + content_object=ContentObjectChangedData( + object_id=str(key), + changes=["units"], + ), + ) + case ContainerType.Subsection: + units = [_get_container_from_key(key).unit for key in children_ids] # type: ignore[arg-type] + new_version = authoring_api.create_next_subsection_version( + container.subsection, + units=units, # type: ignore[arg-type] + created=created, + created_by=user_id, + entities_action=entities_action, + ) + + for key in children_ids: + # .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED + # .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1 + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( + content_object=ContentObjectChangedData( + object_id=str(key), + changes=["subsections"], + ), + ) + case ContainerType.Section: + subsections = [_get_container_from_key(key).subsection for key in children_ids] # type: ignore[arg-type] + new_version = authoring_api.create_next_section_version( + container.section, + subsections=subsections, # type: ignore[arg-type] + created=created, + created_by=user_id, + entities_action=entities_action, + ) + + for key in children_ids: + # .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED + # .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1 + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( + content_object=ContentObjectChangedData( + object_id=str(key), + changes=["sections"], + ), + ) + case _: + raise ValueError(f"Invalid container type: {container_type}") + + # .. event_implemented_name: LIBRARY_CONTAINER_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.container.updated.v1 + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData( + container_key=container_key, + ) + ) + + return ContainerMetadata.from_container(library_key, new_version.container) + + +def get_containers_contains_item( + key: LibraryUsageLocatorV2 | LibraryContainerLocator +) -> list[ContainerMetadata]: + """ + Get containers that contains the item, + that can be a component or another container. + """ + item: Component | Container + + if isinstance(key, LibraryUsageLocatorV2): + item = get_component_from_usage_key(key) + + elif isinstance(key, LibraryContainerLocator): + item = _get_container_from_key(key) + + containers = authoring_api.get_containers_with_entity( + item.publishable_entity.pk, + ) + return [ + ContainerMetadata.from_container(key.lib_key, container) + for container in containers + ] + + +def publish_container_changes(container_key: LibraryContainerLocator, user_id: int | None) -> None: + """ + Publish all unpublished changes in a container and all its child + containers/blocks. + """ + container = _get_container_from_key(container_key) + library_key = container_key.lib_key + content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined] + learning_package = content_library.learning_package + assert learning_package + # The core publishing API is based on draft objects, so find the draft that corresponds to this container: + drafts_to_publish = authoring_api.get_all_drafts(learning_package.id).filter(entity__pk=container.pk) + # Publish the container, which will also auto-publish any unpublished child components: + publish_log = authoring_api.publish_from_drafts( + learning_package.id, + draft_qset=drafts_to_publish, + published_by=user_id, + ) + # Update the search index (and anything else) for the affected container + blocks + # This is mostly synchronous but may complete some work asynchronously if there are a lot of changes. + tasks.wait_for_post_publish_events(publish_log, library_key) diff --git a/openedx/core/djangoapps/content_libraries/api/courseware_import.py b/openedx/core/djangoapps/content_libraries/api/courseware_import.py new file mode 100644 index 0000000000..de20243070 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/api/courseware_import.py @@ -0,0 +1,356 @@ +""" +Content Libraries Python API to import blocks from Courseware +============================================================= + +Content Libraries can import blocks from Courseware (Modulestore). The import +can be done per-course, by listing its content, and supports both access to +remote platform instances as well as local modulestore APIs. Additionally, +there are Celery-based interfaces suitable for background processing controlled +through RESTful APIs (see :mod:`.views`). +""" +import abc +import collections +import base64 +import hashlib +import logging + +from django.conf import settings +import requests + +from opaque_keys.edx.locator import ( + LibraryUsageLocatorV2, + LibraryLocator as LibraryLocatorV1, +) +from opaque_keys.edx.keys import UsageKey +from edx_rest_api_client.client import OAuthAPIClient + +from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_for_learning_core +from xmodule.modulestore.django import modulestore + +from .. import tasks +from ..models import ContentLibrary, ContentLibraryBlockImportTask +from .blocks import ( + LibraryBlockAlreadyExists, + add_library_block_static_asset_file, + create_library_block, + get_library_block_static_asset_files, + get_library_block, + set_library_block_olx, +) +from .libraries import publish_changes + +log = logging.getLogger(__name__) + +__all__ = [ + "EdxModulestoreImportClient", + "EdxApiImportClient", + "import_blocks_create_task", +] + + +class BaseEdxImportClient(abc.ABC): + """ + Base class for all courseware import clients. + + Import clients are wrappers tailored to implement the steps used in the + import APIs and can leverage different backends. It is not aimed towards + being a generic API client for Open edX. + """ + + EXPORTABLE_BLOCK_TYPES = { + "drag-and-drop-v2", + "problem", + "html", + "video", + } + + def __init__(self, library_key=None, library=None, use_course_key_as_block_id_suffix=True): + """ + Initialize an import client for a library. + + The method accepts either a library object or a key to a library object. + """ + self.use_course_key_as_block_id_suffix = use_course_key_as_block_id_suffix + if bool(library_key) == bool(library): + raise ValueError('Provide at least one of `library_key` or ' + '`library`, but not both.') + if library is None: + library = ContentLibrary.objects.get_by_key(library_key) + self.library = library + + @abc.abstractmethod + def get_block_data(self, block_key): + """ + Get the block's OLX and static files, if any. + """ + + @abc.abstractmethod + def get_export_keys(self, course_key): + """ + Get all exportable block keys of a given course. + """ + + @abc.abstractmethod + def get_block_static_data(self, asset_file): + """ + Get the contents of an asset_file.. + """ + + def import_block(self, modulestore_key): + """ + Import a single modulestore block. + """ + block_data = self.get_block_data(modulestore_key) + + # Get or create the block in the library. + # + # To dedup blocks from different courses with the same ID, we hash the + # course key into the imported block id. + + course_key_id = base64.b32encode( + hashlib.blake2s( + str(modulestore_key.course_key).encode() + ).digest() + )[:16].decode().lower() + + # add the course_key_id if use_course_key_as_suffix is enabled to increase the namespace. + # The option exists to not use the course key as a suffix because + # in order to preserve learner state in the v1 to v2 libraries migration, + # the v2 and v1 libraries' child block ids must be the same. + block_id = ( + # Prepend 'c' to allow changing hash without conflicts. + f"{modulestore_key.block_id}_c{course_key_id}" + if self.use_course_key_as_block_id_suffix + else f"{modulestore_key.block_id}" + ) + + log.info('Importing to library block: id=%s', block_id) + try: + library_block = create_library_block( + self.library.library_key, + modulestore_key.block_type, + block_id, + ) + dest_key = library_block.usage_key + except LibraryBlockAlreadyExists: + dest_key = LibraryUsageLocatorV2( + lib_key=self.library.library_key, + block_type=modulestore_key.block_type, + usage_id=block_id, + ) + get_library_block(dest_key) + log.warning('Library block already exists: Appending static files ' + 'and overwriting OLX: %s', str(dest_key)) + + # Handle static files. + + files = [ + f.path for f in + get_library_block_static_asset_files(dest_key) + ] + for filename, static_file in block_data.get('static_files', {}).items(): + if filename in files: + # Files already added, move on. + continue + file_content = self.get_block_static_data(static_file) + add_library_block_static_asset_file(dest_key, filename, file_content) + files.append(filename) + + # Import OLX. + + set_library_block_olx(dest_key, block_data['olx']) + + def import_blocks_from_course(self, course_key, progress_callback): + """ + Import all eligible blocks from course key. + + Progress is reported through ``progress_callback``, guaranteed to be + called within an exception handler if ``exception is not None``. + """ + + # Query the course and rerieve all course blocks. + + export_keys = self.get_export_keys(course_key) + if not export_keys: + raise ValueError(f"The courseware course {course_key} does not have " + "any exportable content. No action taken.") + + # Import each block, skipping the ones that fail. + + for index, block_key in enumerate(export_keys): + try: + log.info('Importing block: %s/%s: %s', index + 1, len(export_keys), block_key) + self.import_block(block_key) + except Exception as exc: # pylint: disable=broad-except + log.exception("Error importing block: %s", block_key) + progress_callback(block_key, index + 1, len(export_keys), exc) + else: + log.info('Successfully imported: %s/%s: %s', index + 1, len(export_keys), block_key) + progress_callback(block_key, index + 1, len(export_keys), None) + + log.info("Publishing library: %s", self.library.library_key) + publish_changes(self.library.library_key) + + +class EdxModulestoreImportClient(BaseEdxImportClient): + """ + An import client based on the local instance of modulestore. + """ + + def __init__(self, modulestore_instance=None, **kwargs): + """ + Initialize the client with a modulestore instance. + """ + super().__init__(**kwargs) + self.modulestore = modulestore_instance or modulestore() + + def get_block_data(self, block_key): + """ + Get block OLX by serializing it from modulestore directly. + """ + block = self.modulestore.get_item(block_key) + data = serialize_modulestore_block_for_learning_core(block) + return {'olx': data.olx_str, + 'static_files': {s.name: s for s in data.static_files}} + + def get_export_keys(self, course_key): + """ + Retrieve the course from modulestore and traverse its content tree. + """ + course = self.modulestore.get_course(course_key) + if isinstance(course_key, LibraryLocatorV1): + course = self.modulestore.get_library(course_key) + export_keys = set() + blocks_q = collections.deque(course.get_children()) + while blocks_q: + block = blocks_q.popleft() + usage_id = block.scope_ids.usage_id + if usage_id in export_keys: + continue + if usage_id.block_type in self.EXPORTABLE_BLOCK_TYPES: + export_keys.add(usage_id) + if block.has_children: + blocks_q.extend(block.get_children()) + return list(export_keys) + + def get_block_static_data(self, asset_file): + """ + Get static content from its URL if available, otherwise from its data. + """ + if asset_file.data: + return asset_file.data + resp = requests.get(f"http://{settings.CMS_BASE}" + asset_file.url) + resp.raise_for_status() + return resp.content + + +class EdxApiImportClient(BaseEdxImportClient): + """ + An import client based on a remote Open Edx API interface. + + TODO: Look over this class. We'll probably need to completely re-implement + the import process. + """ + + URL_COURSES = "/api/courses/v1/courses/{course_key}" + + URL_MODULESTORE_BLOCK_OLX = "/api/olx-export/v1/xblock/{block_key}/" + + def __init__(self, lms_url, studio_url, oauth_key, oauth_secret, *args, **kwargs): + """ + Initialize the API client with URLs and OAuth keys. + """ + super().__init__(**kwargs) + self.lms_url = lms_url + self.studio_url = studio_url + self.oauth_client = OAuthAPIClient( + self.lms_url, + oauth_key, + oauth_secret, + ) + + def get_block_data(self, block_key): + """ + See parent's docstring. + """ + olx_path = self.URL_MODULESTORE_BLOCK_OLX.format(block_key=block_key) + resp = self._get(self.studio_url + olx_path) + return resp['blocks'][str(block_key)] + + def get_export_keys(self, course_key): + """ + See parent's docstring. + """ + course_blocks_url = self._get_course(course_key)['blocks_url'] + course_blocks = self._get( + course_blocks_url, + params={'all_blocks': True, 'depth': 'all'})['blocks'] + export_keys = [] + for block_info in course_blocks.values(): + if block_info['type'] in self.EXPORTABLE_BLOCK_TYPES: + export_keys.append(UsageKey.from_string(block_info['id'])) + return export_keys + + def get_block_static_data(self, asset_file): + """ + See parent's docstring. + """ + if (asset_file['url'].startswith(self.studio_url) + and 'export-file' in asset_file['url']): + # We must call download this file with authentication. But + # we only want to pass the auth headers if this is the same + # studio instance, or else we could leak credentials to a + # third party. + path = asset_file['url'][len(self.studio_url):] + resp = self._call('get', path) + else: + resp = requests.get(asset_file['url']) + resp.raise_for_status() + return resp.content + + def _get(self, *args, **kwargs): + """ + Perform a get request to the client. + """ + return self._json_call('get', *args, **kwargs) + + def _get_course(self, course_key): + """ + Request details for a course. + """ + course_url = self.lms_url + self.URL_COURSES.format(course_key=course_key) + return self._get(course_url) + + def _json_call(self, method, *args, **kwargs): + """ + Wrapper around request calls that ensures valid json responses. + """ + return self._call(method, *args, **kwargs).json() + + def _call(self, method, *args, **kwargs): + """ + Wrapper around request calls. + """ + response = getattr(self.oauth_client, method)(*args, **kwargs) + response.raise_for_status() + return response + + +def import_blocks_create_task(library_key, course_key, use_course_key_as_block_id_suffix=True): + """ + Create a new import block task. + + This API will schedule a celery task to perform the import, and it returns a + import task object for polling. + """ + library = ContentLibrary.objects.get_by_key(library_key) + import_task = ContentLibraryBlockImportTask.objects.create( + library=library, + course_id=course_key, + ) + result = tasks.import_blocks_from_course.apply_async( + args=(import_task.pk, str(course_key), use_course_key_as_block_id_suffix) + ) + log.info(f"Import block task created: import_task={import_task} " + f"celery_task={result.id}") + return import_task diff --git a/openedx/core/djangoapps/content_libraries/api/exceptions.py b/openedx/core/djangoapps/content_libraries/api/exceptions.py new file mode 100644 index 0000000000..e715f08561 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/api/exceptions.py @@ -0,0 +1,64 @@ +""" +Exceptions that can be thrown by the Content Libraries API. +""" +from django.db import IntegrityError + +from openedx_learning.api.authoring_models import Collection, Container +from xblock.exceptions import XBlockNotFoundError + +from ..models import ContentLibrary + + +# The public API is only the following symbols: +__all__ = [ + "ContentLibraryNotFound", + "ContentLibraryCollectionNotFound", + "ContentLibraryContainerNotFound", + "ContentLibraryBlockNotFound", + "LibraryAlreadyExists", + "LibraryCollectionAlreadyExists", + "LibraryBlockAlreadyExists", + "BlockLimitReachedError", + "IncompatibleTypesError", + "InvalidNameError", + "LibraryPermissionIntegrityError", +] + + +ContentLibraryNotFound = ContentLibrary.DoesNotExist + +ContentLibraryCollectionNotFound = Collection.DoesNotExist + +ContentLibraryContainerNotFound = Container.DoesNotExist + + +class ContentLibraryBlockNotFound(XBlockNotFoundError): + """ XBlock not found in the content library """ + + +class LibraryAlreadyExists(KeyError): + """ A library with the specified slug already exists """ + + +class LibraryCollectionAlreadyExists(IntegrityError): + """ A Collection with that key already exists in the library """ + + +class LibraryBlockAlreadyExists(KeyError): + """ An XBlock with that ID already exists in the library """ + + +class BlockLimitReachedError(Exception): + """ Maximum number of allowed XBlocks in the library reached """ + + +class IncompatibleTypesError(Exception): + """ Library type constraint violated """ + + +class InvalidNameError(ValueError): + """ The specified name/identifier is not valid """ + + +class LibraryPermissionIntegrityError(IntegrityError): + """ Thrown when an operation would cause insane permissions. """ diff --git a/openedx/core/djangoapps/content_libraries/api/libraries.py b/openedx/core/djangoapps/content_libraries/api/libraries.py new file mode 100644 index 0000000000..293f9761b8 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/api/libraries.py @@ -0,0 +1,694 @@ +""" +Python API for content libraries +================================ + +Via ``views.py``, most of these API methods are also exposed as a REST API. + +The API methods in this file are focused on authoring and specific to content +libraries; they wouldn't necessarily apply or work in other learning contexts +such as courses, blogs, "pathways," etc. + +** As this is an authoring-focused API, all API methods in this file deal with +the DRAFT version of the content library.** + +Some of these methods will work and may be used from the LMS if needed (mostly +for test setup; other use is discouraged), but some of the implementation +details rely on Studio so other methods will raise errors if called from the +LMS. (The REST API is not available at all from the LMS.) + +Any APIs that use/affect content libraries but are generic enough to work in +other learning contexts too are in the core XBlock python/REST API at +``openedx.core.djangoapps.xblock.api/rest_api``. + +For example, to render a content library XBlock as HTML, one can use the +generic: + + render_block_view(block, view_name, user) + +That is an API in ``openedx.core.djangoapps.xblock.api`` (use it from Studio for +the draft version, from the LMS for published version). + +There are one or two methods in this file that have some overlap with the core +XBlock API; for example, this content library API provides a +``get_library_block()`` which returns metadata about an XBlock; it's in this API +because it also returns data about whether or not the XBlock has unpublished +edits, which is an authoring-only concern. Likewise, APIs for getting/setting +an individual XBlock's OLX directly seem more appropriate for small, reusable +components in content libraries and may not be appropriate for other learning +contexts so they are implemented here in the library API only. In the future, +if we find a need for these in most other learning contexts then those methods +could be promoted to the core XBlock API and made generic. +""" +from __future__ import annotations + +from dataclasses import dataclass, field as dataclass_field +from datetime import datetime +import logging + +from django.conf import settings +from django.contrib.auth.models import AbstractUser, AnonymousUser, Group +from django.core.exceptions import PermissionDenied +from django.core.validators import validate_unicode_slug +from django.db import IntegrityError, transaction +from django.db.models import Q, QuerySet +from django.utils.translation import gettext as _ +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 +from openedx_events.content_authoring.data import ( + ContentLibraryData, +) +from openedx_events.content_authoring.signals import ( + CONTENT_LIBRARY_CREATED, + CONTENT_LIBRARY_DELETED, + CONTENT_LIBRARY_UPDATED, +) +from openedx_learning.api import authoring as authoring_api +from openedx_learning.api.authoring_models import Component +from organizations.models import Organization +from xblock.core import XBlock + +from openedx.core.types import User as UserType + +from .. import permissions +from ..constants import ALL_RIGHTS_RESERVED +from ..models import ContentLibrary, ContentLibraryPermission +from .. import tasks +from .exceptions import ( + LibraryAlreadyExists, + LibraryPermissionIntegrityError, +) + +log = logging.getLogger(__name__) + +# The public API is only the following symbols: +__all__ = [ + # Library Models + "ContentLibrary", # Should this be public or not? + "ContentLibraryMetadata", + "AccessLevel", + "ContentLibraryPermissionEntry", + "LibraryXBlockType", + "CollectionMetadata", + # Library API methods + "user_can_create_library", + "get_libraries_for_user", + "get_metadata", + "require_permission_for_library_key", + "get_library", + "create_library", + "get_library_team", + "get_library_user_permissions", + "set_library_user_permissions", + "set_library_group_permissions", + "update_library", + "delete_library", + "library_component_usage_key", + "get_allowed_block_types", + "publish_changes", + "revert_changes", +] + + +# Models +# ====== + + +@dataclass(frozen=True) +class ContentLibraryMetadata: + """ + Class that represents the metadata about a content library. + """ + key: LibraryLocatorV2 + learning_package_id: int | None + title: str = "" + description: str = "" + num_blocks: int = 0 + version: int = 0 + last_published: datetime | None = None + # The username of the user who last published this + published_by: str = "" + last_draft_created: datetime | None = None + # The username of the user who created the last draft. + last_draft_created_by: str = "" + has_unpublished_changes: bool = False + # has_unpublished_deletes will be true when the draft version of the library's bundle + # contains deletes of any XBlocks that were in the most recently published version + has_unpublished_deletes: bool = False + allow_lti: bool = False + # Allow any user (even unregistered users) to view and interact directly + # with this library's content in the LMS + allow_public_learning: bool = False + # Allow any user with Studio access to view this library's content in + # Studio, use it in their courses, and copy content out of this library. + allow_public_read: bool = False + license: str = "" + created: datetime | None = None + updated: datetime | None = None + + +class AccessLevel: + """ Enum defining library access levels/permissions """ + ADMIN_LEVEL = ContentLibraryPermission.ADMIN_LEVEL + AUTHOR_LEVEL = ContentLibraryPermission.AUTHOR_LEVEL + READ_LEVEL = ContentLibraryPermission.READ_LEVEL + NO_ACCESS = None + + +@dataclass(frozen=True) +class ContentLibraryPermissionEntry: + """ + A user or group granted permission to use a content library. + """ + user: AbstractUser | None = None + group: Group | None = None + access_level: str | None = AccessLevel.NO_ACCESS # TODO: make this a proper enum? + + +@dataclass(frozen=True) +class CollectionMetadata: + """ + Class to represent collection metadata in a content library. + """ + key: str + title: str + + +@dataclass(frozen=True) +class LibraryItem: + """ + Common fields for anything that can be found in a content library. + """ + created: datetime + modified: datetime + display_name: str + tags_count: int = 0 + + +@dataclass(frozen=True, kw_only=True) +class PublishableItem(LibraryItem): + """ + Common fields for anything that can be found in a content library that has + draft/publish support. + """ + draft_version_num: int + published_version_num: int | None = None + published_display_name: str | None + last_published: datetime | None = None + # The username of the user who last published this. + published_by: str = "" + last_draft_created: datetime | None = None + # The username of the user who created the last draft. + last_draft_created_by: str = "" + has_unpublished_changes: bool = False + collections: list[CollectionMetadata] = dataclass_field(default_factory=list) + can_stand_alone: bool = True + + +@dataclass(frozen=True) +class LibraryXBlockStaticFile: + """ + Class that represents a static file in a content library, associated with + a particular XBlock. + """ + # File path e.g. "diagram.png" + # In some rare cases it might contain a folder part, e.g. "en/track1.srt" + path: str + # Publicly accessible URL where the file can be downloaded + url: str + # Size in bytes + size: int + + +@dataclass(frozen=True) +class LibraryXBlockType: + """ + An XBlock type that can be added to a content library + """ + block_type: str + display_name: str + + +# General APIs +# ============ + + +def user_can_create_library(user: AbstractUser) -> bool: + """ + Check if the user has permission to create a content library. + """ + return user.has_perm(permissions.CAN_CREATE_CONTENT_LIBRARY) + + +def get_libraries_for_user(user, org=None, text_search=None, order=None) -> QuerySet[ContentLibrary]: + """ + Return content libraries that the user has permission to view. + """ + filter_kwargs = {} + if org: + filter_kwargs['org__short_name'] = org + qs = ContentLibrary.objects.filter(**filter_kwargs) \ + .select_related('learning_package', 'org') \ + .order_by('org__short_name', 'slug') + + if text_search: + qs = qs.filter( + Q(slug__icontains=text_search) | + Q(org__short_name__icontains=text_search) | + Q(learning_package__title__icontains=text_search) | + Q(learning_package__description__icontains=text_search) + ) + + filtered = permissions.perms[permissions.CAN_VIEW_THIS_CONTENT_LIBRARY].filter(user, qs) + + if order: + order_query = 'learning_package__' + valid_order_fields = ['title', 'created', 'updated'] + # If order starts with a -, that means order descending (default is ascending) + if order.startswith('-'): + order_query = f"-{order_query}" + order = order[1:] + + if order in valid_order_fields: + return filtered.order_by(f"{order_query}{order}") + else: + log.exception(f"Error ordering libraries by {order}: Invalid order field") + + return filtered + + +def get_metadata(queryset: QuerySet[ContentLibrary], text_search: str | None = None) -> list[ContentLibraryMetadata]: + """ + Take a list of ContentLibrary objects and return metadata from Learning Core. + """ + if text_search: + queryset = queryset.filter(org__short_name__icontains=text_search) + + libraries = [ + # TODO: Do we really need these fields for the library listing view? + # It's actually going to be pretty expensive to compute this over a + # large list. If we do need it, it might need to go into a denormalized + # form, e.g. a new table for stats that it can join to, even if we don't + # guarantee accuracy (because of possible race conditions). + ContentLibraryMetadata( + key=lib.library_key, + title=lib.learning_package.title if lib.learning_package else "", + description="", + version=0, + allow_public_learning=lib.allow_public_learning, + allow_public_read=lib.allow_public_read, + + # These are currently dummy values to maintain the REST API contract + # while we shift to Learning Core models. + num_blocks=0, + last_published=None, + has_unpublished_changes=False, + has_unpublished_deletes=False, + license=lib.license, + learning_package_id=lib.learning_package_id, + ) + for lib in queryset + ] + return libraries + + +def require_permission_for_library_key(library_key: LibraryLocatorV2, user: UserType, permission) -> ContentLibrary: + """ + Given any of the content library permission strings defined in + openedx.core.djangoapps.content_libraries.permissions, + check if the given user has that permission for the library with the + specified library ID. + + Raises django.core.exceptions.PermissionDenied if the user doesn't have + permission. + """ + library_obj = ContentLibrary.objects.get_by_key(library_key) + # obj should be able to read any valid model object but mypy thinks it can only be + # "User | AnonymousUser | None" + if not user.has_perm(permission, obj=library_obj): # type:ignore[arg-type] + raise PermissionDenied + + return library_obj + + +def get_library(library_key: LibraryLocatorV2) -> ContentLibraryMetadata: + """ + Get the library with the specified key. Does not check permissions. + returns a ContentLibraryMetadata instance. + + Raises ContentLibraryNotFound if the library doesn't exist. + """ + ref = ContentLibrary.objects.get_by_key(library_key) + learning_package = ref.learning_package + assert learning_package is not None # Shouldn't happen - this is just for the type checker + num_blocks = authoring_api.get_all_drafts(learning_package.id).count() + last_publish_log = authoring_api.get_last_publish(learning_package.id) + last_draft_log = authoring_api.get_entities_with_unpublished_changes(learning_package.id) \ + .order_by('-created').first() + last_draft_created = last_draft_log.created if last_draft_log else None + last_draft_created_by = last_draft_log.created_by.username if last_draft_log and last_draft_log.created_by else "" + has_unpublished_changes = last_draft_log is not None + + # TODO: I'm doing this one to match already-existing behavior, but this is + # something that we should remove. It exists to accomodate some complexities + # with how Blockstore staged changes, but Learning Core works differently, + # and has_unpublished_changes should be sufficient. + # Ref: https://github.com/openedx/edx-platform/issues/34283 + has_unpublished_deletes = authoring_api.get_entities_with_unpublished_deletes(learning_package.id) \ + .exists() + + # Learning Core doesn't really have a notion of a global version number,but + # we can sort of approximate it by using the primary key of the last publish + # log entry, in the sense that it will be a monotonically increasing + # integer, though there will be large gaps. We use 0 to denote that nothing + # has been done, since that will never be a valid value for a PublishLog pk. + # + # That being said, we should figure out if we really even want to keep a top + # level version indicator for the Library as a whole. In the v1 libs + # implemention, this served as a way to know whether or not there was an + # updated version of content that a course could pull in. But more recently, + # we've decided to do those version references at the level of the + # individual blocks being used, since a Learning Core backed library is + # intended to be referenced in multiple course locations and not 1:1 like v1 + # libraries. The top level version stays for now because LegacyLibraryContentBlock + # uses it, but that should hopefully change before the Redwood release. + version = 0 if last_publish_log is None else last_publish_log.pk + published_by = "" + if last_publish_log and last_publish_log.published_by: + published_by = last_publish_log.published_by.username + + return ContentLibraryMetadata( + key=library_key, + title=learning_package.title, + description=learning_package.description, + num_blocks=num_blocks, + version=version, + last_published=None if last_publish_log is None else last_publish_log.published_at, + published_by=published_by, + last_draft_created=last_draft_created, + last_draft_created_by=last_draft_created_by, + allow_lti=ref.allow_lti, + allow_public_learning=ref.allow_public_learning, + allow_public_read=ref.allow_public_read, + has_unpublished_changes=has_unpublished_changes, + has_unpublished_deletes=has_unpublished_deletes, + license=ref.license, + created=learning_package.created, + updated=learning_package.updated, + learning_package_id=learning_package.pk, + ) + + +def create_library( + org: str, + slug: str, + title: str, + description: str = "", + allow_public_learning: bool = False, + allow_public_read: bool = False, + library_license: str = ALL_RIGHTS_RESERVED, +) -> ContentLibraryMetadata: + """ + Create a new content library. + + org: an organizations.models.Organization instance + + slug: a slug for this library like 'physics-problems' + + title: title for this library + + description: description of this library + + allow_public_learning: Allow anyone to read/learn from blocks in the LMS + + allow_public_read: Allow anyone to view blocks (including source) in Studio? + + Returns a ContentLibraryMetadata instance. + """ + assert isinstance(org, Organization) + validate_unicode_slug(slug) + try: + with transaction.atomic(): + ref = ContentLibrary.objects.create( + org=org, + slug=slug, + allow_public_learning=allow_public_learning, + allow_public_read=allow_public_read, + license=library_license, + ) + learning_package = authoring_api.create_learning_package( + key=str(ref.library_key), + title=title, + description=description, + ) + ref.learning_package = learning_package + ref.save() + + except IntegrityError: + raise LibraryAlreadyExists(slug) # lint-amnesty, pylint: disable=raise-missing-from + + # .. event_implemented_name: CONTENT_LIBRARY_CREATED + # .. event_type: org.openedx.content_authoring.content_library.created.v1 + CONTENT_LIBRARY_CREATED.send_event( + content_library=ContentLibraryData( + library_key=ref.library_key + ) + ) + return ContentLibraryMetadata( + key=ref.library_key, + title=title, + description=description, + num_blocks=0, + version=0, + last_published=None, + allow_public_learning=ref.allow_public_learning, + allow_public_read=ref.allow_public_read, + license=library_license, + learning_package_id=ref.learning_package.pk, + ) + + +def get_library_team(library_key: LibraryLocatorV2) -> list[ContentLibraryPermissionEntry]: + """ + Get the list of users/groups granted permission to use this library. + """ + ref = ContentLibrary.objects.get_by_key(library_key) + return [ + ContentLibraryPermissionEntry(user=entry.user, group=entry.group, access_level=entry.access_level) + for entry in ref.permission_grants.all() + ] + + +def get_library_user_permissions(library_key: LibraryLocatorV2, user: UserType) -> ContentLibraryPermissionEntry | None: + """ + Fetch the specified user's access information. Will return None if no + permissions have been granted. + """ + if isinstance(user, AnonymousUser): + return None # Mostly here for the type checker + ref = ContentLibrary.objects.get_by_key(library_key) + grant = ref.permission_grants.filter(user=user).first() + if grant is None: + return None + return ContentLibraryPermissionEntry( + user=grant.user, + group=grant.group, + access_level=grant.access_level, + ) + + +def set_library_user_permissions(library_key: LibraryLocatorV2, user: UserType, access_level: str | None): + """ + Change the specified user's level of access to this library. + + access_level should be one of the AccessLevel values defined above. + """ + if isinstance(user, AnonymousUser): + raise TypeError("Invalid user type") # Mostly here for the type checker + ref = ContentLibrary.objects.get_by_key(library_key) + current_grant = get_library_user_permissions(library_key, user) + if current_grant and current_grant.access_level == AccessLevel.ADMIN_LEVEL: + if not ref.permission_grants.filter(access_level=AccessLevel.ADMIN_LEVEL).exclude(user_id=user.id).exists(): + raise LibraryPermissionIntegrityError(_('Cannot change or remove the access level for the only admin.')) + + if access_level is None: + ref.permission_grants.filter(user=user).delete() + else: + ContentLibraryPermission.objects.update_or_create( + library=ref, + user=user, + defaults={"access_level": access_level}, + ) + + +def set_library_group_permissions(library_key: LibraryLocatorV2, group, access_level: str): + """ + Change the specified group's level of access to this library. + + access_level should be one of the AccessLevel values defined above. + """ + ref = ContentLibrary.objects.get_by_key(library_key) + + if access_level is None: + ref.permission_grants.filter(group=group).delete() + else: + ContentLibraryPermission.objects.update_or_create( + library=ref, + group=group, + defaults={"access_level": access_level}, + ) + + +def update_library( + library_key: LibraryLocatorV2, + title=None, + description=None, + allow_public_learning=None, + allow_public_read=None, + library_license=None, +): + """ + Update a library's metadata + (Slug cannot be changed as it would break IDs throughout the system.) + + A value of None means "don't change". + """ + lib_obj_fields = [ + allow_public_learning, allow_public_read, library_license + ] + lib_obj_changed = any(field is not None for field in lib_obj_fields) + learning_pkg_changed = any(field is not None for field in [title, description]) + + # If nothing's changed, just return early. + if (not lib_obj_changed) and (not learning_pkg_changed): + return + + content_lib = ContentLibrary.objects.get_by_key(library_key) + learning_package_id = content_lib.learning_package_id + assert learning_package_id is not None + + with transaction.atomic(): + # We need to make updates to both the ContentLibrary and its linked + # LearningPackage. + if lib_obj_changed: + if allow_public_learning is not None: + content_lib.allow_public_learning = allow_public_learning + if allow_public_read is not None: + content_lib.allow_public_read = allow_public_read + if library_license is not None: + content_lib.license = library_license + content_lib.save() + + if learning_pkg_changed: + authoring_api.update_learning_package( + learning_package_id, + title=title, + description=description, + ) + + # .. event_implemented_name: CONTENT_LIBRARY_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.updated.v1 + CONTENT_LIBRARY_UPDATED.send_event( + content_library=ContentLibraryData( + library_key=content_lib.library_key + ) + ) + + return content_lib + + +def delete_library(library_key: LibraryLocatorV2) -> None: + """ + Delete a content library + """ + with transaction.atomic(): + content_lib = ContentLibrary.objects.get_by_key(library_key) + learning_package = content_lib.learning_package + content_lib.delete() + + # TODO: Move the LearningPackage delete() operation to an API call + # TODO: We should eventually detach the LearningPackage and delete it + # asynchronously, especially if we need to delete a bunch of stuff + # on the filesystem for it. + if learning_package: + learning_package.delete() + + # .. event_implemented_name: CONTENT_LIBRARY_DELETED + # .. event_type: org.openedx.content_authoring.content_library.deleted.v1 + CONTENT_LIBRARY_DELETED.send_event( + content_library=ContentLibraryData( + library_key=library_key + ) + ) + + +def library_component_usage_key( + library_key: LibraryLocatorV2, + component: Component, +) -> LibraryUsageLocatorV2: + """ + Returns a LibraryUsageLocatorV2 for the given library + component. + """ + return LibraryUsageLocatorV2( # type: ignore[abstract] + library_key, + block_type=component.component_type.name, + usage_id=component.local_key, + ) + + +def get_allowed_block_types(library_key: LibraryLocatorV2): # pylint: disable=unused-argument + """ + Get a list of XBlock types that can be added to the specified content + library. + """ + # This import breaks in the LMS so keep it here. The LMS doesn't generally + # use content libraries APIs directly but some tests may want to use them to + # create libraries and then test library learning or course-library integration. + from cms.djangoapps.contentstore import helpers as studio_helpers + block_types = sorted(name for name, class_ in XBlock.load_classes()) + + # Get enabled block types + # + # TODO: For now we are using `settings.LIBRARY_ENABLED_BLOCKS` without filtering + # to return the enabled block types for all libraries. In the future, filtering will be + # done based on a custom configuration per library. + enabled_block_types = [item for item in block_types if item in settings.LIBRARY_ENABLED_BLOCKS] + + info = [] + for block_type in enabled_block_types: + # TODO: unify the contentstore helper with the xblock.api version of + # xblock_type_display_name + display_name = studio_helpers.xblock_type_display_name(block_type, None) + # For now as a crude heuristic, we exclude blocks that don't have a display_name + if display_name: + info.append(LibraryXBlockType(block_type=block_type, display_name=display_name)) + return info + + +def publish_changes(library_key: LibraryLocatorV2, user_id: int | None = None): + """ + Publish all pending changes to the specified library. + """ + learning_package = ContentLibrary.objects.get_by_key(library_key).learning_package + assert learning_package is not None # shouldn't happen but it's technically possible. + publish_log = authoring_api.publish_all_drafts(learning_package.id, published_by=user_id) + + # Update the search index (and anything else) for the affected blocks + # This is mostly synchronous but may complete some work asynchronously if there are a lot of changes. + tasks.wait_for_post_publish_events(publish_log, library_key) + + # Unlike revert_changes below, we do not have to re-index collections, + # because publishing changes does not affect the component counts, and + # collections themselves don't have draft/published/unpublished status. + + +def revert_changes(library_key: LibraryLocatorV2, user_id: int | None = None) -> None: + """ + Revert all pending changes to the specified library, restoring it to the + last published version. + """ + learning_package = ContentLibrary.objects.get_by_key(library_key).learning_package + assert learning_package is not None # shouldn't happen but it's technically possible. + with authoring_api.bulk_draft_changes_for(learning_package.id) as draft_change_log: + authoring_api.reset_drafts_to_published(learning_package.id, reset_by=user_id) + + # Call the event handlers as needed. + tasks.wait_for_post_revert_events(draft_change_log, library_key) diff --git a/openedx/core/djangoapps/content_libraries/api/permissions.py b/openedx/core/djangoapps/content_libraries/api/permissions.py new file mode 100644 index 0000000000..6064b80d6f --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/api/permissions.py @@ -0,0 +1,14 @@ +""" +Public permissions that are part of the content libraries API +""" +# pylint: disable=unused-import + +from ..permissions import ( + CAN_CREATE_CONTENT_LIBRARY, + CAN_DELETE_THIS_CONTENT_LIBRARY, + CAN_EDIT_THIS_CONTENT_LIBRARY, + CAN_EDIT_THIS_CONTENT_LIBRARY_TEAM, + CAN_LEARN_FROM_THIS_CONTENT_LIBRARY, + CAN_VIEW_THIS_CONTENT_LIBRARY, + CAN_VIEW_THIS_CONTENT_LIBRARY_TEAM +) diff --git a/openedx/core/djangoapps/content_libraries/library_context.py b/openedx/core/djangoapps/content_libraries/library_context.py index 4bda10eb12..e1f08ac328 100644 --- a/openedx/core/djangoapps/content_libraries/library_context.py +++ b/openedx/core/djangoapps/content_libraries/library_context.py @@ -6,8 +6,8 @@ import logging from django.core.exceptions import PermissionDenied from rest_framework.exceptions import NotFound -from openedx_events.content_authoring.data import LibraryBlockData -from openedx_events.content_authoring.signals import LIBRARY_BLOCK_UPDATED +from openedx_events.content_authoring.data import LibraryBlockData, LibraryContainerData +from openedx_events.content_authoring.signals import LIBRARY_BLOCK_UPDATED, LIBRARY_CONTAINER_UPDATED from opaque_keys.edx.keys import UsageKeyV2 from opaque_keys.edx.locator import LibraryUsageLocatorV2, LibraryLocatorV2 from openedx_learning.api import authoring as authoring_api @@ -108,9 +108,28 @@ class LibraryContextImpl(LearningContext): Send a "block updated" event for the library block with the given usage_key. """ assert isinstance(usage_key, LibraryUsageLocatorV2) + # .. event_implemented_name: LIBRARY_BLOCK_UPDATED + # .. event_type: org.openedx.content_authoring.library_block.updated.v1 LIBRARY_BLOCK_UPDATED.send_event( library_block=LibraryBlockData( library_key=usage_key.lib_key, usage_key=usage_key, ) ) + + def send_container_updated_events(self, usage_key: UsageKeyV2): + """ + Send "container updated" events for containers that contains the library block + with the given usage_key. + """ + assert isinstance(usage_key, LibraryUsageLocatorV2) + affected_containers = api.get_containers_contains_item(usage_key) + for container in affected_containers: + # .. event_implemented_name: LIBRARY_CONTAINER_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.container.updated.v1 + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData( + container_key=container.container_key, + background=True, + ) + ) diff --git a/openedx/core/djangoapps/content_libraries/models.py b/openedx/core/djangoapps/content_libraries/models.py index 61e28b9448..4158211456 100644 --- a/openedx/core/djangoapps/content_libraries/models.py +++ b/openedx/core/djangoapps/content_libraries/models.py @@ -36,6 +36,7 @@ from __future__ import annotations import contextlib import logging +from typing import ClassVar import uuid from django.contrib.auth import get_user_model @@ -67,11 +68,11 @@ log = logging.getLogger(__name__) User = get_user_model() -class ContentLibraryManager(models.Manager): +class ContentLibraryManager(models.Manager["ContentLibrary"]): """ Custom manager for ContentLibrary class. """ - def get_by_key(self, library_key): + def get_by_key(self, library_key) -> "ContentLibrary": """ Get the ContentLibrary for the given LibraryLocatorV2 key. """ @@ -92,7 +93,7 @@ class ContentLibrary(models.Model): .. no_pii: """ - objects: ContentLibraryManager[ContentLibrary] = ContentLibraryManager() + objects: ClassVar[ContentLibraryManager] = ContentLibraryManager() id = models.AutoField(primary_key=True) # Every Library is uniquely and permanently identified by an 'org' and a diff --git a/openedx/core/djangoapps/content_libraries/permissions.py b/openedx/core/djangoapps/content_libraries/permissions.py index 17671b5659..4e72381986 100644 --- a/openedx/core/djangoapps/content_libraries/permissions.py +++ b/openedx/core/djangoapps/content_libraries/permissions.py @@ -48,6 +48,12 @@ def is_studio_request(_): return settings.SERVICE_VARIANT == "cms" +@blanket_rule +def is_course_creator(user): + from cms.djangoapps.course_creators.views import get_course_creator_status + + return get_course_creator_status(user) == 'granted' + ########################### Permissions ########################### # Is the user allowed to view XBlocks from the specified content library @@ -68,7 +74,10 @@ perms[CAN_LEARN_FROM_THIS_CONTENT_LIBRARY] = ( # Is the user allowed to create content libraries? CAN_CREATE_CONTENT_LIBRARY = 'content_libraries.create_library' -perms[CAN_CREATE_CONTENT_LIBRARY] = is_user_active +if settings.FEATURES.get('ENABLE_CREATOR_GROUP', False): + perms[CAN_CREATE_CONTENT_LIBRARY] = is_global_staff | (is_user_active & is_course_creator) +else: + perms[CAN_CREATE_CONTENT_LIBRARY] = is_global_staff # Is the user allowed to view the specified content library in Studio, # including to view the raw OLX and asset files? @@ -76,8 +85,8 @@ CAN_VIEW_THIS_CONTENT_LIBRARY = 'content_libraries.view_library' perms[CAN_VIEW_THIS_CONTENT_LIBRARY] = is_user_active & ( # Global staff can access any library is_global_staff | - # Some libraries allow anyone to view them in Studio: - Attribute('allow_public_read', True) | + # Libraries with "public read" permissions can be accessed only by course creators + (Attribute('allow_public_read', True) & is_course_creator) | # Otherwise the user must be part of the library's team has_explicit_read_permission_for_library ) diff --git a/lms/djangoapps/learner_dashboard/api/v0/__init__.py b/openedx/core/djangoapps/content_libraries/rest_api/__init__.py similarity index 100% rename from lms/djangoapps/learner_dashboard/api/v0/__init__.py rename to openedx/core/djangoapps/content_libraries/rest_api/__init__.py diff --git a/openedx/core/djangoapps/content_libraries/rest_api/blocks.py b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py new file mode 100644 index 0000000000..bc31409989 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/rest_api/blocks.py @@ -0,0 +1,460 @@ +""" +Content Library REST APIs related to XBlocks/Components and their static assets +""" +import edx_api_doc_tools as apidocs +from django.core.exceptions import ObjectDoesNotExist +from django.db.transaction import non_atomic_requests +from django.http import Http404, HttpResponse, StreamingHttpResponse +from django.urls import reverse +from django.utils.decorators import method_decorator +from drf_yasg.utils import swagger_auto_schema +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 +from openedx_learning.api import authoring as authoring_api +from rest_framework import status +from rest_framework.exceptions import NotFound, ValidationError +from rest_framework.generics import GenericAPIView +from rest_framework.parsers import MultiPartParser +from rest_framework.response import Response +from rest_framework.views import APIView + +from openedx.core.djangoapps.content_libraries import api, permissions +from openedx.core.djangoapps.content_libraries.rest_api.serializers import ( + ContentLibraryItemCollectionsUpdateSerializer, + LibraryXBlockCreationSerializer, + LibraryXBlockMetadataSerializer, + LibraryXBlockOlxSerializer, + LibraryXBlockStaticFileSerializer, + LibraryXBlockStaticFilesSerializer, +) +from openedx.core.djangoapps.xblock import api as xblock_api +from openedx.core.lib.api.view_utils import view_auth_classes +from openedx.core.types.http import RestRequest + +from .libraries import LibraryApiPaginationDocs +from .utils import convert_exceptions + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryBlocksView(GenericAPIView): + """ + Views to work with XBlocks in a specific content library. + """ + serializer_class = LibraryXBlockMetadataSerializer + + @apidocs.schema( + parameters=[ + *LibraryApiPaginationDocs.apidoc_params, + apidocs.query_parameter( + 'text_search', + str, + description="The string used to filter libraries by searching in title, id, org, or description", + ), + apidocs.query_parameter( + 'block_type', + str, + description="The block type to search for. If omitted or blank, searches for all types. " + "May be specified multiple times to match multiple types." + ) + ], + ) + @convert_exceptions + def get(self, request, lib_key_str): + """ + Get the list of all top-level blocks in this content library + """ + key = LibraryLocatorV2.from_string(lib_key_str) + text_search = request.query_params.get('text_search', None) + block_types = request.query_params.getlist('block_type') or None + + api.require_permission_for_library_key(key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) + components = api.get_library_components(key, text_search=text_search, block_types=block_types) + + paginated_xblock_metadata = [ + api.LibraryXBlockMetadata.from_component(key, component) + for component in self.paginate_queryset(components) + ] + serializer = LibraryXBlockMetadataSerializer(paginated_xblock_metadata, many=True) + 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 + """ + library_key = LibraryLocatorV2.from_string(lib_key_str) + api.require_permission_for_library_key(library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY) + serializer = LibraryXBlockCreationSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + # Create a new regular top-level block: + try: + result = api.create_library_block(library_key, user_id=request.user.id, **serializer.validated_data) + except api.IncompatibleTypesError as err: + raise ValidationError( # lint-amnesty, pylint: disable=raise-missing-from + detail={'block_type': str(err)}, + ) + + return Response(LibraryXBlockMetadataSerializer(result).data) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryBlockView(APIView): + """ + Views to work with an existing XBlock in a content library. + """ + @convert_exceptions + def get(self, request, usage_key_str): + """ + Get metadata about an existing XBlock in the content library. + + This API doesn't support versioning; most of the information it returns + is related to the latest draft version, or to all versions of the block. + If you need to get the display name of a previous version, use the + similar "metadata" API from djangoapps.xblock, which does support + versioning. + """ + key = LibraryUsageLocatorV2.from_string(usage_key_str) + api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) + result = api.get_library_block(key, include_collections=True) + + return Response(LibraryXBlockMetadataSerializer(result).data) + + @convert_exceptions + def delete(self, request, usage_key_str): # pylint: disable=unused-argument + """ + Delete a usage of a block from the library (and any children it has). + + If this is the only usage of the block's definition within this library, + both the definition and the usage will be deleted. If this is only one + of several usages, the definition will be kept. Usages by linked bundles + are ignored and will not prevent deletion of the definition. + + If the usage points to a definition in a linked bundle, the usage will + be deleted but the link and the linked bundle will be unaffected. + """ + key = LibraryUsageLocatorV2.from_string(usage_key_str) + api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY) + api.delete_library_block(key, user_id=request.user.id) + return Response({}) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryBlockAssetListView(APIView): + """ + Views to list an existing XBlock's static asset files + """ + @convert_exceptions + def get(self, request, usage_key_str): + """ + List the static asset files belonging to this block. + """ + key = LibraryUsageLocatorV2.from_string(usage_key_str) + api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) + files = api.get_library_block_static_asset_files(key) + return Response(LibraryXBlockStaticFilesSerializer({"files": files}).data) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryBlockAssetView(APIView): + """ + Views to work with an existing XBlock's static asset files + """ + parser_classes = (MultiPartParser, ) + + @convert_exceptions + def get(self, request, usage_key_str, file_path): + """ + Get a static asset file belonging to this block. + """ + key = LibraryUsageLocatorV2.from_string(usage_key_str) + api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) + files = api.get_library_block_static_asset_files(key) + for f in files: + if f.path == file_path: + return Response(LibraryXBlockStaticFileSerializer(f).data) + raise NotFound + + @convert_exceptions + def put(self, request, usage_key_str, file_path): + """ + Replace a static asset file belonging to this block. + """ + file_path = file_path.replace(" ", "_") # Messes up url/name correspondence due to URL encoding. + usage_key = LibraryUsageLocatorV2.from_string(usage_key_str) + api.require_permission_for_library_key( + usage_key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY, + ) + file_wrapper = request.data['content'] + if file_wrapper.size > 20 * 1024 * 1024: # > 20 MiB + # TODO: This check was written when V2 Libraries were backed by the Blockstore micro-service. + # Now that we're on Learning Core, do we still need it? Here's the original comment: + # In the future, we need a way to use file_wrapper.chunks() to read + # the file in chunks and stream that to Blockstore, but Blockstore + # currently lacks an API for streaming file uploads. + # Ref: https://github.com/openedx/edx-platform/issues/34737 + raise ValidationError("File too big") + file_content = file_wrapper.read() + try: + result = api.add_library_block_static_asset_file(usage_key, file_path, file_content, request.user) + except ValueError: + raise ValidationError("Invalid file path") # lint-amnesty, pylint: disable=raise-missing-from + return Response(LibraryXBlockStaticFileSerializer(result).data) + + @convert_exceptions + def delete(self, request, usage_key_str, file_path): + """ + Delete a static asset file belonging to this block. + """ + usage_key = LibraryUsageLocatorV2.from_string(usage_key_str) + api.require_permission_for_library_key( + usage_key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY, + ) + try: + api.delete_library_block_static_asset_file(usage_key, file_path, request.user) + except ValueError: + raise ValidationError("Invalid file path") # lint-amnesty, pylint: disable=raise-missing-from + return Response(status=status.HTTP_204_NO_CONTENT) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryBlockPublishView(APIView): + """ + Commit/publish all of the draft changes made to the component. + """ + + @convert_exceptions + def post(self, request, usage_key_str): + """ + Publish the draft changes made to this component. + """ + key = LibraryUsageLocatorV2.from_string(usage_key_str) + api.require_permission_for_library_key( + key.lib_key, + request.user, + permissions.CAN_EDIT_THIS_CONTENT_LIBRARY + ) + api.publish_component_changes(key, request.user) + return Response({}) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryBlockCollectionsView(APIView): + """ + View to set collections for a component. + """ + @convert_exceptions + def patch(self, request: RestRequest, usage_key_str) -> Response: + """ + Sets Collections for a Component. + + Collection and Components must all be part of the given library/learning package. + """ + key = LibraryUsageLocatorV2.from_string(usage_key_str) + content_library = api.require_permission_for_library_key( + key.lib_key, + request.user, + permissions.CAN_EDIT_THIS_CONTENT_LIBRARY + ) + serializer = ContentLibraryItemCollectionsUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + component = api.get_component_from_usage_key(key) + collection_keys = serializer.validated_data['collection_keys'] + api.set_library_item_collections( + library_key=key.lib_key, + entity_key=component.publishable_entity.key, + collection_keys=collection_keys, + created_by=request.user.id, + content_library=content_library, + ) + + return Response({'count': len(collection_keys)}) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryBlockLtiUrlView(APIView): + """ + Views to generate LTI URL for existing XBlocks in a content library. + + Returns 404 in case the block not found by the given key. + """ + @convert_exceptions + def get(self, request, usage_key_str): + """ + Get the LTI launch URL for the XBlock. + """ + key = LibraryUsageLocatorV2.from_string(usage_key_str) + api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) + + # Get the block to validate its existence + api.get_library_block(key) + lti_login_url = f"{reverse('content_libraries:lti-launch')}?id={key}" + return Response({"lti_url": lti_login_url}) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryBlockOlxView(APIView): + """ + Views to work with an existing XBlock's OLX + """ + @convert_exceptions + def get(self, request, usage_key_str): + """ + DEPRECATED. Use get_block_olx_view() in xblock REST-API. + Can be removed post-Teak. + + Get the block's OLX + """ + key = LibraryUsageLocatorV2.from_string(usage_key_str) + api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) + xml_str = xblock_api.get_block_draft_olx(key) + return Response(LibraryXBlockOlxSerializer({"olx": xml_str}).data) + + @convert_exceptions + def post(self, request, usage_key_str): + """ + Replace the block's OLX. + + This API is only meant for use by developers or API client applications. + Very little validation is done. + """ + key = LibraryUsageLocatorV2.from_string(usage_key_str) + api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY) + serializer = LibraryXBlockOlxSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + new_olx_str = serializer.validated_data["olx"] + try: + version_num = api.set_library_block_olx(key, new_olx_str).version_num + except ValueError as err: + raise ValidationError(detail=str(err)) # lint-amnesty, pylint: disable=raise-missing-from + return Response(LibraryXBlockOlxSerializer({"olx": new_olx_str, "version_num": version_num}).data) + + +@view_auth_classes() +class LibraryBlockRestore(APIView): + """ + View to restore soft-deleted library xblocks. + """ + @convert_exceptions + def post(self, request, usage_key_str) -> Response: + """ + Restores a soft-deleted library block that belongs to a Content Library + """ + key = LibraryUsageLocatorV2.from_string(usage_key_str) + api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY) + api.restore_library_block(key, request.user.id) + return Response(None, status=status.HTTP_204_NO_CONTENT) + + +def get_component_version_asset(request, component_version_uuid, asset_path): + """ + Serves static assets associated with particular Component versions. + + Important notes: + * This is meant for Studio/authoring use ONLY. It requires read access to + the content library. + * It uses the UUID because that's easier to parse than the key field (which + could be part of an OpaqueKey, but could also be almost anything else). + * This is not very performant, and we still want to use the X-Accel-Redirect + method for serving LMS traffic in the longer term (and probably Studio + eventually). + """ + try: + component_version = authoring_api.get_component_version_by_uuid( + component_version_uuid + ) + except ObjectDoesNotExist as exc: + raise Http404() from exc + + # Permissions check... + learning_package = component_version.component.learning_package + library_key = LibraryLocatorV2.from_string(learning_package.key) + api.require_permission_for_library_key( + library_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, + ) + + # We already have logic for getting the correct content and generating the + # proper headers in Learning Core, but the response generated here is an + # X-Accel-Redirect and lacks the actual content. We eventually want to use + # this response in conjunction with a media reverse proxy (Caddy or Nginx), + # but in the short term we're just going to remove the redirect and stream + # the content directly. + redirect_response = authoring_api.get_redirect_response_for_component_asset( + component_version_uuid, + asset_path, + public=False, + ) + + # If there was any error, we return that response because it will have the + # correct headers set and won't have any X-Accel-Redirect header set. + if redirect_response.status_code != 200: + return redirect_response + + # If we got here, we know that the asset exists and it's okay to download. + cv_content = component_version.componentversioncontent_set.get(key=asset_path) + content = cv_content.content + + # Delete the re-direct part of the response headers. We'll copy the rest. + headers = redirect_response.headers + headers.pop('X-Accel-Redirect') + + # We need to set the content size header manually because this is a + # streaming response. It's not included in the redirect headers because it's + # not needed there (the reverse-proxy would have direct access to the file). + headers['Content-Length'] = content.size + + if request.method == "HEAD": + return HttpResponse(headers=headers) + + # Otherwise it's going to be a GET response. We don't support response + # offsets or anything fancy, because we don't expect to run this view at + # LMS-scale. + return StreamingHttpResponse( + content.read_file().chunks(), + headers=redirect_response.headers, + ) + + +@view_auth_classes() +class LibraryComponentAssetView(APIView): + """ + Serves static assets associated with particular Component versions. + """ + @convert_exceptions + def get(self, request, component_version_uuid, asset_path): + """ + GET API for fetching static asset for given component_version_uuid. + """ + return get_component_version_asset(request, component_version_uuid, asset_path) + + +@view_auth_classes() +class LibraryComponentDraftAssetView(APIView): + """ + Serves the draft version of static assets associated with a Library Component. + + See `get_component_version_asset` for more details + """ + @convert_exceptions + def get(self, request, usage_key, asset_path): + """ + Fetches component_version_uuid for given usage_key and returns component asset. + """ + try: + component_version_uuid = api.get_component_from_usage_key(usage_key).versioning.draft.uuid + except ObjectDoesNotExist as exc: + raise Http404() from exc + + return get_component_version_asset(request, component_version_uuid, asset_path) diff --git a/openedx/core/djangoapps/content_libraries/views_collections.py b/openedx/core/djangoapps/content_libraries/rest_api/collections.py similarity index 83% rename from openedx/core/djangoapps/content_libraries/views_collections.py rename to openedx/core/djangoapps/content_libraries/rest_api/collections.py index b6c1c999ba..d893d766d8 100644 --- a/openedx/core/djangoapps/content_libraries/views_collections.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/collections.py @@ -1,7 +1,6 @@ """ Collections API Views """ - from __future__ import annotations from django.db.models import QuerySet @@ -17,14 +16,15 @@ from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_learning.api import authoring as authoring_api from openedx_learning.api.authoring_models import Collection -from openedx.core.djangoapps.content_libraries import api, permissions -from openedx.core.djangoapps.content_libraries.models import ContentLibrary -from openedx.core.djangoapps.content_libraries.views import convert_exceptions -from openedx.core.djangoapps.content_libraries.serializers import ( +from .. import api, permissions +from ..models import ContentLibrary +from .utils import convert_exceptions +from .serializers import ( ContentLibraryCollectionSerializer, - ContentLibraryCollectionComponentsUpdateSerializer, ContentLibraryCollectionUpdateSerializer, + ContentLibraryItemKeysSerializer, ) +from openedx.core.types.http import RestRequest class LibraryCollectionsView(ModelViewSet): @@ -89,7 +89,7 @@ class LibraryCollectionsView(ModelViewSet): return collection @convert_exceptions - def retrieve(self, request, *args, **kwargs) -> Response: + def retrieve(self, request: RestRequest, *args, **kwargs) -> Response: """ Retrieve the Content Library Collection """ @@ -97,7 +97,7 @@ class LibraryCollectionsView(ModelViewSet): return super().retrieve(request, *args, **kwargs) @convert_exceptions - def list(self, request, *args, **kwargs) -> Response: + def list(self, request: RestRequest, *args, **kwargs) -> Response: """ List Collections that belong to Content Library """ @@ -105,7 +105,7 @@ class LibraryCollectionsView(ModelViewSet): return super().list(request, *args, **kwargs) @convert_exceptions - def create(self, request, *args, **kwargs) -> Response: + def create(self, request: RestRequest, *args, **kwargs) -> Response: """ Create a Collection that belongs to a Content Library """ @@ -139,7 +139,7 @@ class LibraryCollectionsView(ModelViewSet): return Response(serializer.data) @convert_exceptions - def partial_update(self, request, *args, **kwargs) -> Response: + def partial_update(self, request: RestRequest, *args, **kwargs) -> Response: """ Update a Collection that belongs to a Content Library """ @@ -161,7 +161,7 @@ class LibraryCollectionsView(ModelViewSet): return Response(serializer.data) @convert_exceptions - def destroy(self, request, *args, **kwargs) -> Response: + def destroy(self, request: RestRequest, *args, **kwargs) -> Response: """ Soft-deletes a Collection that belongs to a Content Library """ @@ -176,7 +176,7 @@ class LibraryCollectionsView(ModelViewSet): @convert_exceptions @action(detail=True, methods=['post'], url_path='restore', url_name='collection-restore') - def restore(self, request, *args, **kwargs) -> Response: + def restore(self, request: RestRequest, *args, **kwargs) -> Response: """ Restores a soft-deleted Collection that belongs to a Content Library """ @@ -190,27 +190,27 @@ class LibraryCollectionsView(ModelViewSet): return Response(None, status=HTTP_204_NO_CONTENT) @convert_exceptions - @action(detail=True, methods=['delete', 'patch'], url_path='components', url_name='components-update') - def update_components(self, request, *args, **kwargs) -> Response: + @action(detail=True, methods=['delete', 'patch'], url_path='items', url_name='items-update') + def update_items(self, request: RestRequest, *args, **kwargs) -> Response: """ - Adds (PATCH) or removes (DELETE) Components to/from a Collection. + Adds (PATCH) or removes (DELETE) items to/from a Collection. - Collection and Components must all be part of the given library/learning package. + Collection and items must all be part of the given library/learning package. """ content_library = self.get_content_library() collection_key = kwargs["key"] - serializer = ContentLibraryCollectionComponentsUpdateSerializer(data=request.data) + serializer = ContentLibraryItemKeysSerializer(data=request.data) serializer.is_valid(raise_exception=True) - usage_keys = serializer.validated_data["usage_keys"] - api.update_library_collection_components( + opaque_keys = serializer.validated_data["usage_keys"] + api.update_library_collection_items( library_key=content_library.library_key, content_library=content_library, collection_key=collection_key, - usage_keys=usage_keys, - created_by=self.request.user.id, + opaque_keys=opaque_keys, + created_by=request.user.id, remove=(request.method == "DELETE"), ) - return Response({'count': len(usage_keys)}) + return Response({'count': len(opaque_keys)}) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/containers.py b/openedx/core/djangoapps/content_libraries/rest_api/containers.py new file mode 100644 index 0000000000..d861cbceec --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/rest_api/containers.py @@ -0,0 +1,352 @@ +""" +REST API views for containers (sections, subsections, units) in content libraries +""" +from __future__ import annotations + +import logging + +from django.contrib.auth import get_user_model +from django.db.transaction import non_atomic_requests +from django.utils.decorators import method_decorator +from drf_yasg.utils import swagger_auto_schema + +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryContainerLocator +from openedx_learning.api import authoring as authoring_api +from rest_framework.generics import GenericAPIView +from rest_framework.response import Response +from rest_framework.status import HTTP_204_NO_CONTENT + +from openedx.core.djangoapps.content_libraries import api, permissions +from openedx.core.lib.api.view_utils import view_auth_classes +from openedx.core.types.http import RestRequest +from . import serializers +from .utils import convert_exceptions + +User = get_user_model() +log = logging.getLogger(__name__) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryContainersView(GenericAPIView): + """ + Views to work with Containers in a specific content library. + """ + serializer_class = serializers.LibraryContainerMetadataSerializer + + @convert_exceptions + @swagger_auto_schema( + request_body=serializers.LibraryContainerMetadataSerializer, + responses={200: serializers.LibraryContainerMetadataSerializer} + ) + def post(self, request, lib_key_str): + """ + Create a new Container in this content library + """ + library_key = LibraryLocatorV2.from_string(lib_key_str) + api.require_permission_for_library_key(library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY) + serializer = serializers.LibraryContainerMetadataSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + container_type = serializer.validated_data['container_type'] + container = api.create_container( + library_key, + container_type, + title=serializer.validated_data['display_name'], + slug=serializer.validated_data.get('slug'), + user_id=request.user.id, + ) + + return Response(serializers.LibraryContainerMetadataSerializer(container).data) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryContainerView(GenericAPIView): + """ + View to retrieve, delete or update data about a specific container (a section, subsection, or unit) + """ + serializer_class = serializers.LibraryContainerMetadataSerializer + + @convert_exceptions + @swagger_auto_schema( + responses={200: serializers.LibraryContainerMetadataSerializer} + ) + def get(self, request, container_key: LibraryContainerLocator): + """ + Get information about a container + """ + api.require_permission_for_library_key( + container_key.lib_key, + request.user, + permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, + ) + container = api.get_container(container_key, include_collections=True) + return Response(serializers.LibraryContainerMetadataSerializer(container).data) + + @convert_exceptions + @swagger_auto_schema( + request_body=serializers.LibraryContainerUpdateSerializer, + responses={200: serializers.LibraryContainerMetadataSerializer} + ) + def patch(self, request, container_key: LibraryContainerLocator): + """ + Update a Container. + """ + api.require_permission_for_library_key( + container_key.lib_key, + request.user, + permissions.CAN_EDIT_THIS_CONTENT_LIBRARY, + ) + serializer = serializers.LibraryContainerUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + container = api.update_container( + container_key, + display_name=serializer.validated_data['display_name'], + user_id=request.user.id, + ) + + return Response(serializers.LibraryContainerMetadataSerializer(container).data) + + @convert_exceptions + def delete(self, request, container_key: LibraryContainerLocator): + """ + Delete a Container (soft delete). + """ + api.require_permission_for_library_key( + container_key.lib_key, + request.user, + permissions.CAN_EDIT_THIS_CONTENT_LIBRARY, + ) + + api.delete_container( + container_key, + ) + + return Response({}, status=HTTP_204_NO_CONTENT) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryContainerChildrenView(GenericAPIView): + """ + View to get or update children of specific container (a section, subsection, or unit) + """ + serializer_class = serializers.LibraryXBlockMetadataSerializer + + @convert_exceptions + @swagger_auto_schema( + responses={ + 200: list[serializers.LibraryXBlockMetadataSerializer] + | list[serializers.LibraryContainerMetadataSerializer] + } + ) + def get(self, request, container_key: LibraryContainerLocator): + """ + Get children of given container + Example: + GET /api/libraries/v2/containers//children/ + Result: + [ + { + 'block_type': 'problem', + 'can_stand_alone': True, + 'collections': [], + 'created': '2025-03-21T13:53:55Z', + 'def_key': None, + 'display_name': 'Blank Problem', + 'has_unpublished_changes': True, + 'id': 'lb:CL-TEST:containers:problem:Problem1', + 'last_draft_created': '2025-03-21T13:53:55Z', + 'last_draft_created_by': 'Bob', + 'last_published': None, + 'modified': '2025-03-21T13:53:55Z', + 'published_by': None, + }, + { + 'block_type': 'html', + 'can_stand_alone': False, + 'collections': [], + 'created': '2025-03-21T13:53:55Z', + 'def_key': None, + 'display_name': 'Text', + 'has_unpublished_changes': True, + 'id': 'lb:CL-TEST:containers:html:Html1', + 'last_draft_created': '2025-03-21T13:53:55Z', + 'last_draft_created_by': 'Bob', + 'last_published': None, + 'modified': '2025-03-21T13:53:55Z', + 'published_by': None, + } + ] + """ + published = request.GET.get('published', 'false').lower() == 'true' + api.require_permission_for_library_key( + container_key.lib_key, + request.user, + permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, + ) + child_entities = api.get_container_children(container_key, published=published) + if container_key.container_type == api.ContainerType.Unit.value: + data = serializers.LibraryXBlockMetadataSerializer(child_entities, many=True).data + else: + data = serializers.LibraryContainerMetadataSerializer(child_entities, many=True).data + return Response(data) + + def _update_component_children( + self, + request, + container_key: LibraryContainerLocator, + action: authoring_api.ChildrenEntitiesAction, + ): + """ + Helper function to update children in container. + """ + api.require_permission_for_library_key( + container_key.lib_key, + request.user, + permissions.CAN_EDIT_THIS_CONTENT_LIBRARY, + ) + serializer = serializers.ContentLibraryItemContainerKeysSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + container = api.update_container_children( + container_key, + children_ids=serializer.validated_data["usage_keys"], + user_id=request.user.id, + entities_action=action, + ) + return Response(serializers.LibraryContainerMetadataSerializer(container).data) + + @convert_exceptions + @swagger_auto_schema( + request_body=serializers.ContentLibraryItemContainerKeysSerializer, + responses={200: serializers.LibraryContainerMetadataSerializer} + ) + def post(self, request, container_key: LibraryContainerLocator): + """ + Add items to container + Example: + POST /api/libraries/v2/containers//children/ + Request body: + {"usage_keys": ['lb:CL-TEST:containers:problem:Problem1', 'lb:CL-TEST:containers:html:Html1']} + """ + return self._update_component_children( + request, + container_key, + action=authoring_api.ChildrenEntitiesAction.APPEND, + ) + + @convert_exceptions + @swagger_auto_schema( + request_body=serializers.ContentLibraryItemContainerKeysSerializer, + responses={200: serializers.LibraryContainerMetadataSerializer} + ) + def delete(self, request, container_key: LibraryContainerLocator): + """ + Remove items from container + Example: + DELETE /api/libraries/v2/containers//children/ + Request body: + {"usage_keys": ['lb:CL-TEST:containers:problem:Problem1', 'lb:CL-TEST:containers:html:Html1']} + """ + return self._update_component_children( + request, + container_key, + action=authoring_api.ChildrenEntitiesAction.REMOVE, + ) + + @convert_exceptions + @swagger_auto_schema( + request_body=serializers.ContentLibraryItemContainerKeysSerializer, + responses={200: serializers.LibraryContainerMetadataSerializer} + ) + def patch(self, request, container_key: LibraryContainerLocator): + """ + Replace items in container, can be used to reorder items as well. + Example: + PATCH /api/libraries/v2/containers//children/ + Request body: + {"usage_keys": ['lb:CL-TEST:containers:problem:Problem1', 'lb:CL-TEST:containers:html:Html1']} + """ + return self._update_component_children( + request, + container_key, + action=authoring_api.ChildrenEntitiesAction.REPLACE, + ) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryContainerRestore(GenericAPIView): + """ + View to restore soft-deleted library containers. + """ + @convert_exceptions + def post(self, request, container_key: LibraryContainerLocator) -> Response: + """ + Restores a soft-deleted library container + """ + api.require_permission_for_library_key( + container_key.lib_key, + request.user, + permissions.CAN_EDIT_THIS_CONTENT_LIBRARY, + ) + api.restore_container(container_key) + return Response(None, status=HTTP_204_NO_CONTENT) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryContainerCollectionsView(GenericAPIView): + """ + View to set collections for a container. + """ + @convert_exceptions + def patch(self, request: RestRequest, container_key: LibraryContainerLocator) -> Response: + """ + Sets Collections for a Component. + + Collection and Components must all be part of the given library/learning package. + """ + content_library = api.require_permission_for_library_key( + container_key.lib_key, + request.user, + permissions.CAN_EDIT_THIS_CONTENT_LIBRARY + ) + serializer = serializers.ContentLibraryItemCollectionsUpdateSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + collection_keys = serializer.validated_data['collection_keys'] + api.set_library_item_collections( + library_key=container_key.lib_key, + entity_key=container_key.container_id, + collection_keys=collection_keys, + created_by=request.user.id, + content_library=content_library, + ) + + return Response({'count': len(collection_keys)}) + + +@method_decorator(non_atomic_requests, name="dispatch") +@view_auth_classes() +class LibraryContainerPublishView(GenericAPIView): + """ + View to publish a container, or revert to last published. + """ + @convert_exceptions + def post(self, request: RestRequest, container_key: LibraryContainerLocator) -> Response: + """ + Publish the container and its children + """ + api.require_permission_for_library_key( + container_key.lib_key, + request.user, + permissions.CAN_EDIT_THIS_CONTENT_LIBRARY, + ) + api.publish_container_changes(container_key, request.user.id) + # If we need to in the future, we could return a list of all the child containers/components that were + # auto-published as a result. + return Response({}) diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/rest_api/libraries.py similarity index 71% rename from openedx/core/djangoapps/content_libraries/views.py rename to openedx/core/djangoapps/content_libraries/rest_api/libraries.py index e30e58e75a..869b65a3ea 100644 --- a/openedx/core/djangoapps/content_libraries/views.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/libraries.py @@ -62,8 +62,6 @@ the api module instead. to Learning Core) atomic: https://github.com/openedx/edx-platform/pull/30456 """ - -from functools import wraps import itertools import json import logging @@ -71,36 +69,36 @@ import logging from django.conf import settings from django.contrib.auth import authenticate, get_user_model, login from django.contrib.auth.models import Group -from django.core.exceptions import ObjectDoesNotExist from django.db.transaction import atomic, non_atomic_requests -from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse, StreamingHttpResponse +from django.http import Http404, HttpResponseBadRequest, JsonResponse from django.shortcuts import get_object_or_404 -from django.urls import reverse from django.utils.decorators import method_decorator from django.utils.translation import gettext as _ 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 import edx_api_doc_tools as apidocs -from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 -from openedx_learning.api import authoring from organizations.api import ensure_organization from organizations.exceptions import InvalidOrganizationException from organizations.models import Organization from rest_framework import status from rest_framework.exceptions import NotFound, PermissionDenied, ValidationError from rest_framework.generics import GenericAPIView -from rest_framework.parsers import MultiPartParser from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.viewsets import GenericViewSet +from cms.djangoapps.contentstore.views.course import ( + get_allowed_organizations_for_libraries, + user_can_create_organizations, +) from openedx.core.djangoapps.content_libraries import api, permissions -from openedx.core.djangoapps.content_libraries.serializers import ( +from openedx.core.djangoapps.content_libraries.rest_api.serializers import ( ContentLibraryBlockImportTaskCreateSerializer, ContentLibraryBlockImportTaskSerializer, ContentLibraryFilterSerializer, @@ -108,65 +106,25 @@ from openedx.core.djangoapps.content_libraries.serializers import ( ContentLibraryPermissionLevelSerializer, ContentLibraryPermissionSerializer, ContentLibraryUpdateSerializer, - ContentLibraryComponentCollectionsUpdateSerializer, LibraryXBlockCreationSerializer, LibraryXBlockMetadataSerializer, LibraryXBlockTypeSerializer, - LibraryXBlockOlxSerializer, - LibraryXBlockStaticFileSerializer, - LibraryXBlockStaticFilesSerializer, ContentLibraryAddPermissionByEmailSerializer, - LibraryPasteClipboardSerializer, + PublishableItemSerializer, ) import openedx.core.djangoapps.site_configuration.helpers as configuration_helpers from openedx.core.lib.api.view_utils import view_auth_classes from openedx.core.djangoapps.safe_sessions.middleware import mark_user_change_as_expected from openedx.core.djangoapps.xblock import api as xblock_api -from .models import ContentLibrary, LtiGradedResource, LtiProfile +from .utils import convert_exceptions +from ..models import ContentLibrary, LtiGradedResource, LtiProfile User = get_user_model() log = logging.getLogger(__name__) -def convert_exceptions(fn): - """ - Catch any Content Library API exceptions that occur and convert them to - DRF exceptions so DRF will return an appropriate HTTP response - """ - - @wraps(fn) - def wrapped_fn(*args, **kwargs): - try: - return fn(*args, **kwargs) - except InvalidKeyError as exc: - log.exception(str(exc)) - raise NotFound # lint-amnesty, pylint: disable=raise-missing-from - except api.ContentLibraryNotFound: - log.exception("Content library not found") - raise NotFound # lint-amnesty, pylint: disable=raise-missing-from - except api.ContentLibraryBlockNotFound: - log.exception("XBlock not found in content library") - raise NotFound # lint-amnesty, pylint: disable=raise-missing-from - except api.ContentLibraryCollectionNotFound: - log.exception("Collection not found in content library") - raise NotFound # lint-amnesty, pylint: disable=raise-missing-from - except api.LibraryCollectionAlreadyExists as exc: - log.exception(str(exc)) - raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from - except api.LibraryBlockAlreadyExists as exc: - log.exception(str(exc)) - raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from - except api.InvalidNameError as exc: - log.exception(str(exc)) - raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from - except api.BlockLimitReachedError as exc: - log.exception(str(exc)) - raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from - return wrapped_fn - - class LibraryApiPaginationDocs: """ API docs for query params related to paginating ContentLibraryMetadata objects. @@ -196,8 +154,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( @@ -268,6 +228,14 @@ class LibraryRootView(GenericAPIView): raise ValidationError( # lint-amnesty, pylint: disable=raise-missing-from detail={"org": f"No such organization '{org_name}' found."} ) + # Ensure the user is allowed to create libraries under this org + if not ( + user_can_create_organizations(request.user) or + org_name in get_allowed_organizations_for_libraries(request.user) + ): + raise ValidationError( # lint-amnesty, pylint: disable=raise-missing-from + detail={"org": f"User not allowed to create libraries in '{org_name}'."} + ) org = Organization.objects.get(short_name=org_name) try: @@ -507,7 +475,7 @@ class LibraryCommitView(APIView): """ key = LibraryLocatorV2.from_string(lib_key_str) api.require_permission_for_library_key(key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY) - api.revert_changes(key) + api.revert_changes(key, request.user.id) return Response({}) @@ -517,26 +485,25 @@ class LibraryPasteClipboardView(GenericAPIView): """ Paste content of clipboard into Library. """ + serializer_class = PublishableItemSerializer + @convert_exceptions + @swagger_auto_schema( + responses={200: PublishableItemSerializer} + ) def post(self, request, lib_key_str): """ Import the contents of the user's clipboard and paste them into the Library """ library_key = LibraryLocatorV2.from_string(lib_key_str) api.require_permission_for_library_key(library_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY) - serializer = LibraryPasteClipboardSerializer(data=request.data) - serializer.is_valid(raise_exception=True) try: - result = api.import_staged_content_from_user_clipboard( - library_key, request.user, **serializer.validated_data - ) + result = api.import_staged_content_from_user_clipboard(library_key, request.user) except api.IncompatibleTypesError as err: - raise ValidationError( # lint-amnesty, pylint: disable=raise-missing-from - detail={'block_type': str(err)}, - ) + raise ValidationError(detail={'block_type': str(err)}) from err - return Response(LibraryXBlockMetadataSerializer(result).data) + return Response(PublishableItemSerializer(result).data) @method_decorator(non_atomic_requests, name="dispatch") @@ -545,6 +512,7 @@ class LibraryBlocksView(GenericAPIView): """ Views to work with XBlocks in a specific content library. """ + serializer_class = LibraryXBlockMetadataSerializer @apidocs.schema( parameters=[ @@ -582,6 +550,10 @@ class LibraryBlocksView(GenericAPIView): 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 @@ -644,192 +616,6 @@ class LibraryBlockView(APIView): return Response({}) -@method_decorator(non_atomic_requests, name="dispatch") -@view_auth_classes() -class LibraryBlockCollectionsView(APIView): - """ - View to set collections for a component. - """ - @convert_exceptions - def patch(self, request, usage_key_str) -> Response: - """ - Sets Collections for a Component. - - Collection and Components must all be part of the given library/learning package. - """ - key = LibraryUsageLocatorV2.from_string(usage_key_str) - content_library = api.require_permission_for_library_key( - key.lib_key, - request.user, - permissions.CAN_EDIT_THIS_CONTENT_LIBRARY - ) - component = api.get_component_from_usage_key(key) - serializer = ContentLibraryComponentCollectionsUpdateSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - - collection_keys = serializer.validated_data['collection_keys'] - api.set_library_component_collections( - library_key=key.lib_key, - component=component, - collection_keys=collection_keys, - created_by=self.request.user.id, - content_library=content_library, - ) - - return Response({'count': len(collection_keys)}) - - -@method_decorator(non_atomic_requests, name="dispatch") -@view_auth_classes() -class LibraryBlockLtiUrlView(APIView): - """ - Views to generate LTI URL for existing XBlocks in a content library. - - Returns 404 in case the block not found by the given key. - """ - @convert_exceptions - def get(self, request, usage_key_str): - """ - Get the LTI launch URL for the XBlock. - """ - key = LibraryUsageLocatorV2.from_string(usage_key_str) - api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) - - # Get the block to validate its existence - api.get_library_block(key) - lti_login_url = f"{reverse('content_libraries:lti-launch')}?id={key}" - return Response({"lti_url": lti_login_url}) - - -@method_decorator(non_atomic_requests, name="dispatch") -@view_auth_classes() -class LibraryBlockOlxView(APIView): - """ - Views to work with an existing XBlock's OLX - """ - @convert_exceptions - def get(self, request, usage_key_str): - """ - Get the block's OLX - """ - key = LibraryUsageLocatorV2.from_string(usage_key_str) - api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) - xml_str = xblock_api.get_block_draft_olx(key) - return Response(LibraryXBlockOlxSerializer({"olx": xml_str}).data) - - @convert_exceptions - def post(self, request, usage_key_str): - """ - Replace the block's OLX. - - This API is only meant for use by developers or API client applications. - Very little validation is done. - """ - key = LibraryUsageLocatorV2.from_string(usage_key_str) - api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY) - serializer = LibraryXBlockOlxSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - new_olx_str = serializer.validated_data["olx"] - try: - version_num = api.set_library_block_olx(key, new_olx_str).version_num - except ValueError as err: - raise ValidationError(detail=str(err)) # lint-amnesty, pylint: disable=raise-missing-from - return Response(LibraryXBlockOlxSerializer({"olx": new_olx_str, "version_num": version_num}).data) - - -@method_decorator(non_atomic_requests, name="dispatch") -@view_auth_classes() -class LibraryBlockAssetListView(APIView): - """ - Views to list an existing XBlock's static asset files - """ - @convert_exceptions - def get(self, request, usage_key_str): - """ - List the static asset files belonging to this block. - """ - key = LibraryUsageLocatorV2.from_string(usage_key_str) - api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) - files = api.get_library_block_static_asset_files(key) - return Response(LibraryXBlockStaticFilesSerializer({"files": files}).data) - - -@method_decorator(non_atomic_requests, name="dispatch") -@view_auth_classes() -class LibraryBlockAssetView(APIView): - """ - Views to work with an existing XBlock's static asset files - """ - parser_classes = (MultiPartParser, ) - - @convert_exceptions - def get(self, request, usage_key_str, file_path): - """ - Get a static asset file belonging to this block. - """ - key = LibraryUsageLocatorV2.from_string(usage_key_str) - api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY) - files = api.get_library_block_static_asset_files(key) - for f in files: - if f.path == file_path: - return Response(LibraryXBlockStaticFileSerializer(f).data) - raise NotFound - - @convert_exceptions - def put(self, request, usage_key_str, file_path): - """ - Replace a static asset file belonging to this block. - """ - usage_key = LibraryUsageLocatorV2.from_string(usage_key_str) - api.require_permission_for_library_key( - usage_key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY, - ) - file_wrapper = request.data['content'] - if file_wrapper.size > 20 * 1024 * 1024: # > 20 MiB - # TODO: This check was written when V2 Libraries were backed by the Blockstore micro-service. - # Now that we're on Learning Core, do we still need it? Here's the original comment: - # In the future, we need a way to use file_wrapper.chunks() to read - # the file in chunks and stream that to Blockstore, but Blockstore - # currently lacks an API for streaming file uploads. - # Ref: https://github.com/openedx/edx-platform/issues/34737 - raise ValidationError("File too big") - file_content = file_wrapper.read() - try: - result = api.add_library_block_static_asset_file(usage_key, file_path, file_content, request.user) - except ValueError: - raise ValidationError("Invalid file path") # lint-amnesty, pylint: disable=raise-missing-from - return Response(LibraryXBlockStaticFileSerializer(result).data) - - @convert_exceptions - def delete(self, request, usage_key_str, file_path): - """ - Delete a static asset file belonging to this block. - """ - usage_key = LibraryUsageLocatorV2.from_string(usage_key_str) - api.require_permission_for_library_key( - usage_key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY, - ) - try: - api.delete_library_block_static_asset_file(usage_key, file_path, request.user) - except ValueError: - raise ValidationError("Invalid file path") # lint-amnesty, pylint: disable=raise-missing-from - return Response(status=status.HTTP_204_NO_CONTENT) - - -@method_decorator(non_atomic_requests, name="dispatch") -@view_auth_classes() -class LibraryBlockPublishView(APIView): - """ - Commit/publish all of the draft changes made to the component. - """ - - @convert_exceptions - def post(self, request, usage_key_str): - key = LibraryUsageLocatorV2.from_string(usage_key_str) - api.publish_component_changes(key, request.user) - return Response({}) - - @method_decorator(non_atomic_requests, name="dispatch") @view_auth_classes() class LibraryImportTaskViewSet(GenericViewSet): @@ -837,6 +623,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): """ @@ -848,7 +637,7 @@ class LibraryImportTaskViewSet(GenericViewSet): request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY ) - queryset = api.ContentLibrary.objects.get_by_key(library_key).import_tasks + queryset = ContentLibrary.objects.get_by_key(library_key).import_tasks result = ContentLibraryBlockImportTaskSerializer(queryset, many=True).data return self.get_paginated_response( @@ -856,6 +645,10 @@ class LibraryImportTaskViewSet(GenericViewSet): ) @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. @@ -1160,105 +953,3 @@ class LtiToolJwksView(LtiToolView): Return the JWKS. """ return JsonResponse(self.lti_tool_config.get_jwks(), safe=False) - - -def get_component_version_asset(request, component_version_uuid, asset_path): - """ - Serves static assets associated with particular Component versions. - - Important notes: - * This is meant for Studio/authoring use ONLY. It requires read access to - the content library. - * It uses the UUID because that's easier to parse than the key field (which - could be part of an OpaqueKey, but could also be almost anything else). - * This is not very performant, and we still want to use the X-Accel-Redirect - method for serving LMS traffic in the longer term (and probably Studio - eventually). - """ - try: - component_version = authoring.get_component_version_by_uuid( - component_version_uuid - ) - except ObjectDoesNotExist as exc: - raise Http404() from exc - - # Permissions check... - learning_package = component_version.component.learning_package - library_key = LibraryLocatorV2.from_string(learning_package.key) - api.require_permission_for_library_key( - library_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY, - ) - - # We already have logic for getting the correct content and generating the - # proper headers in Learning Core, but the response generated here is an - # X-Accel-Redirect and lacks the actual content. We eventually want to use - # this response in conjunction with a media reverse proxy (Caddy or Nginx), - # but in the short term we're just going to remove the redirect and stream - # the content directly. - redirect_response = authoring.get_redirect_response_for_component_asset( - component_version_uuid, - asset_path, - public=False, - ) - - # If there was any error, we return that response because it will have the - # correct headers set and won't have any X-Accel-Redirect header set. - if redirect_response.status_code != 200: - return redirect_response - - # If we got here, we know that the asset exists and it's okay to download. - cv_content = component_version.componentversioncontent_set.get(key=asset_path) - content = cv_content.content - - # Delete the re-direct part of the response headers. We'll copy the rest. - headers = redirect_response.headers - headers.pop('X-Accel-Redirect') - - # We need to set the content size header manually because this is a - # streaming response. It's not included in the redirect headers because it's - # not needed there (the reverse-proxy would have direct access to the file). - headers['Content-Length'] = content.size - - if request.method == "HEAD": - return HttpResponse(headers=headers) - - # Otherwise it's going to be a GET response. We don't support response - # offsets or anything fancy, because we don't expect to run this view at - # LMS-scale. - return StreamingHttpResponse( - content.read_file().chunks(), - headers=redirect_response.headers, - ) - - -@view_auth_classes() -class LibraryComponentAssetView(APIView): - """ - Serves static assets associated with particular Component versions. - """ - @convert_exceptions - def get(self, request, component_version_uuid, asset_path): - """ - GET API for fetching static asset for given component_version_uuid. - """ - return get_component_version_asset(request, component_version_uuid, asset_path) - - -@view_auth_classes() -class LibraryComponentDraftAssetView(APIView): - """ - Serves the draft version of static assets associated with a Library Component. - - See `get_component_version_asset` for more details - """ - @convert_exceptions - def get(self, request, usage_key, asset_path): - """ - Fetches component_version_uuid for given usage_key and returns component asset. - """ - try: - component_version_uuid = api.get_component_from_usage_key(usage_key).versioning.draft.uuid - except ObjectDoesNotExist as exc: - raise Http404() from exc - - return get_component_version_asset(request, component_version_uuid, asset_path) diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py similarity index 69% rename from openedx/core/djangoapps/content_libraries/serializers.py rename to openedx/core/djangoapps/content_libraries/rest_api/serializers.py index d639ed63af..6734babd00 100644 --- a/openedx/core/djangoapps/content_libraries/serializers.py +++ b/openedx/core/djangoapps/content_libraries/rest_api/serializers.py @@ -6,10 +6,12 @@ from django.core.validators import validate_unicode_slug from rest_framework import serializers from rest_framework.exceptions import ValidationError -from opaque_keys.edx.keys import UsageKeyV2 +from opaque_keys import OpaqueKey +from opaque_keys.edx.locator import LibraryContainerLocator, LibraryUsageLocatorV2 from opaque_keys import InvalidKeyError from openedx_learning.api.authoring_models import Collection +from openedx.core.djangoapps.content_libraries.api.containers import ContainerType from openedx.core.djangoapps.content_libraries.constants import ( ALL_RIGHTS_RESERVED, LICENSE_OPTIONS, @@ -19,7 +21,7 @@ from openedx.core.djangoapps.content_libraries.models import ( ContentLibrary ) from openedx.core.lib.api.serializers import CourseKeyField -from . import permissions +from .. import permissions DATETIME_FORMAT = '%Y-%m-%dT%H:%M:%SZ' @@ -130,20 +132,14 @@ class CollectionMetadataSerializer(serializers.Serializer): title = serializers.CharField() -class LibraryXBlockMetadataSerializer(serializers.Serializer): +class PublishableItemSerializer(serializers.Serializer): """ - Serializer for LibraryXBlockMetadata + Serializer for any PublishableItem in a library (XBlock, Container, etc.) """ - id = serializers.CharField(source="usage_key", read_only=True) - - # TODO: Remove this serializer field once the frontend no longer relies on - # it. Learning Core doesn't use definition IDs, but we're passing this dummy - # value back to preserve the REST API contract (just to reduce the number of - # things we're changing at one time). - def_key = serializers.ReadOnlyField(default=None) - - block_type = serializers.CharField(source="usage_key.block_type") - display_name = serializers.CharField(read_only=True) + id = serializers.SerializerMethodField() + display_name = serializers.CharField() + published_display_name = serializers.CharField(required=False) + tags_count = serializers.IntegerField(read_only=True) last_published = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) published_by = serializers.CharField(read_only=True) last_draft_created = serializers.DateTimeField(format=DATETIME_FORMAT, read_only=True) @@ -155,9 +151,28 @@ class LibraryXBlockMetadataSerializer(serializers.Serializer): # When creating a new XBlock in a library, the slug becomes the ID part of # the definition key and usage key: slug = serializers.CharField(write_only=True) - tags_count = serializers.IntegerField(read_only=True) collections = CollectionMetadataSerializer(many=True, required=False) + can_stand_alone = serializers.BooleanField(read_only=True) + + # Fields that are _sometimes_ set, depending on the subclass: + block_type = serializers.CharField(source="usage_key.block_type", required=False) + container_type = serializers.CharField(source="container_key.block_type", required=False) + + def get_id(self, obj) -> str: + """ Get a unique ID for this PublishableItem """ + if hasattr(obj, "usage_key"): + return str(obj.usage_key) + elif hasattr(obj, "container_key"): + return str(obj.container_key) + return "" + + +class LibraryXBlockMetadataSerializer(PublishableItemSerializer): + """ + Serializer for LibraryXBlockMetadata + """ + block_type = serializers.CharField(source="usage_key.block_type") class LibraryXBlockTypeSerializer(serializers.Serializer): @@ -192,12 +207,8 @@ class LibraryXBlockCreationSerializer(serializers.Serializer): # creating new block from scratch staged_content = serializers.CharField(required=False) - -class LibraryPasteClipboardSerializer(serializers.Serializer): - """ - Serializer for pasting clipboard data into library - """ - block_id = serializers.CharField(validators=(validate_unicode_slug, )) + # Optional param defaults to True, set to False if block is being created under a container. + can_stand_alone = serializers.BooleanField(required=False, default=True) class LibraryXBlockOlxSerializer(serializers.Serializer): @@ -230,6 +241,36 @@ class LibraryXBlockStaticFilesSerializer(serializers.Serializer): files = LibraryXBlockStaticFileSerializer(many=True) +class LibraryContainerMetadataSerializer(PublishableItemSerializer): + """ + Serializer for Containers like Sections, Subsections, Units + + Converts from ContainerMetadata to JSON-compatible data + """ + # Use 'source' to get this as a string, not an enum value instance which the container_type field has. + container_type = serializers.CharField(source="container_key.container_type") + + # When creating a new container in a library, the slug becomes the ID part of + # the definition key and usage key: + slug = serializers.CharField(write_only=True, required=False) + + def to_internal_value(self, data): + """ + Convert JSON-ish data back to native python types. + Returns a dictionary, not a ContainerMetadata instance. + """ + result = super().to_internal_value(data) + result["container_type"] = ContainerType(data["container_type"]) + return result + + +class LibraryContainerUpdateSerializer(serializers.Serializer): + """ + Serializer for updating metadata for Containers like Sections, Subsections, Units + """ + display_name = serializers.CharField() + + class ContentLibraryBlockImportTaskSerializer(serializers.ModelSerializer): """ Serializer for a Content Library block import task. @@ -276,39 +317,72 @@ class ContentLibraryCollectionUpdateSerializer(serializers.Serializer): description = serializers.CharField(allow_blank=True) -class UsageKeyV2Serializer(serializers.Serializer): +class UsageKeyV2Serializer(serializers.BaseSerializer): """ - Serializes a UsageKeyV2. + Serializes a library Component (XBlock) key. """ - def to_representation(self, value: UsageKeyV2) -> str: + def to_representation(self, value: LibraryUsageLocatorV2) -> str: """ - Returns the UsageKeyV2 value as a string. + Returns the LibraryUsageLocatorV2 value as a string. """ return str(value) - def to_internal_value(self, value: str) -> UsageKeyV2: + def to_internal_value(self, value: str) -> LibraryUsageLocatorV2: """ - Returns a UsageKeyV2 from the string value. + Returns a LibraryUsageLocatorV2 from the string value. - Raises ValidationError if invalid UsageKeyV2. + Raises ValidationError if invalid LibraryUsageLocatorV2. """ try: - return UsageKeyV2.from_string(value) + return LibraryUsageLocatorV2.from_string(value) except InvalidKeyError as err: raise ValidationError from err -class ContentLibraryCollectionComponentsUpdateSerializer(serializers.Serializer): +class OpaqueKeySerializer(serializers.BaseSerializer): """ - Serializer for adding/removing Components to/from a Collection. + Serializes a OpaqueKey with the correct class. """ + def to_representation(self, value: OpaqueKey) -> str: + """ + Returns the OpaqueKey value as a string. + """ + return str(value) - usage_keys = serializers.ListField(child=UsageKeyV2Serializer(), allow_empty=False) + def to_internal_value(self, value: str) -> OpaqueKey: + """ + Returns a LibraryUsageLocatorV2 or a LibraryContainerLocator from the string value. + + Raises ValidationError if invalid UsageKeyV2 or LibraryContainerLocator. + """ + try: + return LibraryUsageLocatorV2.from_string(value) + except InvalidKeyError: + try: + return LibraryContainerLocator.from_string(value) + except InvalidKeyError as err: + raise ValidationError from err -class ContentLibraryComponentCollectionsUpdateSerializer(serializers.Serializer): +class ContentLibraryItemContainerKeysSerializer(serializers.Serializer): """ - Serializer for adding/removing Collections to/from a Component. + Serializer for adding/removing items to/from a Container. + """ + + usage_keys = serializers.ListField(child=OpaqueKeySerializer(), allow_empty=False) + + +class ContentLibraryItemKeysSerializer(serializers.Serializer): + """ + Serializer for adding/removing items to/from a Collection. + """ + + usage_keys = serializers.ListField(child=OpaqueKeySerializer(), allow_empty=False) + + +class ContentLibraryItemCollectionsUpdateSerializer(serializers.Serializer): + """ + Serializer for adding/removing Collections to/from a Library Item (component, unit, etc..). """ collection_keys = serializers.ListField(child=serializers.CharField(), allow_empty=True) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/url_converters.py b/openedx/core/djangoapps/content_libraries/rest_api/url_converters.py new file mode 100644 index 0000000000..c8c4b1c6eb --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/rest_api/url_converters.py @@ -0,0 +1,23 @@ +""" +URL pattern converters +https://docs.djangoproject.com/en/5.1/topics/http/urls/#registering-custom-path-converters +""" +from opaque_keys import InvalidKeyError +from opaque_keys.edx.locator import LibraryContainerLocator + + +class LibraryContainerLocatorConverter: + """ + Converter that matches library container IDs like: + lct:CL-TEST:containers:unit:u1 + """ + regex = r'[\w-]+(:[\w\-.]+)+' + + def to_python(self, value: str) -> LibraryContainerLocator: + try: + return LibraryContainerLocator.from_string(value) + except InvalidKeyError as exc: + raise ValueError from exc + + def to_url(self, value: LibraryContainerLocator) -> str: + return str(value) diff --git a/openedx/core/djangoapps/content_libraries/rest_api/utils.py b/openedx/core/djangoapps/content_libraries/rest_api/utils.py new file mode 100644 index 0000000000..99825fbe75 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/rest_api/utils.py @@ -0,0 +1,52 @@ +""" +REST API utilities for content libraries +""" +from functools import wraps +import logging + +from opaque_keys import InvalidKeyError +from rest_framework.exceptions import NotFound, ValidationError + +from .. import api + +log = logging.getLogger(__name__) + + +def convert_exceptions(fn): + """ + Catch any Content Library API exceptions that occur and convert them to + DRF exceptions so DRF will return an appropriate HTTP response + """ + + @wraps(fn) + def wrapped_fn(*args, **kwargs): + try: + return fn(*args, **kwargs) + except InvalidKeyError as exc: + log.exception(str(exc)) + raise NotFound # lint-amnesty, pylint: disable=raise-missing-from + except api.ContentLibraryNotFound: + log.exception("Content library not found") + raise NotFound # lint-amnesty, pylint: disable=raise-missing-from + except api.ContentLibraryBlockNotFound: + log.exception("XBlock not found in content library") + raise NotFound # lint-amnesty, pylint: disable=raise-missing-from + except api.ContentLibraryCollectionNotFound: + log.exception("Collection not found in content library") + raise NotFound # lint-amnesty, pylint: disable=raise-missing-from + except api.ContentLibraryContainerNotFound: + log.exception("Container not found in content library") + raise NotFound # lint-amnesty, pylint: disable=raise-missing-from + except api.LibraryCollectionAlreadyExists as exc: + log.exception(str(exc)) + raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from + except api.LibraryBlockAlreadyExists as exc: + log.exception(str(exc)) + raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from + except api.InvalidNameError as exc: + log.exception(str(exc)) + raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from + except api.BlockLimitReachedError as exc: + log.exception(str(exc)) + raise ValidationError(str(exc)) # lint-amnesty, pylint: disable=raise-missing-from + return wrapped_fn diff --git a/openedx/core/djangoapps/content_libraries/signal_handlers.py b/openedx/core/djangoapps/content_libraries/signal_handlers.py index 58f45d218e..8a5b8bd89c 100644 --- a/openedx/core/djangoapps/content_libraries/signal_handlers.py +++ b/openedx/core/djangoapps/content_libraries/signal_handlers.py @@ -5,30 +5,25 @@ Content library signal handlers. import logging from django.conf import settings -from django.db.models.signals import post_save, post_delete, m2m_changed +from django.db.models.signals import m2m_changed, post_delete, post_save from django.dispatch import receiver - -from opaque_keys import InvalidKeyError +from opaque_keys import InvalidKeyError, OpaqueKey from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 -from openedx_events.content_authoring.data import ( - ContentObjectChangedData, - LibraryCollectionData, -) +from openedx_events.content_authoring.data import ContentObjectChangedData, LibraryCollectionData from openedx_events.content_authoring.signals import ( CONTENT_OBJECT_ASSOCIATIONS_CHANGED, LIBRARY_COLLECTION_CREATED, LIBRARY_COLLECTION_DELETED, - LIBRARY_COLLECTION_UPDATED, + LIBRARY_COLLECTION_UPDATED ) -from openedx_learning.api.authoring import get_component, get_components -from openedx_learning.api.authoring_models import Collection, CollectionPublishableEntity, Component, PublishableEntity +from openedx_learning.api.authoring import get_components, get_containers +from openedx_learning.api.authoring_models import Collection, CollectionPublishableEntity, PublishableEntity from lms.djangoapps.grades.api import signals as grades_signals -from .api import library_component_usage_key +from .api import library_collection_locator, library_component_usage_key, library_container_locator from .models import ContentLibrary, LtiGradedResource - log = logging.getLogger(__name__) @@ -85,17 +80,25 @@ def library_collection_saved(sender, instance, created, **kwargs): return if created: + # .. event_implemented_name: LIBRARY_COLLECTION_CREATED + # .. event_type: org.openedx.content_authoring.content_library.collection.created.v1 LIBRARY_COLLECTION_CREATED.send_event( library_collection=LibraryCollectionData( - library_key=library.library_key, - collection_key=instance.key, + collection_key=library_collection_locator( + library_key=library.library_key, + collection_key=instance.key, + ), ) ) else: + # .. event_implemented_name: LIBRARY_COLLECTION_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.collection.updated.v1 LIBRARY_COLLECTION_UPDATED.send_event( library_collection=LibraryCollectionData( - library_key=library.library_key, - collection_key=instance.key, + collection_key=library_collection_locator( + library_key=library.library_key, + collection_key=instance.key, + ), ) ) @@ -111,41 +114,59 @@ def library_collection_deleted(sender, instance, **kwargs): log.error("{instance} is not associated with a content library.") return + # .. event_implemented_name: LIBRARY_COLLECTION_DELETED + # .. event_type: org.openedx.content_authoring.content_library.collection.deleted.v1 LIBRARY_COLLECTION_DELETED.send_event( library_collection=LibraryCollectionData( - library_key=library.library_key, - collection_key=instance.key, + collection_key=library_collection_locator( + library_key=library.library_key, + collection_key=instance.key, + ), ) ) -def _library_collection_component_changed( - component: Component, +def _library_collection_entity_changed( + publishable_entity: PublishableEntity, library_key: LibraryLocatorV2 | None = None, ) -> None: """ - Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for the component. + Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for the entity. """ if not library_key: try: library = ContentLibrary.objects.get( - learning_package_id=component.learning_package_id, + learning_package_id=publishable_entity.learning_package_id, ) except ContentLibrary.DoesNotExist: - log.error("{component} is not associated with a content library.") + log.error("{publishable_entity} is not associated with a content library.") return library_key = library.library_key assert library_key - usage_key = library_component_usage_key( - library_key, - component, - ) + opaque_key: OpaqueKey + + if hasattr(publishable_entity, 'component'): + opaque_key = library_component_usage_key( + library_key, + publishable_entity.component, + ) + elif hasattr(publishable_entity, 'container'): + opaque_key = library_container_locator( + library_key, + publishable_entity.container, + ) + else: + log.error("Unknown publishable entity type: %s", publishable_entity) + return + + # .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED + # .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1 CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( content_object=ContentObjectChangedData( - object_id=str(usage_key), + object_id=str(opaque_key), changes=["collections"], ), ) @@ -157,9 +178,7 @@ def library_collection_entity_saved(sender, instance, created, **kwargs): Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for components added to a collection. """ if created: - # Component.pk matches its entity.pk - component = get_component(instance.entity_id) - _library_collection_component_changed(component) + _library_collection_entity_changed(instance.entity) @receiver(post_delete, sender=CollectionPublishableEntity, dispatch_uid="library_collection_entity_deleted") @@ -169,9 +188,7 @@ def library_collection_entity_deleted(sender, instance, **kwargs): """ # Only trigger component updates if CollectionPublishableEntity was cascade deleted due to deletion of a collection. if isinstance(kwargs.get('origin'), Collection): - # Component.pk matches its entity.pk - component = get_component(instance.entity_id) - _library_collection_component_changed(component) + _library_collection_entity_changed(instance.entity) @receiver(m2m_changed, sender=CollectionPublishableEntity, dispatch_uid="library_collection_entities_changed") @@ -191,15 +208,18 @@ def library_collection_entities_changed(sender, instance, action, pk_set, **kwar return if isinstance(instance, PublishableEntity): - _library_collection_component_changed(instance.component, library.library_key) + _library_collection_entity_changed(instance, library.library_key) return # When action=="post_clear", pk_set==None # Since the collection instance now has an empty entities set, - # we don't know which ones were removed, so we need to update associations for all library components. + # we don't know which ones were removed, so we need to update associations for all library + # components and containers. components = get_components(instance.learning_package_id) + containers = get_containers(instance.learning_package_id) if pk_set: components = components.filter(pk__in=pk_set) + containers = containers.filter(pk__in=pk_set) - for component in components.all(): - _library_collection_component_changed(component, library.library_key) + for entity in list(components.all()) + list(containers.all()): + _library_collection_entity_changed(entity.publishable_entity, library.library_key) diff --git a/openedx/core/djangoapps/content_libraries/tasks.py b/openedx/core/djangoapps/content_libraries/tasks.py index f56b4adfe3..ebc8e27830 100644 --- a/openedx/core/djangoapps/content_libraries/tasks.py +++ b/openedx/core/djangoapps/content_libraries/tasks.py @@ -22,12 +22,35 @@ from celery import shared_task from celery_utils.logged_task import LoggedTask from celery.utils.log import get_task_logger from edx_django_utils.monitoring import set_code_owner_attribute, set_code_owner_attribute_from_module +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import ( + BlockUsageLocator, + LibraryCollectionLocator, + LibraryContainerLocator, + LibraryLocatorV2, +) +from openedx_learning.api import authoring as authoring_api +from openedx_learning.api.authoring_models import DraftChangeLog, PublishLog +from openedx_events.content_authoring.data import ( + LibraryBlockData, + LibraryCollectionData, + LibraryContainerData, +) +from openedx_events.content_authoring.signals import ( + LIBRARY_BLOCK_CREATED, + LIBRARY_BLOCK_DELETED, + LIBRARY_BLOCK_UPDATED, + LIBRARY_BLOCK_PUBLISHED, + LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_CREATED, + LIBRARY_CONTAINER_DELETED, + LIBRARY_CONTAINER_UPDATED, + LIBRARY_CONTAINER_PUBLISHED, +) from user_tasks.tasks import UserTask, UserTaskStatus from xblock.fields import Scope -from opaque_keys.edx.keys import CourseKey -from opaque_keys.edx.locator import BlockUsageLocator from openedx.core.lib import ensure_cms from xmodule.capa_block import ProblemBlock from xmodule.library_content_block import ANY_CAPA_TYPE_VALUE, LegacyLibraryContentBlock @@ -39,10 +62,219 @@ from xmodule.modulestore.mixed import MixedModuleStore from . import api from .models import ContentLibraryBlockImportTask -logger = logging.getLogger(__name__) +log = logging.getLogger(__name__) TASK_LOGGER = get_task_logger(__name__) +@shared_task(base=LoggedTask) +@set_code_owner_attribute +def send_events_after_publish(publish_log_pk: int, library_key_str: str) -> None: + """ + Send events to trigger actions like updating the search index, after we've + published some items in a library. + + We use the PublishLog record so we can detect exactly what was changed, + including any auto-published changes like child items in containers. + + This happens in a celery task so that it can be run asynchronously if + needed, because the "publish all changes" action can potentially publish + hundreds or even thousands of components/containers at once, and synchronous + event handlers like updating the search index may a while to complete in + that case. + """ + publish_log = PublishLog.objects.get(pk=publish_log_pk) + library_key = LibraryLocatorV2.from_string(library_key_str) + affected_entities = publish_log.records.select_related("entity", "entity__container", "entity__component").all() + affected_containers: set[LibraryContainerLocator] = set() + + # Update anything that needs to be updated (e.g. search index): + for record in affected_entities: + if hasattr(record.entity, "component"): + usage_key = api.library_component_usage_key(library_key, record.entity.component) + # Note that this item may be newly created, updated, or even deleted - but all we care about for this event + # is that the published version is now different. Only for draft changes do we send differentiated events. + + # .. event_implemented_name: LIBRARY_BLOCK_PUBLISHED + # .. event_type: org.openedx.content_authoring.library_block.published.v1 + LIBRARY_BLOCK_PUBLISHED.send_event( + library_block=LibraryBlockData(library_key=library_key, usage_key=usage_key) + ) + # Publishing a container will auto-publish its children, but publishing a single component or all changes + # in the library will NOT usually include any parent containers. But we do need to notify listeners that the + # parent container(s) have changed, e.g. so the search index can update the "has_unpublished_changes" + for parent_container in api.get_containers_contains_item(usage_key): + affected_containers.add(parent_container.container_key) + # TODO: should this be a CONTAINER_CHILD_PUBLISHED event instead of CONTAINER_PUBLISHED ? + elif hasattr(record.entity, "container"): + container_key = api.library_container_locator(library_key, record.entity.container) + affected_containers.add(container_key) + else: + log.warning( + f"PublishableEntity {record.entity.pk} / {record.entity.key} was modified during publish operation " + "but is of unknown type." + ) + + for container_key in affected_containers: + # .. event_implemented_name: LIBRARY_CONTAINER_PUBLISHED + # .. event_type: org.openedx.content_authoring.content_library.container.published.v1 + LIBRARY_CONTAINER_PUBLISHED.send_event( + library_container=LibraryContainerData(container_key=container_key) + ) + + +def wait_for_post_publish_events(publish_log: PublishLog, library_key: LibraryLocatorV2): + """ + After publishing some changes, trigger the required event handlers (e.g. + update the search index). Try to wait for that to complete before returning, + up to some reasonable timeout, and then finish anything remaining + asynchonrously. + """ + # Update the search index (and anything else) for the affected blocks + result = send_events_after_publish.apply_async(args=(publish_log.pk, str(library_key))) + # Try waiting a bit for those post-publish events to be handled: + try: + result.get(timeout=15) + except TimeoutError: + pass + # This is fine! The search index is still being updated, and/or other + # event handlers are still following up on the results, but the publish + # already *did* succeed, and the events will continue to be processed in + # the background by the celery worker until everything is updated. + + +@shared_task(base=LoggedTask) +@set_code_owner_attribute +def send_events_after_revert(draft_change_log_id: int, library_key_str: str) -> None: + """ + Send events to trigger actions like updating the search index, after we've + reverted some unpublished changes in a library. + + See notes on the analogous function above, send_events_after_publish. + """ + try: + draft_change_log = DraftChangeLog.objects.get(id=draft_change_log_id) + except DraftChangeLog.DoesNotExist: + # When a revert operation is a no-op, Learning Core deletes the empty + # DraftChangeLog, so we'll assume that's what happened here. + log.info(f"Library revert in {library_key_str} did not result in any changes.") + return + + library_key = LibraryLocatorV2.from_string(library_key_str) + affected_entities = draft_change_log.records.select_related( + "entity", "entity__container", "entity__component", + ).all() + + created_container_keys: set[LibraryContainerLocator] = set() + updated_container_keys: set[LibraryContainerLocator] = set() + deleted_container_keys: set[LibraryContainerLocator] = set() + affected_collection_keys: set[LibraryCollectionLocator] = set() + + # Update anything that needs to be updated (e.g. search index): + for record in affected_entities: + # This will be true if the entity was [soft] deleted, but we're now reverting that deletion: + is_undeleted = (record.old_version is None and record.new_version is not None) + # This will be true if the entity was created and we're now deleting it by reverting that creation: + is_deleted = (record.old_version is not None and record.new_version is None) + if hasattr(record.entity, "component"): + usage_key = api.library_component_usage_key(library_key, record.entity.component) + event = LIBRARY_BLOCK_UPDATED + if is_deleted: + event = LIBRARY_BLOCK_DELETED + elif is_undeleted: + event = LIBRARY_BLOCK_CREATED + + # .. event_implemented_name: LIBRARY_BLOCK_UPDATED + # .. event_type: org.openedx.content_authoring.library_block.updated.v1 + + # .. event_implemented_name: LIBRARY_BLOCK_DELETED + # .. event_type: org.openedx.content_authoring.library_block.deleted.v1 + + # .. event_implemented_name: LIBRARY_BLOCK_CREATED + # .. event_type: org.openedx.content_authoring.library_block.created.v1 + event.send_event(library_block=LibraryBlockData(library_key=library_key, usage_key=usage_key)) + # If any containers contain this component, their child list / component count may need to be updated + # e.g. if this was a newly created component in the container and is now deleted, or this was deleted and + # is now restored. + for parent_container in api.get_containers_contains_item(usage_key): + updated_container_keys.add(parent_container.container_key) + + # TODO: do we also need to send CONTENT_OBJECT_ASSOCIATIONS_CHANGED for this component, or is + # LIBRARY_BLOCK_UPDATED sufficient? + elif hasattr(record.entity, "container"): + container_key = api.library_container_locator(library_key, record.entity.container) + if is_deleted: + deleted_container_keys.add(container_key) + elif is_undeleted: + created_container_keys.add(container_key) + else: + updated_container_keys.add(container_key) + else: + log.warning( + f"PublishableEntity {record.entity.pk} / {record.entity.key} was modified during publish operation " + "but is of unknown type." + ) + # If any collections contain this entity, their item count may need to be updated, e.g. if this was a + # newly created component in the collection and is now deleted, or this was deleted and is now re-added. + for parent_collection in authoring_api.get_entity_collections( + record.entity.learning_package_id, record.entity.key, + ): + collection_key = api.library_collection_locator( + library_key=library_key, + collection_key=parent_collection.key, + ) + affected_collection_keys.add(collection_key) + + for container_key in deleted_container_keys: + # .. event_implemented_name: LIBRARY_CONTAINER_DELETED + # .. event_type: org.openedx.content_authoring.content_library.container.deleted.v1 + LIBRARY_CONTAINER_DELETED.send_event( + library_container=LibraryContainerData(container_key=container_key) + ) + # Don't bother sending UPDATED events for these containers that are now deleted + created_container_keys.discard(container_key) + + for container_key in created_container_keys: + # .. event_implemented_name: LIBRARY_CONTAINER_CREATED + # .. event_type: org.openedx.content_authoring.content_library.container.created.v1 + LIBRARY_CONTAINER_CREATED.send_event( + library_container=LibraryContainerData(container_key=container_key) + ) + + for container_key in updated_container_keys: + # .. event_implemented_name: LIBRARY_CONTAINER_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.container.updated.v1 + LIBRARY_CONTAINER_UPDATED.send_event( + library_container=LibraryContainerData(container_key=container_key) + ) + + for collection_key in affected_collection_keys: + # .. event_implemented_name: LIBRARY_COLLECTION_UPDATED + # .. event_type: org.openedx.content_authoring.content_library.collection.updated.v1 + LIBRARY_COLLECTION_UPDATED.send_event( + library_collection=LibraryCollectionData(collection_key=collection_key) + ) + + +def wait_for_post_revert_events(draft_change_log: DraftChangeLog, library_key: LibraryLocatorV2): + """ + After discard all changes in a library, trigger the required event handlers + (e.g. update the search index). Try to wait for that to complete before + returning, up to some reasonable timeout, and then finish anything remaining + asynchonrously. + """ + # Update the search index (and anything else) for the affected blocks + result = send_events_after_revert.apply_async(args=(draft_change_log.pk, str(library_key))) + # Try waiting a bit for those post-publish events to be handled: + try: + result.get(timeout=15) + except TimeoutError: + pass + # This is fine! The search index is still being updated, and/or other + # event handlers are still following up on the results, but the revert + # already *did* succeed, and the events will continue to be processed in + # the background by the celery worker until everything is updated. + + @shared_task(base=LoggedTask) @set_code_owner_attribute def import_blocks_from_course(import_task_id, course_key_str, use_course_key_as_block_id_suffix=True): @@ -57,9 +289,9 @@ def import_blocks_from_course(import_task_id, course_key_str, use_course_key_as_ def on_progress(block_key, block_num, block_count, exception=None): if exception: - logger.exception('Import block failed: %s', block_key) + log.exception('Import block failed: %s', block_key) else: - logger.info('Import block succesful: %s', block_key) + log.info('Import block succesful: %s', block_key) import_task.save_progress(block_num / block_count) edx_client = api.EdxModulestoreImportClient( @@ -121,6 +353,9 @@ def sync_from_library( ) -> None: """ Celery task to update the children of the library_content block at `dest_block_id`. + + FIXME: this is related to legacy modulestore libraries and shouldn't be part of the + openedx.core.djangoapps.content_libraries app, which is the app for v2 libraries. """ set_code_owner_attribute_from_module(__name__) store = modulestore() @@ -143,6 +378,9 @@ def duplicate_children( ) -> None: """ Celery task to duplicate the children from `source_block_id` to `dest_block_id`. + + FIXME: this is related to legacy modulestore libraries and shouldn't be part of the + openedx.core.djangoapps.content_libraries app, which is the app for v2 libraries. """ set_code_owner_attribute_from_module(__name__) store = modulestore() @@ -180,6 +418,9 @@ def _sync_children( Implementation helper for `sync_from_library` and `duplicate_children` Celery tasks. Can update children with a specific library `library_version`, or latest (`library_version=None`). + + FIXME: this is related to legacy modulestore libraries and shouldn't be part of the + openedx.core.djangoapps.content_libraries app, which is the app for v2 libraries. """ source_blocks = [] library_key = dest_block.source_library_key.for_branch( @@ -220,6 +461,9 @@ def _copy_overrides( ) -> None: """ Copy any overrides the user has made on children of `source` over to the children of `dest_block`, recursively. + + FIXME: this is related to legacy modulestore libraries and shouldn't be part of the + openedx.core.djangoapps.content_libraries app, which is the app for v2 libraries. """ for field in source_block.fields.values(): if field.scope == Scope.settings and field.is_set_on(source_block): diff --git a/openedx/core/djangoapps/content_libraries/tests/__init__.py b/openedx/core/djangoapps/content_libraries/tests/__init__.py index e69de29bb2..18083ab3e0 100644 --- a/openedx/core/djangoapps/content_libraries/tests/__init__.py +++ b/openedx/core/djangoapps/content_libraries/tests/__init__.py @@ -0,0 +1,4 @@ +""" +Python API for testing content libraries +""" +from .base import * diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py index 638c053f62..0036c208b0 100644 --- a/openedx/core/djangoapps/content_libraries/tests/base.py +++ b/openedx/core/djangoapps/content_libraries/tests/base.py @@ -2,13 +2,17 @@ Tests for Learning-Core-based Content Libraries """ from contextlib import contextmanager +import json from io import BytesIO from urllib.parse import urlencode from organizations.models import Organization from rest_framework.test import APITransactionTestCase, APIClient +from opaque_keys.edx.keys import ContainerKey, UsageKey +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryCollectionLocator from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.util.json_request import JsonResponse as SpecialJsonResponse from openedx.core.djangoapps.content_libraries.constants import ALL_RIGHTS_RESERVED from openedx.core.djangolib.testing.utils import skip_unless_cms @@ -22,6 +26,8 @@ URL_LIB_BLOCK_TYPES = URL_LIB_DETAIL + 'block_types/' # Get the list of XBlock URL_LIB_LINKS = URL_LIB_DETAIL + 'links/' # Get the list of links in this library, or add a new one URL_LIB_COMMIT = URL_LIB_DETAIL + 'commit/' # Commit (POST) or revert (DELETE) all pending changes to this library URL_LIB_BLOCKS = URL_LIB_DETAIL + 'blocks/' # Get the list of XBlocks in this library, or add a new one +URL_LIB_CONTAINERS = URL_LIB_DETAIL + 'containers/' # Create a new container in this library +URL_LIB_COLLECTIONS = URL_LIB_DETAIL + 'collections/' # Create a new collection in this library URL_LIB_TEAM = URL_LIB_DETAIL + 'team/' # Get the list of users/groups authorized to use this library URL_LIB_TEAM_USER = URL_LIB_TEAM + 'user/{username}/' # Add/edit/remove a user's permission to use this library URL_LIB_TEAM_GROUP = URL_LIB_TEAM + 'group/{group_name}/' # Add/edit/remove a group's permission to use this library @@ -31,6 +37,13 @@ URL_LIB_BLOCK_PUBLISH = URL_LIB_BLOCK + 'publish/' # Publish changes from a spe URL_LIB_BLOCK_OLX = URL_LIB_BLOCK + 'olx/' # Get or set the OLX of the specified XBlock URL_LIB_BLOCK_ASSETS = URL_LIB_BLOCK + 'assets/' # List the static asset files of the specified XBlock URL_LIB_BLOCK_ASSET_FILE = URL_LIB_BLOCK + 'assets/{file_name}' # Get, delete, or upload a specific static asset file +URL_LIB_CONTAINER = URL_PREFIX + 'containers/{container_key}/' # Get a container in this library +URL_LIB_CONTAINER_CHILDREN = URL_LIB_CONTAINER + 'children/' # Get, add or delete a component in this container +URL_LIB_CONTAINER_RESTORE = URL_LIB_CONTAINER + 'restore/' # Restore a deleted container +URL_LIB_CONTAINER_COLLECTIONS = URL_LIB_CONTAINER + 'collections/' # Handle associated collections +URL_LIB_CONTAINER_PUBLISH = URL_LIB_CONTAINER + 'publish/' # Publish changes to the specified container + children +URL_LIB_COLLECTION = URL_LIB_COLLECTIONS + '{collection_key}/' # Get a collection in this library +URL_LIB_COLLECTION_ITEMS = URL_LIB_COLLECTION + 'items/' # Get a collection in this library URL_LIB_LTI_PREFIX = URL_PREFIX + 'lti/1.3/' URL_LIB_LTI_JWKS = URL_LIB_LTI_PREFIX + 'pub/jwks/' @@ -62,16 +75,11 @@ class ContentLibrariesRestApiTest(APITransactionTestCase): entire response has some specific shape. That way, things like adding new fields to an API response, which are backwards compatible, won't break any tests, but backwards-incompatible API changes will. - - WARNING: every test should have a unique library slug, because even though - the django/mysql database gets reset for each test case, the lookup between - library slug and bundle UUID does not because it's assumed to be immutable - and cached forever. """ def setUp(self): super().setUp() - self.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx") + self.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx", is_staff=True) # Create an organization self.organization, _ = Organization.objects.get_or_create( short_name="CL-TEST", @@ -107,6 +115,8 @@ class ContentLibrariesRestApiTest(APITransactionTestCase): response = getattr(self.client, method)(url, data, format="json") assert response.status_code == expect_response,\ 'Unexpected response code {}:\n{}'.format(response.status_code, getattr(response, 'data', '(no data)')) + if isinstance(response, SpecialJsonResponse): # Required for some old APIs in the CMS that aren't using DRF + return json.loads(response.content) return response.data @contextmanager @@ -227,9 +237,21 @@ class ContentLibrariesRestApiTest(APITransactionTestCase): expect_response ) - def _add_block_to_library(self, lib_key, block_type, slug, parent_block=None, expect_response=200): + def _add_block_to_library( + self, + lib_key, + block_type, + slug, + parent_block=None, + can_stand_alone=True, + expect_response=200, + ): """ Add a new XBlock to the library """ - data = {"block_type": block_type, "definition_id": slug} + data = { + "block_type": block_type, + "definition_id": slug, + "can_stand_alone": can_stand_alone, + } if parent_block: data["parent_block"] = parent_block return self._api('post', URL_LIB_BLOCKS.format(lib_key=lib_key), data, expect_response) @@ -289,11 +311,10 @@ class ContentLibrariesRestApiTest(APITransactionTestCase): """ Publish changes from a specified XBlock """ return self._api('post', URL_LIB_BLOCK_PUBLISH.format(block_key=block_key), None, expect_response) - def _paste_clipboard_content_in_library(self, lib_key, block_id, expect_response=200): + def _paste_clipboard_content_in_library(self, lib_key, expect_response=200): """ Paste's the users clipboard content into Library """ url = URL_LIB_PASTE_CLIPBOARD.format(lib_key=lib_key) - data = {"block_id": block_id} - return self._api('post', url, data, expect_response) + return self._api('post', url, {}, expect_response) def _render_block_view(self, block_key, view_name, version=None, expect_response=200): """ @@ -350,3 +371,142 @@ class ContentLibrariesRestApiTest(APITransactionTestCase): def _set_library_block_fields(self, block_key, new_fields, expect_response=200): """ Set the fields of a specific block in the library. This API is only used by the MFE editors. """ return self._api('post', URL_BLOCK_FIELDS_URL.format(block_key=block_key), new_fields, expect_response) + + def _create_container(self, lib_key, container_type, slug: str | None, display_name: str, expect_response=200): + """ Create a container (unit etc.) """ + data = {"container_type": container_type, "display_name": display_name} + if slug: + data["slug"] = slug + return self._api('post', URL_LIB_CONTAINERS.format(lib_key=lib_key), data, expect_response) + + def _get_container(self, container_key: ContainerKey | str, expect_response=200): + """ Get a container (unit etc.) """ + return self._api('get', URL_LIB_CONTAINER.format(container_key=container_key), None, expect_response) + + def _update_container(self, container_key: ContainerKey | str, display_name: str, expect_response=200): + """ Update a container (unit etc.) """ + data = {"display_name": display_name} + return self._api('patch', URL_LIB_CONTAINER.format(container_key=container_key), data, expect_response) + + def _delete_container(self, container_key: ContainerKey | str, expect_response=204): + """ Delete a container (unit etc.) """ + return self._api('delete', URL_LIB_CONTAINER.format(container_key=container_key), None, expect_response) + + def _restore_container(self, container_key: ContainerKey | str, expect_response=204): + """ Restore a deleted a container (unit etc.) """ + return self._api('post', URL_LIB_CONTAINER_RESTORE.format(container_key=container_key), None, expect_response) + + def _get_container_children(self, container_key: ContainerKey | str, expect_response=200): + """ Get container children""" + return self._api( + 'get', + URL_LIB_CONTAINER_CHILDREN.format(container_key=container_key), + None, + expect_response + ) + + def _add_container_children( + self, + container_key: ContainerKey | str, + children_ids: list[str], + expect_response=200, + ): + """ Add container children""" + return self._api( + 'post', + URL_LIB_CONTAINER_CHILDREN.format(container_key=container_key), + {'usage_keys': children_ids}, + expect_response + ) + + def _remove_container_components( + self, + container_key: ContainerKey | str, + children_ids: list[str], + expect_response=200, + ): + """ Remove container components""" + return self._api( + 'delete', + URL_LIB_CONTAINER_CHILDREN.format(container_key=container_key), + {'usage_keys': children_ids}, + expect_response + ) + + def _patch_container_components( + self, + container_key: ContainerKey | str, + children_ids: list[str], + expect_response=200, + ): + """ Update container components""" + return self._api( + 'patch', + URL_LIB_CONTAINER_CHILDREN.format(container_key=container_key), + {'usage_keys': children_ids}, + expect_response + ) + + def _patch_container_collections( + self, + container_key: ContainerKey | str, + collection_keys: list[str], + expect_response=200, + ): + """ Update container collections""" + return self._api( + 'patch', + URL_LIB_CONTAINER_COLLECTIONS.format(container_key=container_key), + {'collection_keys': collection_keys}, + expect_response + ) + + def _publish_container(self, container_key: ContainerKey | str, expect_response=200): + """ Publish all changes in the specified container + children """ + return self._api('post', URL_LIB_CONTAINER_PUBLISH.format(container_key=container_key), None, expect_response) + + def _create_collection( + self, + lib_key: LibraryLocatorV2 | str, + title: str, + description: str = "", + expect_response=200, + ): + """ Create a new collection in this library """ + data = {"title": title, "description": description} + return self._api('post', URL_LIB_COLLECTIONS.format(lib_key=lib_key), data, expect_response) + + def _soft_delete_collection(self, collection_key: LibraryCollectionLocator, expect_response=204): + """ Soft delete (disable) a collection """ + url = URL_LIB_COLLECTION.format(lib_key=collection_key.lib_key, collection_key=collection_key.collection_id) + return self._api('delete', url, {}, expect_response) + + def _update_collection( + self, + collection_key: LibraryCollectionLocator, + title: str | None = None, + description: str | None = None, + expect_response=200, + ): + """ Update a collection's title/description """ + data = {} + if title is not None: + data["title"] = title + if description is not None: + data["description"] = description + url = URL_LIB_COLLECTION.format(lib_key=collection_key.lib_key, collection_key=collection_key.collection_id) + return self._api('patch', url, data, expect_response) + + def _add_items_to_collection( + self, + collection_key: LibraryCollectionLocator, + item_keys: list[str | UsageKey | ContainerKey], + expect_response=200, + ): + """ Add components/containers to a collection """ + data = {"usage_keys": [str(k) for k in item_keys]} + url = URL_LIB_COLLECTION_ITEMS.format( + lib_key=collection_key.lib_key, + collection_key=collection_key.collection_id, + ) + return self._api('patch', url, data, expect_response) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py index c526e7b1a1..6756e4373a 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_api.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py @@ -11,19 +11,21 @@ from django.test import TestCase from opaque_keys.edx.keys import ( CourseKey, UsageKey, + UsageKeyV2, ) -from opaque_keys.edx.locator import LibraryLocatorV2 +from opaque_keys.edx.locator import LibraryContainerLocator, LibraryLocatorV2, LibraryUsageLocatorV2 from openedx_events.content_authoring.data import ( ContentObjectChangedData, LibraryCollectionData, + LibraryContainerData, ) from openedx_events.content_authoring.signals import ( CONTENT_OBJECT_ASSOCIATIONS_CHANGED, LIBRARY_COLLECTION_CREATED, LIBRARY_COLLECTION_DELETED, LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_UPDATED, ) -from openedx_events.tests.utils import OpenEdxEventsTestMixin from openedx_learning.api import authoring as authoring_api from .. import api @@ -65,11 +67,11 @@ class EdxModulestoreImportClientTest(TestCase): with self.assertRaises(ValueError): self.client.import_blocks_from_course('foobar', lambda *_: None) - @mock.patch('openedx.core.djangoapps.content_libraries.api.create_library_block') - @mock.patch('openedx.core.djangoapps.content_libraries.api.get_library_block') - @mock.patch('openedx.core.djangoapps.content_libraries.api.get_library_block_static_asset_files') - @mock.patch('openedx.core.djangoapps.content_libraries.api.publish_changes') - @mock.patch('openedx.core.djangoapps.content_libraries.api.set_library_block_olx') + @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.create_library_block') + @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.get_library_block') + @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.get_library_block_static_asset_files') + @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.publish_changes') + @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.set_library_block_olx') def test_import_blocks_from_course_on_block_with_olx( self, mock_set_library_block_olx, @@ -101,9 +103,9 @@ class EdxModulestoreImportClientTest(TestCase): mock.ANY, 'fake-olx') mock_publish_changes.assert_called_once() - @mock.patch('openedx.core.djangoapps.content_libraries.api.create_library_block') - @mock.patch('openedx.core.djangoapps.content_libraries.api.get_library_block_static_asset_files') - @mock.patch('openedx.core.djangoapps.content_libraries.api.set_library_block_olx') + @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.create_library_block') + @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.get_library_block_static_asset_files') + @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.set_library_block_olx') def test_import_block_when_called_twice_same_block_but_different_course( self, mock_set_library_block_olx, @@ -138,7 +140,7 @@ class EdxModulestoreImportClientTest(TestCase): mock_set_library_block_olx.assert_called_once() -@mock.patch('openedx.core.djangoapps.content_libraries.api.OAuthAPIClient') +@mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.OAuthAPIClient') class EdxApiImportClientTest(TestCase): """ Tests for EdxApiImportClient. @@ -195,11 +197,11 @@ class EdxApiImportClientTest(TestCase): return mock_response, mock_content return mock_response - @mock.patch('openedx.core.djangoapps.content_libraries.api.add_library_block_static_asset_file') - @mock.patch('openedx.core.djangoapps.content_libraries.api.create_library_block') - @mock.patch('openedx.core.djangoapps.content_libraries.api.get_library_block_static_asset_files') - @mock.patch('openedx.core.djangoapps.content_libraries.api.publish_changes') - @mock.patch('openedx.core.djangoapps.content_libraries.api.set_library_block_olx') + @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.add_library_block_static_asset_file') + @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.create_library_block') + @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.get_library_block_static_asset_files') + @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.publish_changes') + @mock.patch('openedx.core.djangoapps.content_libraries.api.courseware_import.set_library_block_olx') def test_import_block_when_url_is_from_studio( self, mock_set_library_block_olx, @@ -257,32 +259,14 @@ class EdxApiImportClientTest(TestCase): mock_publish_changes.assert_not_called() -class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin): +class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest): """ Tests for Content Library API collections methods. Same guidelines as ContentLibrariesTestCase. """ - ENABLED_OPENEDX_EVENTS = [ - CONTENT_OBJECT_ASSOCIATIONS_CHANGED.event_type, - LIBRARY_COLLECTION_CREATED.event_type, - LIBRARY_COLLECTION_DELETED.event_type, - LIBRARY_COLLECTION_UPDATED.event_type, - ] - @classmethod - def setUpClass(cls): - """ - Set up class method for the Test class. - - TODO: It's unclear why we need to call start_events_isolation ourselves rather than relying on - OpenEdxEventsTestMixin.setUpClass to handle it. It fails it we don't, and many other test cases do it, - so we're following a pattern here. But that pattern doesn't really make sense. - """ - super().setUpClass() - cls.start_events_isolation() - - def setUp(self): + def setUp(self) -> None: super().setUp() # Create Content Libraries @@ -323,12 +307,26 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe self.lib1_html_block = self._add_block_to_library( self.lib1.library_key, "html", "html1", ) + # Create a container in lib1 + self.unit1 = self._create_container( + str(self.lib1.library_key), + "unit", 'unit-1', 'Unit 1' + ) + + # Create a subsection container + self.subsection1 = api.create_container( + self.lib1.library_key, + api.ContainerType.Subsection, + 'subsection-1', + 'Subsection 1', + None, + ) # Create some library blocks in lib2 self.lib2_problem_block = self._add_block_to_library( self.lib2.library_key, "problem", "problem2", ) - def test_create_library_collection(self): + def test_create_library_collection(self) -> None: event_receiver = mock.Mock() LIBRARY_COLLECTION_CREATED.connect(event_receiver) @@ -345,19 +343,21 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe assert collection.created_by == self.user assert event_receiver.call_count == 1 - self.assertDictContainsSubset( + self.assertDictContainsEntries( + event_receiver.call_args_list[0].kwargs, { "signal": LIBRARY_COLLECTION_CREATED, "sender": None, "library_collection": LibraryCollectionData( - self.lib2.library_key, - collection_key="COL4", + collection_key=api.library_collection_locator( + self.lib2.library_key, + collection_key="COL4", + ), ), }, - event_receiver.call_args_list[0].kwargs, ) - def test_create_library_collection_invalid_library(self): + def test_create_library_collection_invalid_library(self) -> None: library_key = LibraryLocatorV2.from_string("lib:INVALID:test-lib-does-not-exist") with self.assertRaises(api.ContentLibraryNotFound) as exc: api.create_library_collection( @@ -366,7 +366,7 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe title="Collection 3", ) - def test_update_library_collection(self): + def test_update_library_collection(self) -> None: event_receiver = mock.Mock() LIBRARY_COLLECTION_UPDATED.connect(event_receiver) @@ -381,29 +381,32 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe assert self.col1.created_by == self.user assert event_receiver.call_count == 1 - self.assertDictContainsSubset( + self.assertDictContainsEntries( + event_receiver.call_args_list[0].kwargs, { "signal": LIBRARY_COLLECTION_UPDATED, "sender": None, "library_collection": LibraryCollectionData( - self.lib1.library_key, - collection_key="COL1", + collection_key=api.library_collection_locator( + self.lib1.library_key, + collection_key="COL1", + ), ), }, - event_receiver.call_args_list[0].kwargs, ) - def test_update_library_collection_wrong_library(self): + def test_update_library_collection_wrong_library(self) -> None: with self.assertRaises(api.ContentLibraryCollectionNotFound) as exc: api.update_library_collection( self.lib1.library_key, self.col2.key, ) - def test_delete_library_collection(self): + def test_delete_library_collection(self) -> None: event_receiver = mock.Mock() LIBRARY_COLLECTION_DELETED.connect(event_receiver) + assert self.lib1.learning_package_id is not None authoring_api.delete_collection( self.lib1.learning_package_id, self.col1.key, @@ -411,42 +414,45 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe ) assert event_receiver.call_count == 1 - self.assertDictContainsSubset( + self.assertDictContainsEntries( + event_receiver.call_args_list[0].kwargs, { "signal": LIBRARY_COLLECTION_DELETED, "sender": None, "library_collection": LibraryCollectionData( - self.lib1.library_key, - collection_key="COL1", + collection_key=api.library_collection_locator( + self.lib1.library_key, + collection_key="COL1", + ), ), }, - event_receiver.call_args_list[0].kwargs, ) - def test_update_library_collection_components(self): + def test_update_library_collection_items(self) -> None: assert not list(self.col1.entities.all()) - self.col1 = api.update_library_collection_components( + self.col1 = api.update_library_collection_items( self.lib1.library_key, self.col1.key, - usage_keys=[ - UsageKey.from_string(self.lib1_problem_block["id"]), - UsageKey.from_string(self.lib1_html_block["id"]), + opaque_keys=[ + LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]), + LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]), + LibraryContainerLocator.from_string(self.unit1["id"]), ], ) - assert len(self.col1.entities.all()) == 2 + assert len(self.col1.entities.all()) == 3 - self.col1 = api.update_library_collection_components( + self.col1 = api.update_library_collection_items( self.lib1.library_key, self.col1.key, - usage_keys=[ - UsageKey.from_string(self.lib1_html_block["id"]), + opaque_keys=[ + LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]), ], remove=True, ) - assert len(self.col1.entities.all()) == 1 + assert len(self.col1.entities.all()) == 2 - def test_update_library_collection_components_event(self): + def test_update_library_collection_components_event(self) -> None: """ Check that a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event is raised for each added/removed component. """ @@ -454,17 +460,19 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_receiver) LIBRARY_COLLECTION_UPDATED.connect(event_receiver) - api.update_library_collection_components( + api.update_library_collection_items( self.lib1.library_key, self.col1.key, - usage_keys=[ - UsageKey.from_string(self.lib1_problem_block["id"]), - UsageKey.from_string(self.lib1_html_block["id"]), + opaque_keys=[ + LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]), + LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]), + LibraryContainerLocator.from_string(self.unit1["id"]), ], ) - assert event_receiver.call_count == 3 - self.assertDictContainsSubset( + assert event_receiver.call_count == 4 + self.assertDictContainsEntries( + event_receiver.call_args_list[0].kwargs, { "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, "sender": None, @@ -473,9 +481,9 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe changes=["collections"], ), }, - event_receiver.call_args_list[0].kwargs, ) - self.assertDictContainsSubset( + self.assertDictContainsEntries( + event_receiver.call_args_list[1].kwargs, { "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, "sender": None, @@ -484,49 +492,64 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe changes=["collections"], ), }, - event_receiver.call_args_list[1].kwargs, ) - self.assertDictContainsSubset( + self.assertDictContainsEntries( + event_receiver.call_args_list[2].kwargs, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=self.unit1["id"], + changes=["collections"], + ), + }, + ) + self.assertDictContainsEntries( + event_receiver.call_args_list[3].kwargs, { "signal": LIBRARY_COLLECTION_UPDATED, "sender": None, "library_collection": LibraryCollectionData( - self.lib1.library_key, - collection_key="COL1", + collection_key=api.library_collection_locator( + self.lib1.library_key, + collection_key="COL1", + ), ), }, - event_receiver.call_args_list[2].kwargs, ) - def test_update_collection_components_from_wrong_library(self): + def test_update_collection_components_from_wrong_library(self) -> None: with self.assertRaises(api.ContentLibraryBlockNotFound) as exc: - api.update_library_collection_components( + api.update_library_collection_items( self.lib2.library_key, self.col2.key, - usage_keys=[ - UsageKey.from_string(self.lib1_problem_block["id"]), - UsageKey.from_string(self.lib1_html_block["id"]), + opaque_keys=[ + LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]), + LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]), + LibraryContainerLocator.from_string(self.unit1["id"]), ], ) assert self.lib1_problem_block["id"] in str(exc.exception) - def test_set_library_component_collections(self): + def test_set_library_component_collections(self) -> None: event_receiver = mock.Mock() CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_receiver) collection_update_event_receiver = mock.Mock() LIBRARY_COLLECTION_UPDATED.connect(collection_update_event_receiver) assert not list(self.col2.entities.all()) - component = api.get_component_from_usage_key(UsageKey.from_string(self.lib2_problem_block["id"])) - - api.set_library_component_collections( - self.lib2.library_key, - component, + component = api.get_component_from_usage_key(UsageKeyV2.from_string(self.lib2_problem_block["id"])) + api.set_library_item_collections( + library_key=self.lib2.library_key, + entity_key=component.publishable_entity.key, collection_keys=[self.col2.key, self.col3.key], ) + assert self.lib2.learning_package_id is not None assert len(authoring_api.get_collection(self.lib2.learning_package_id, self.col2.key).entities.all()) == 1 assert len(authoring_api.get_collection(self.lib2.learning_package_id, self.col3.key).entities.all()) == 1 - self.assertDictContainsSubset( + + self.assertDictContainsEntries( + event_receiver.call_args_list[0].kwargs, { "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, "sender": None, @@ -535,29 +558,754 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe changes=["collections"], ), }, + ) + + assert len(collection_update_event_receiver.call_args_list) == 2 + collection_update_events = [call.kwargs for call in collection_update_event_receiver.call_args_list] + assert all(event["signal"] == LIBRARY_COLLECTION_UPDATED for event in collection_update_events) + assert {event["library_collection"] for event in collection_update_events} == { + LibraryCollectionData( + collection_key=api.library_collection_locator(self.lib2.library_key, collection_key=self.col2.key), + background=True, + ), + LibraryCollectionData( + collection_key=api.library_collection_locator(self.lib2.library_key, collection_key=self.col3.key), + background=True, + ) + } + + def test_delete_library_block(self) -> None: + api.update_library_collection_items( + self.lib1.library_key, + self.col1.key, + opaque_keys=[ + LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]), + LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]), + ], + ) + + event_receiver = mock.Mock() + LIBRARY_COLLECTION_UPDATED.connect(event_receiver) + + api.delete_library_block(LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"])) + + assert event_receiver.call_count == 1 + self.assertDictContainsEntries( event_receiver.call_args_list[0].kwargs, - ) - self.assertDictContainsSubset( { "signal": LIBRARY_COLLECTION_UPDATED, "sender": None, "library_collection": LibraryCollectionData( - self.lib2.library_key, - collection_key=self.col2.key, + collection_key=api.library_collection_locator( + self.lib1.library_key, + collection_key=self.col1.key, + ), background=True, ), }, + ) + + def test_delete_library_container(self) -> None: + api.update_library_collection_items( + self.lib1.library_key, + self.col1.key, + opaque_keys=[ + LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]), + LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]), + LibraryContainerLocator.from_string(self.unit1["id"]), + ], + ) + + # Add container under another container + api.update_container_children( + self.subsection1.container_key, + [LibraryContainerLocator.from_string(self.unit1["id"])], + None, + ) + event_receiver = mock.Mock() + LIBRARY_COLLECTION_UPDATED.connect(event_receiver) + LIBRARY_CONTAINER_UPDATED.connect(event_receiver) + + api.delete_container(LibraryContainerLocator.from_string(self.unit1["id"])) + + assert event_receiver.call_count == 2 + self.assertDictContainsEntries( + event_receiver.call_args_list[0].kwargs, + { + "signal": LIBRARY_COLLECTION_UPDATED, + "sender": None, + "library_collection": LibraryCollectionData( + collection_key=api.library_collection_locator( + self.lib1.library_key, + collection_key=self.col1.key, + ), + background=True, + ), + }, + ) + self.assertDictContainsEntries( + event_receiver.call_args_list[1].kwargs, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + container_key=self.subsection1.container_key, + background=False, + ) + }, + ) + + def test_restore_library_block(self) -> None: + api.update_library_collection_items( + self.lib1.library_key, + self.col1.key, + opaque_keys=[ + LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]), + LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]), + ], + ) + + event_receiver = mock.Mock() + LIBRARY_COLLECTION_UPDATED.connect(event_receiver) + + api.restore_library_block(LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"])) + + assert event_receiver.call_count == 1 + self.assertDictContainsEntries( + event_receiver.call_args_list[0].kwargs, + { + "signal": LIBRARY_COLLECTION_UPDATED, + "sender": None, + "library_collection": LibraryCollectionData( + collection_key=api.library_collection_locator( + self.lib1.library_key, + collection_key=self.col1.key, + ), + background=True, + ), + }, + ) + + def test_add_component_and_revert(self) -> None: + # Publish changes + api.publish_changes(self.lib1.library_key) + + # Create a new component that will only exist as a draft + new_problem_block = self._add_block_to_library( + self.lib1.library_key, "problem", "problemNEW", + ) + + # Add component. Note: collections are not part of the draft/publish cycle so this is not a draft change. + api.update_library_collection_items( + self.lib1.library_key, + self.col1.key, + opaque_keys=[ + LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]), + LibraryUsageLocatorV2.from_string(new_problem_block["id"]), + ], + ) + + collection_update_event_receiver = mock.Mock() + LIBRARY_COLLECTION_UPDATED.connect(collection_update_event_receiver) + + api.revert_changes(self.lib1.library_key) + + assert collection_update_event_receiver.call_count == 1 + self.assertDictContainsEntries( collection_update_event_receiver.call_args_list[0].kwargs, - ) - self.assertDictContainsSubset( { "signal": LIBRARY_COLLECTION_UPDATED, "sender": None, "library_collection": LibraryCollectionData( - self.lib2.library_key, - collection_key=self.col3.key, - background=True, + collection_key=api.library_collection_locator( + self.lib1.library_key, + collection_key=self.col1.key, + ), + ), + }, + ) + + def test_delete_component_and_revert(self) -> None: + """ + When a component is deleted and then the delete is reverted, signals + will be emitted to update any containing collections. + """ + # Add components and publish + api.update_library_collection_items( + self.lib1.library_key, + self.col1.key, + opaque_keys=[ + LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"]), + LibraryUsageLocatorV2.from_string(self.lib1_html_block["id"]) + ], + ) + api.publish_changes(self.lib1.library_key) + + # Delete component and revert + api.delete_library_block(LibraryUsageLocatorV2.from_string(self.lib1_problem_block["id"])) + + collection_update_event_receiver = mock.Mock() + LIBRARY_COLLECTION_UPDATED.connect(collection_update_event_receiver) + + api.revert_changes(self.lib1.library_key) + + assert collection_update_event_receiver.call_count == 1 + self.assertDictContainsEntries( + collection_update_event_receiver.call_args_list[0].kwargs, + { + "signal": LIBRARY_COLLECTION_UPDATED, + "sender": None, + "library_collection": LibraryCollectionData( + collection_key=api.library_collection_locator( + self.lib1.library_key, + collection_key=self.col1.key, + ), + ), + }, + ) + + +class ContentLibraryContainersTest(ContentLibrariesRestApiTest): + """ + Tests for Content Library API containers methods. + """ + + def setUp(self) -> None: + super().setUp() + + # Create Content Libraries + self._create_library("test-lib-cont-1", "Test Library 1") + + # Fetch the created ContentLibrare objects so we can access their learning_package.id + self.lib1 = ContentLibrary.objects.get(slug="test-lib-cont-1") + + # Create Units + self.unit1 = api.create_container(self.lib1.library_key, api.ContainerType.Unit, 'unit-1', 'Unit 1', None) + self.unit2 = api.create_container(self.lib1.library_key, api.ContainerType.Unit, 'unit-2', 'Unit 2', None) + self.unit3 = api.create_container(self.lib1.library_key, api.ContainerType.Unit, 'unit-3', 'Unit 3', None) + + # Create Subsections + self.subsection1 = api.create_container( + self.lib1.library_key, + api.ContainerType.Subsection, + 'subsection-1', + 'Subsection 1', + None, + ) + self.subsection2 = api.create_container( + self.lib1.library_key, + api.ContainerType.Subsection, + 'subsection-2', + 'Subsection 2', + None, + ) + + # Create Sections + self.section1 = api.create_container( + self.lib1.library_key, + api.ContainerType.Section, + 'section-1', + 'Section 1', + None, + ) + self.section2 = api.create_container( + self.lib1.library_key, + api.ContainerType.Section, + 'section-2', + 'Section 2', + None, + ) + + # Create XBlocks + # Create some library blocks in lib1 + self.problem_block = self._add_block_to_library( + self.lib1.library_key, "problem", "problem1", + ) + self.problem_block_usage_key = LibraryUsageLocatorV2.from_string(self.problem_block["id"]) + self.problem_block_2 = self._add_block_to_library( + self.lib1.library_key, "problem", "problem2", + ) + self.html_block = self._add_block_to_library( + self.lib1.library_key, "html", "html1", + ) + self.html_block_usage_key = LibraryUsageLocatorV2.from_string(self.html_block["id"]) + + # Add content to units + api.update_container_children( + self.unit1.container_key, + [self.problem_block_usage_key, self.html_block_usage_key], + None, + ) + api.update_container_children( + self.unit2.container_key, + [self.html_block_usage_key], + None, + ) + + # Add units to subsections + api.update_container_children( + self.subsection1.container_key, + [self.unit1.container_key, self.unit2.container_key], + None, + ) + api.update_container_children( + self.subsection2.container_key, + [self.unit1.container_key], + None, + ) + + # Add subsections to sections + api.update_container_children( + self.section1.container_key, + [self.subsection1.container_key, self.subsection2.container_key], + None, + ) + api.update_container_children( + self.section2.container_key, + [self.subsection1.container_key], + None, + ) + + def test_get_containers_contains_item(self): + problem_block_containers = api.get_containers_contains_item(self.problem_block_usage_key) + html_block_containers = api.get_containers_contains_item(self.html_block_usage_key) + unit_1_containers = api.get_containers_contains_item(self.unit1.container_key) + unit_2_containers = api.get_containers_contains_item(self.unit2.container_key) + subsection_1_containers = api.get_containers_contains_item(self.subsection1.container_key) + subsection_2_containers = api.get_containers_contains_item(self.subsection2.container_key) + + assert len(problem_block_containers) == 1 + assert problem_block_containers[0].container_key == self.unit1.container_key + + assert len(html_block_containers) == 2 + assert html_block_containers[0].container_key == self.unit1.container_key + assert html_block_containers[1].container_key == self.unit2.container_key + + assert len(unit_1_containers) == 2 + assert unit_1_containers[0].container_key == self.subsection1.container_key + assert unit_1_containers[1].container_key == self.subsection2.container_key + + assert len(unit_2_containers) == 1 + assert unit_2_containers[0].container_key == self.subsection1.container_key + + assert len(subsection_1_containers) == 2 + assert subsection_1_containers[0].container_key == self.section1.container_key + assert subsection_1_containers[1].container_key == self.section2.container_key + + assert len(subsection_2_containers) == 1 + assert subsection_2_containers[0].container_key == self.section1.container_key + + def _validate_calls_of_html_block(self, event_mock): + """ + Validate that the `event_mock` has been called twice + using the `LIBRARY_CONTAINER_UPDATED` signal. + """ + assert event_mock.call_count == 2 + self.assertDictContainsEntries( + event_mock.call_args_list[0].kwargs, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + container_key=self.unit1.container_key, + background=True, + ) + }, + ) + self.assertDictContainsEntries( + event_mock.call_args_list[1].kwargs, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + container_key=self.unit2.container_key, + background=True, + ) + }, + ) + + def test_call_container_update_signal_when_delete_component(self) -> None: + container_update_event_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) + + api.delete_library_block(self.html_block_usage_key) + self._validate_calls_of_html_block(container_update_event_receiver) + + def test_call_container_update_signal_when_restore_component(self) -> None: + api.delete_library_block(self.html_block_usage_key) + + container_update_event_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) + api.restore_library_block(self.html_block_usage_key) + + self._validate_calls_of_html_block(container_update_event_receiver) + + def test_call_container_update_signal_when_update_olx(self) -> None: + block_olx = "Hello world!" + container_update_event_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) + + self._set_library_block_olx(self.html_block_usage_key, block_olx) + self._validate_calls_of_html_block(container_update_event_receiver) + + def test_call_container_update_signal_when_update_component(self) -> None: + block_olx = "Hello world!" + container_update_event_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) + + self._set_library_block_fields(self.html_block_usage_key, {"data": block_olx, "metadata": {}}) + self._validate_calls_of_html_block(container_update_event_receiver) + + def test_call_container_update_signal_when_update_unit(self) -> None: + container_update_event_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) + self._update_container(self.unit1.container_key, 'New Unit Display Name') + + assert container_update_event_receiver.call_count == 3 + self.assertDictContainsEntries( + container_update_event_receiver.call_args_list[0].kwargs, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + container_key=self.unit1.container_key, + ) + }, + ) + self.assertDictContainsEntries( + container_update_event_receiver.call_args_list[1].kwargs, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + container_key=self.subsection1.container_key, + ) + }, + ) + self.assertDictContainsEntries( + container_update_event_receiver.call_args_list[2].kwargs, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + container_key=self.subsection2.container_key, + ) + }, + ) + + def test_call_container_update_signal_when_update_subsection(self) -> None: + container_update_event_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) + self._update_container(self.subsection1.container_key, 'New Subsection Display Name') + + assert container_update_event_receiver.call_count == 3 + self.assertDictContainsEntries( + container_update_event_receiver.call_args_list[0].kwargs, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + container_key=self.subsection1.container_key, + ) + }, + ) + self.assertDictContainsEntries( + container_update_event_receiver.call_args_list[1].kwargs, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + container_key=self.section1.container_key, + ) + }, + ) + self.assertDictContainsEntries( + container_update_event_receiver.call_args_list[2].kwargs, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + container_key=self.section2.container_key, + ) + }, + ) + + def test_call_container_update_signal_when_update_section(self) -> None: + container_update_event_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(container_update_event_receiver) + self._update_container(self.section1.container_key, 'New Section Display Name') + + assert container_update_event_receiver.call_count == 1 + self.assertDictContainsEntries( + container_update_event_receiver.call_args_list[0].kwargs, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + container_key=self.section1.container_key, + ) + }, + ) + + def test_call_object_changed_signal_when_remove_component(self) -> None: + html_block_1 = self._add_block_to_library( + self.lib1.library_key, "html", "html3", + ) + api.update_container_children( + self.unit2.container_key, + [LibraryUsageLocatorV2.from_string(html_block_1["id"])], + None, + entities_action=authoring_api.ChildrenEntitiesAction.APPEND, + ) + + event_reciver = mock.Mock() + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_reciver) + api.update_container_children( + self.unit2.container_key, + [LibraryUsageLocatorV2.from_string(html_block_1["id"])], + None, + entities_action=authoring_api.ChildrenEntitiesAction.REMOVE, + ) + + assert event_reciver.call_count == 1 + self.assertDictContainsEntries( + event_reciver.call_args_list[0].kwargs, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=html_block_1["id"], + changes=["units"], + ), + }, + ) + + def test_call_object_changed_signal_when_remove_unit(self) -> None: + unit4 = api.create_container(self.lib1.library_key, api.ContainerType.Unit, 'unit-4', 'Unit 4', None) + + api.update_container_children( + self.subsection2.container_key, + [unit4.container_key], + None, + entities_action=authoring_api.ChildrenEntitiesAction.APPEND, + ) + + event_reciver = mock.Mock() + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_reciver) + api.update_container_children( + self.subsection2.container_key, + [unit4.container_key], + None, + entities_action=authoring_api.ChildrenEntitiesAction.REMOVE, + ) + + assert event_reciver.call_count == 1 + self.assertDictContainsEntries( + event_reciver.call_args_list[0].kwargs, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=str(unit4.container_key), + changes=["subsections"], + ), + }, + ) + + def test_call_object_changed_signal_when_remove_subsection(self) -> None: + subsection3 = api.create_container( + self.lib1.library_key, + api.ContainerType.Subsection, + 'subsection-3', + 'Subsection 3', + None, + ) + + api.update_container_children( + self.section2.container_key, + [subsection3.container_key], + None, + entities_action=authoring_api.ChildrenEntitiesAction.APPEND, + ) + + event_reciver = mock.Mock() + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_reciver) + api.update_container_children( + self.section2.container_key, + [subsection3.container_key], + None, + entities_action=authoring_api.ChildrenEntitiesAction.REMOVE, + ) + + assert event_reciver.call_count == 1 + self.assertDictContainsEntries( + event_reciver.call_args_list[0].kwargs, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=str(subsection3.container_key), + changes=["sections"], + ), + }, + ) + + def test_call_object_changed_signal_when_add_component(self) -> None: + event_reciver = mock.Mock() + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_reciver) + html_block_1 = self._add_block_to_library( + self.lib1.library_key, "html", "html4", + ) + html_block_2 = self._add_block_to_library( + self.lib1.library_key, "html", "html5", + ) + + api.update_container_children( + self.unit2.container_key, + [ + LibraryUsageLocatorV2.from_string(html_block_1["id"]), + LibraryUsageLocatorV2.from_string(html_block_2["id"]) + ], + None, + entities_action=authoring_api.ChildrenEntitiesAction.APPEND, + ) + + assert event_reciver.call_count == 2 + self.assertDictContainsEntries( + event_reciver.call_args_list[0].kwargs, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=html_block_1["id"], + changes=["units"], + ), + }, + ) + self.assertDictContainsEntries( + event_reciver.call_args_list[1].kwargs, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=html_block_2["id"], + changes=["units"], + ), + }, + ) + + def test_call_object_changed_signal_when_add_unit(self) -> None: + event_reciver = mock.Mock() + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_reciver) + + unit4 = api.create_container(self.lib1.library_key, api.ContainerType.Unit, 'unit-4', 'Unit 4', None) + unit5 = api.create_container(self.lib1.library_key, api.ContainerType.Unit, 'unit-5', 'Unit 5', None) + + api.update_container_children( + self.subsection2.container_key, + [unit4.container_key, unit5.container_key], + None, + entities_action=authoring_api.ChildrenEntitiesAction.APPEND, + ) + assert event_reciver.call_count == 2 + self.assertDictContainsEntries( + event_reciver.call_args_list[0].kwargs, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=str(unit4.container_key), + changes=["subsections"], + ), + }, + ) + self.assertDictContainsEntries( + event_reciver.call_args_list[1].kwargs, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=str(unit5.container_key), + changes=["subsections"], + ), + }, + ) + + def test_call_object_changed_signal_when_add_subsection(self) -> None: + event_reciver = mock.Mock() + CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_reciver) + + subsection3 = api.create_container( + self.lib1.library_key, + api.ContainerType.Subsection, + 'subsection-3', + 'Subsection 3', + None, + ) + subsection4 = api.create_container( + self.lib1.library_key, + api.ContainerType.Subsection, + 'subsection-4', + 'Subsection 4', + None, + ) + api.update_container_children( + self.section2.container_key, + [subsection3.container_key, subsection4.container_key], + None, + entities_action=authoring_api.ChildrenEntitiesAction.APPEND, + ) + assert event_reciver.call_count == 2 + self.assertDictContainsEntries( + event_reciver.call_args_list[0].kwargs, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=str(subsection3.container_key), + changes=["sections"], + ), + }, + ) + self.assertDictContainsEntries( + event_reciver.call_args_list[1].kwargs, + { + "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED, + "sender": None, + "content_object": ContentObjectChangedData( + object_id=str(subsection4.container_key), + changes=["sections"], + ), + }, + ) + + def test_delete_component_and_revert(self) -> None: + """ + When a component is deleted and then the delete is reverted, signals + will be emitted to update any containing containers. + """ + # Add components and publish + api.update_container_children(self.unit3.container_key, [ + LibraryUsageLocatorV2.from_string(self.problem_block_2["id"]), + ], user_id=None) + api.publish_changes(self.lib1.library_key) + + # Delete component and revert + api.delete_library_block(LibraryUsageLocatorV2.from_string(self.problem_block_2["id"])) + + container_event_receiver = mock.Mock() + LIBRARY_CONTAINER_UPDATED.connect(container_event_receiver) + + api.revert_changes(self.lib1.library_key) + + assert container_event_receiver.call_count == 1 + self.assertDictContainsEntries( + container_event_receiver.call_args_list[0].kwargs, + { + "signal": LIBRARY_CONTAINER_UPDATED, + "sender": None, + "library_container": LibraryContainerData( + container_key=self.unit3.container_key ), }, - collection_update_event_receiver.call_args_list[1].kwargs, ) diff --git a/openedx/core/djangoapps/content_libraries/tests/test_containers.py b/openedx/core/djangoapps/content_libraries/tests/test_containers.py new file mode 100644 index 0000000000..90b0c71794 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/tests/test_containers.py @@ -0,0 +1,659 @@ +""" +Tests for Learning-Core-based Content Libraries +""" +from datetime import datetime, timezone + +import ddt +from freezegun import freeze_time + +from opaque_keys.edx.locator import LibraryLocatorV2 + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.content_libraries import api +from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest +from openedx.core.djangoapps.content_tagging import api as tagging_api +from openedx.core.djangolib.testing.utils import skip_unless_cms + + +@skip_unless_cms +@ddt.ddt +class ContainersTestCase(ContentLibrariesRestApiTest): + """ + Tests for containers (Sections, Subsections, Units) in Content Libraries. + + These tests use the REST API, which in turn relies on the Python API. + Some tests may use the python API directly if necessary to provide + coverage of any code paths not accessible via the REST API. + + In general, these tests should + (1) Use public APIs only - don't directly create data using other methods, + which results in a less realistic test and ties the test suite too + closely to specific implementation details. + (Exception: users can be provisioned using a user factory) + (2) Assert that fields are present in responses, but don't assert that the + entire response has some specific shape. That way, things like adding + new fields to an API response, which are backwards compatible, won't + break any tests, but backwards-incompatible API changes will. + """ + + def setUp(self) -> None: + super().setUp() + self.create_date = datetime(2024, 9, 8, 7, 6, 5, tzinfo=timezone.utc) + self.lib = self._create_library( + slug="containers", + title="Container Test Library", + description="Units and more", + ) + self.lib_key = LibraryLocatorV2.from_string(self.lib["id"]) + + self.taxonomy = tagging_api.create_taxonomy('New Taxonomy') + tagging_api.set_taxonomy_orgs(self.taxonomy, all_orgs=True) + tagging_api.add_tag_to_taxonomy(self.taxonomy, "one") + tagging_api.add_tag_to_taxonomy(self.taxonomy, "two") + tagging_api.add_tag_to_taxonomy(self.taxonomy, "three") + tagging_api.add_tag_to_taxonomy(self.taxonomy, "four") + + # Create containers + with freeze_time(self.create_date): + # Unit + self.unit = self._create_container(self.lib["id"], "unit", display_name="Alpha Bravo", slug=None) + self.unit_with_components = self._create_container( + self.lib["id"], + "unit", + display_name="Alpha Charly", + slug=None, + ) + self.unit_2 = self._create_container(self.lib["id"], "unit", display_name="Test Unit 2", slug=None) + self.unit_3 = self._create_container(self.lib["id"], "unit", display_name="Test Unit 3", slug=None) + + # Subsection + self.subsection = self._create_container( + self.lib["id"], + "subsection", + display_name="Subsection Alpha", + slug=None, + ) + self.subsection_with_units = self._create_container( + self.lib["id"], + "subsection", + display_name="Subsection with units", + slug=None, + ) + self.subsection_2 = self._create_container( + self.lib["id"], + "subsection", + display_name="Test Subsection 2", + slug=None, + ) + self.subsection_3 = self._create_container( + self.lib["id"], + "subsection", + display_name="Test Subsection 3", + slug=None, + ) + + # Section + self.section = self._create_container(self.lib["id"], "section", display_name="Section Alpha", slug=None) + self.section_with_subsections = self._create_container( + self.lib["id"], + "section", + display_name="Section with subsections", + slug=None, + ) + + # Create blocks + self.problem_block = self._add_block_to_library(self.lib["id"], "problem", "Problem1", can_stand_alone=False) + self.html_block = self._add_block_to_library(self.lib["id"], "html", "Html1", can_stand_alone=False) + self.problem_block_2 = self._add_block_to_library(self.lib["id"], "problem", "Problem2", can_stand_alone=False) + self.html_block_2 = self._add_block_to_library(self.lib["id"], "html", "Html2") + + # Add components to `unit_with_components` + self._add_container_children( + self.unit_with_components["id"], + children_ids=[ + self.problem_block["id"], + self.html_block["id"], + self.problem_block_2["id"], + self.html_block_2["id"], + ], + ) + # Add units to `subsection_with_units` + self._add_container_children( + self.subsection_with_units["id"], + children_ids=[ + self.unit["id"], + self.unit_with_components["id"], + self.unit_2["id"], + self.unit_3["id"], + ], + ) + # Add subsections to `section_with_subsections` + self._add_container_children( + self.section_with_subsections["id"], + children_ids=[ + self.subsection["id"], + self.subsection_with_units["id"], + self.subsection_2["id"], + self.subsection_3["id"], + ], + ) + + @ddt.data( + ("unit", "u1", "Test Unit"), + ("subsection", "subs1", "Test Subsection"), + ("section", "s1", "Test Section"), + ) + @ddt.unpack + def test_container_crud(self, container_type, slug, display_name) -> None: + """ + Test Create, Read, Update, and Delete of a Containers + """ + # Create container: + create_date = datetime(2024, 9, 8, 7, 6, 5, tzinfo=timezone.utc) + with freeze_time(create_date): + container_data = self._create_container( + self.lib["id"], + container_type, + slug=slug, + display_name=display_name + ) + container_id = f"lct:CL-TEST:containers:{container_type}:{slug}" + expected_data = { + "id": container_id, + "container_type": container_type, + "display_name": display_name, + "last_published": None, + "published_by": "", + "last_draft_created": "2024-09-08T07:06:05Z", + "last_draft_created_by": 'Bob', + 'has_unpublished_changes': True, + 'created': '2024-09-08T07:06:05Z', + 'modified': '2024-09-08T07:06:05Z', + 'collections': [], + } + + self.assertDictContainsEntries(container_data, expected_data) + + # Fetch the container: + container_as_read = self._get_container(container_data["id"]) + # make sure it contains the same data when we read it back: + self.assertDictContainsEntries(container_as_read, expected_data) + + # Update the container: + modified_date = datetime(2024, 10, 9, 8, 7, 6, tzinfo=timezone.utc) + with freeze_time(modified_date): + container_data = self._update_container(container_id, display_name=f"New Display Name for {container_type}") + expected_data["last_draft_created"] = expected_data["modified"] = "2024-10-09T08:07:06Z" + expected_data["display_name"] = f"New Display Name for {container_type}" + self.assertDictContainsEntries(container_data, expected_data) + + # Re-fetch the container + container_as_re_read = self._get_container(container_data["id"]) + # make sure it contains the same data when we read it back: + self.assertDictContainsEntries(container_as_re_read, expected_data) + + # Delete the container + self._delete_container(container_data["id"]) + self._get_container(container_data["id"], expect_response=404) + + @ddt.data( + ("unit", "u2", "Test Unit"), + ("subsection", "subs2", "Test Subsection"), + ("section", "s2", "Test Section"), + ) + @ddt.unpack + def test_container_permissions(self, container_type, slug, display_name) -> None: + """ + Test that a regular user with read-only permissions on the library cannot create, update, or delete containers. + """ + container_data = self._create_container(self.lib["id"], container_type, slug=slug, display_name=display_name) + + random_user = UserFactory.create(username="Random", email="random@example.com") + with self.as_user(random_user): + self._create_container( + self.lib["id"], + container_type, + slug="new_slug", + display_name=display_name, + expect_response=403, + ) + self._get_container(container_data["id"], expect_response=403) + self._update_container(container_data["id"], display_name="New Display Name", expect_response=403) + self._delete_container(container_data["id"], expect_response=403) + + # Granting read-only permissions on the library should only allow retrieval, nothing else. + self._add_user_by_email(self.lib["id"], random_user.email, access_level="read") + with self.as_user(random_user): + self._create_container( + self.lib["id"], + container_type, + slug=slug, + display_name=display_name, + expect_response=403, + ) + self._get_container(container_data["id"], expect_response=200) + self._update_container(container_data["id"], display_name="New Display Name", expect_response=403) + self._delete_container(container_data["id"], expect_response=403) + + @ddt.data( + ("unit", "Alpha Bravo", "lct:CL-TEST:containers:unit:alpha-bravo-"), + ("subsection", "Subsection Alpha", "lct:CL-TEST:containers:subsection:subsection-alpha-"), + ("section", "Section Alpha", "lct:CL-TEST:containers:section:section-alpha-"), + ) + @ddt.unpack + def test_containers_gets_auto_slugs(self, container_type, display_name, expected_id) -> None: + """ + Test that we can create containers by specifying only a title, and they get + unique slugs assigned automatically. + """ + container_1 = getattr(self, container_type) + container_2 = self._create_container(self.lib["id"], container_type, display_name=display_name, slug=None) + + assert container_1["id"].startswith(expected_id) + assert container_2["id"].startswith(expected_id) + assert container_1["id"] != container_2["id"] + + def test_unit_add_children(self) -> None: + """ + Test that we can add and get unit children components + """ + # Add some components + self._add_container_children( + self.unit["id"], + children_ids=[self.problem_block["id"], self.html_block["id"]] + ) + data = self._get_container_children(self.unit["id"]) + assert len(data) == 2 + assert data[0]['id'] == self.problem_block['id'] + assert not data[0]['can_stand_alone'] + assert data[1]['id'] == self.html_block['id'] + assert not data[1]['can_stand_alone'] + problem_block_2 = self._add_block_to_library(self.lib["id"], "problem", "Problem_2", can_stand_alone=False) + html_block_2 = self._add_block_to_library(self.lib["id"], "html", "Html_2") + # Add two more components + self._add_container_children( + self.unit["id"], + children_ids=[problem_block_2["id"], html_block_2["id"]] + ) + data = self._get_container_children(self.unit["id"]) + # Verify total number of components to be 2 + 2 = 4 + assert len(data) == 4 + assert data[2]['id'] == problem_block_2['id'] + assert not data[2]['can_stand_alone'] + assert data[3]['id'] == html_block_2['id'] + assert data[3]['can_stand_alone'] + + def test_subsection_add_children(self) -> None: + # Create units + child_unit_1 = self._create_container(self.lib["id"], "unit", display_name="Child unit 1", slug=None) + child_unit_2 = self._create_container(self.lib["id"], "unit", display_name="Child unit 2", slug=None) + + # Add the units to subsection + self._add_container_children( + self.subsection["id"], + children_ids=[child_unit_1["id"], child_unit_2["id"]] + ) + data = self._get_container_children(self.subsection["id"]) + assert len(data) == 2 + assert data[0]['id'] == child_unit_1['id'] + assert data[1]['id'] == child_unit_2['id'] + + child_unit_3 = self._create_container(self.lib["id"], "unit", display_name="Child unit 3", slug=None) + child_unit_4 = self._create_container(self.lib["id"], "unit", display_name="Child unit 4", slug=None) + + # Add two more units to subsection + self._add_container_children( + self.subsection["id"], + children_ids=[child_unit_3["id"], child_unit_4["id"]] + ) + data = self._get_container_children(self.subsection["id"]) + # Verify total number of units to be 2 + 2 = 4 + assert len(data) == 4 + assert data[2]['id'] == child_unit_3['id'] + assert data[3]['id'] == child_unit_4['id'] + + def test_section_add_children(self) -> None: + # Create Subsections + child_subsection_1 = self._create_container( + self.lib["id"], + "subsection", + display_name="Child Subsection 1", + slug=None, + ) + child_subsection_2 = self._create_container( + self.lib["id"], + "subsection", + display_name="Child Subsection 2", + slug=None, + ) + + # Add the subsections to section + self._add_container_children( + self.section["id"], + children_ids=[child_subsection_1["id"], child_subsection_2["id"]] + ) + data = self._get_container_children(self.section["id"]) + assert len(data) == 2 + assert data[0]['id'] == child_subsection_1['id'] + assert data[1]['id'] == child_subsection_2['id'] + + child_subsection_3 = self._create_container( + self.lib["id"], + "subsection", + display_name="Child Subsection 3", + slug=None, + ) + child_subsection_4 = self._create_container( + self.lib["id"], + "subsection", + display_name="Child Subsection 4", + slug=None, + ) + + # Add two more subsections to section + self._add_container_children( + self.section["id"], + children_ids=[child_subsection_3["id"], child_subsection_4["id"]] + ) + data = self._get_container_children(self.section["id"]) + # Verify total number of subsections to be 2 + 2 = 4 + assert len(data) == 4 + assert data[2]['id'] == child_subsection_3['id'] + assert data[3]['id'] == child_subsection_4['id'] + + @ddt.data( + ("unit_with_components", ["problem_block_2", "problem_block"], ["html_block", "html_block_2"]), + ("subsection_with_units", ["unit", "unit_with_components"], ["unit_2", "unit_3"]), + ("section_with_subsections", ["subsection", "subsection_with_units"], ["subsection_2", "subsection_3"]), + ) + @ddt.unpack + def test_container_remove_children(self, container_name, items_to_remove, expected_items) -> None: + """ + Test that we can remove container children + """ + container = getattr(self, container_name) + item_to_remove_1 = getattr(self, items_to_remove[0]) + item_to_remove_2 = getattr(self, items_to_remove[1]) + expected_item_1 = getattr(self, expected_items[0]) + expected_item_2 = getattr(self, expected_items[1]) + data = self._get_container_children(container["id"]) + assert len(data) == 4 + # Remove items. + self._remove_container_components( + container["id"], + children_ids=[item_to_remove_1["id"], item_to_remove_2["id"]] + ) + data = self._get_container_children(container["id"]) + assert len(data) == 2 + assert data[0]['id'] == expected_item_1['id'] + assert data[1]['id'] == expected_item_2['id'] + + def test_unit_replace_children(self) -> None: + """ + Test that we can completely replace/reorder unit children components. + """ + data = self._get_container_children(self.unit_with_components["id"]) + assert len(data) == 4 + assert data[0]['id'] == self.problem_block['id'] + assert data[1]['id'] == self.html_block['id'] + assert data[2]['id'] == self.problem_block_2['id'] + assert data[3]['id'] == self.html_block_2['id'] + + # Reorder the components + self._patch_container_components( + self.unit_with_components["id"], + children_ids=[ + self.problem_block["id"], + self.problem_block_2["id"], + self.html_block["id"], + self.html_block_2["id"], + ] + ) + data = self._get_container_children(self.unit_with_components["id"]) + assert len(data) == 4 + assert data[0]['id'] == self.problem_block['id'] + assert data[1]['id'] == self.problem_block_2['id'] + assert data[2]['id'] == self.html_block['id'] + assert data[3]['id'] == self.html_block_2['id'] + + # Replace with new components + new_problem_block = self._add_block_to_library(self.lib["id"], "problem", "New_Problem", can_stand_alone=False) + new_html_block = self._add_block_to_library(self.lib["id"], "html", "New_Html", can_stand_alone=False) + self._patch_container_components( + self.unit_with_components["id"], + children_ids=[new_problem_block["id"], new_html_block["id"]], + ) + data = self._get_container_children(self.unit_with_components["id"]) + assert len(data) == 2 + assert data[0]['id'] == new_problem_block['id'] + assert data[1]['id'] == new_html_block['id'] + + def test_subsection_replace_children(self) -> None: + """ + Test that we can completely replace/reorder subsection children. + """ + data = self._get_container_children(self.subsection_with_units["id"]) + assert len(data) == 4 + assert data[0]['id'] == self.unit['id'] + assert data[1]['id'] == self.unit_with_components['id'] + assert data[2]['id'] == self.unit_2['id'] + assert data[3]['id'] == self.unit_3['id'] + + # Reorder the units + self._patch_container_components( + self.subsection_with_units["id"], + children_ids=[ + self.unit_2["id"], + self.unit["id"], + self.unit_3["id"], + self.unit_with_components["id"], + ] + ) + data = self._get_container_children(self.subsection_with_units["id"]) + assert len(data) == 4 + assert data[0]['id'] == self.unit_2['id'] + assert data[1]['id'] == self.unit['id'] + assert data[2]['id'] == self.unit_3['id'] + assert data[3]['id'] == self.unit_with_components['id'] + + # Replace with new units + new_unit_1 = self._create_container(self.lib["id"], "unit", display_name="New Unit 1", slug=None) + new_unit_2 = self._create_container(self.lib["id"], "unit", display_name="New Unit 2", slug=None) + self._patch_container_components( + self.subsection_with_units["id"], + children_ids=[new_unit_1["id"], new_unit_2["id"]], + ) + data = self._get_container_children(self.subsection_with_units["id"]) + assert len(data) == 2 + assert data[0]['id'] == new_unit_1['id'] + assert data[1]['id'] == new_unit_2['id'] + + def test_section_replace_children(self) -> None: + """ + Test that we can completely replace/reorder section children. + """ + data = self._get_container_children(self.section_with_subsections["id"]) + assert len(data) == 4 + assert data[0]['id'] == self.subsection['id'] + assert data[1]['id'] == self.subsection_with_units['id'] + assert data[2]['id'] == self.subsection_2['id'] + assert data[3]['id'] == self.subsection_3['id'] + + # Reorder the subsections + self._patch_container_components( + self.section_with_subsections["id"], + children_ids=[ + self.subsection_2["id"], + self.subsection["id"], + self.subsection_3["id"], + self.subsection_with_units["id"], + ] + ) + data = self._get_container_children(self.section_with_subsections["id"]) + assert len(data) == 4 + assert data[0]['id'] == self.subsection_2['id'] + assert data[1]['id'] == self.subsection['id'] + assert data[2]['id'] == self.subsection_3['id'] + assert data[3]['id'] == self.subsection_with_units['id'] + + # Replace with new subsections + new_subsection_1 = self._create_container( + self.lib["id"], + "subsection", + display_name="New Subsection 1", + slug=None, + ) + new_subsection_2 = self._create_container( + self.lib["id"], + "subsection", + display_name="New Subsection 2", + slug=None, + ) + self._patch_container_components( + self.section_with_subsections["id"], + children_ids=[new_subsection_1["id"], new_subsection_2["id"]], + ) + data = self._get_container_children(self.section_with_subsections["id"]) + assert len(data) == 2 + assert data[0]['id'] == new_subsection_1['id'] + assert data[1]['id'] == new_subsection_2['id'] + + @ddt.data( + "unit", + "subsection", + "section", + ) + def test_restore_containers(self, container_type) -> None: + """ + Test restore a deleted container. + """ + container = getattr(self, container_type) + + # Delete container + self._delete_container(container["id"]) + + # Restore container + self._restore_container(container["id"]) + new_container_data = self._get_container(container["id"]) + expected_data = { + "id": container["id"], + "container_type": container_type, + "display_name": container["display_name"], + "last_published": None, + "published_by": "", + "last_draft_created": "2024-09-08T07:06:05Z", + "last_draft_created_by": 'Bob', + 'has_unpublished_changes': True, + 'created': '2024-09-08T07:06:05Z', + 'modified': '2024-09-08T07:06:05Z', + 'collections': [], + } + + self.assertDictContainsEntries(new_container_data, expected_data) + + @ddt.data( + "unit", + "subsection", + "section", + ) + def test_tag_containers(self, container_type) -> None: + container = getattr(self, container_type) + + assert container["tags_count"] == 0 + tagging_api.tag_object( + container["id"], + self.taxonomy, + ['one', 'three', 'four'], + ) + + new_container_data = self._get_container(container["id"]) + assert new_container_data["tags_count"] == 3 + + def test_container_collections(self) -> None: + # Create a collection + col1 = api.create_library_collection( + self.lib_key, + "COL1", + title="Collection 1", + created_by=self.user.id, + description="Description for Collection 1", + ) + + result = self._patch_container_collections( + self.unit["id"], + collection_keys=[col1.key], + ) + + assert result['count'] == 1 + + # Fetch the unit + unit_as_read = self._get_container(self.unit["id"]) + + # Verify the collections + assert unit_as_read['collections'] == [{"title": col1.title, "key": col1.key}] + + def test_publish_container(self) -> None: # pylint: disable=too-many-statements + """ + Test that we can publish the changes to a specific container + """ + html_block_3 = self._add_block_to_library(self.lib["id"], "html", "Html3") + self._add_container_children( + self.unit["id"], + children_ids=[ + self.html_block["id"], + html_block_3["id"], + ] + ) + + # At first everything is unpublished: + c1_before = self._get_container(self.unit_with_components["id"]) + assert c1_before["has_unpublished_changes"] + c1_components_before = self._get_container_children(self.unit_with_components["id"]) + assert len(c1_components_before) == 4 + assert c1_components_before[0]["id"] == self.problem_block["id"] + assert c1_components_before[0]["has_unpublished_changes"] + assert c1_components_before[0]["published_by"] is None + assert c1_components_before[1]["id"] == self.html_block["id"] + assert c1_components_before[1]["has_unpublished_changes"] + assert c1_components_before[1]["published_by"] is None + assert c1_components_before[2]["id"] == self.problem_block_2["id"] + assert c1_components_before[2]["has_unpublished_changes"] + assert c1_components_before[2]["published_by"] is None + assert c1_components_before[3]["id"] == self.html_block_2["id"] + assert c1_components_before[3]["has_unpublished_changes"] + assert c1_components_before[3]["published_by"] is None + c2_before = self._get_container(self.unit["id"]) + assert c2_before["has_unpublished_changes"] + + # Now publish only Container 1 + self._publish_container(self.unit_with_components["id"]) + + # Now it is published: + c1_after = self._get_container(self.unit_with_components["id"]) + assert c1_after["has_unpublished_changes"] is False + c1_components_after = self._get_container_children(self.unit_with_components["id"]) + assert len(c1_components_after) == 4 + assert c1_components_after[0]["id"] == self.problem_block["id"] + assert c1_components_after[0]["has_unpublished_changes"] is False + assert c1_components_after[0]["published_by"] == self.user.username + assert c1_components_after[1]["id"] == self.html_block["id"] + assert c1_components_after[1]["has_unpublished_changes"] is False + assert c1_components_after[1]["published_by"] == self.user.username + assert c1_components_after[2]["id"] == self.problem_block_2["id"] + assert c1_components_after[2]["has_unpublished_changes"] is False + assert c1_components_after[2]["published_by"] == self.user.username + assert c1_components_after[3]["id"] == self.html_block_2["id"] + assert c1_components_after[3]["has_unpublished_changes"] is False + assert c1_components_after[3]["published_by"] == self.user.username + + # and container 2 is still unpublished, except for the shared HTML block that is also in container 1: + c2_after = self._get_container(self.unit["id"]) + assert c2_after["has_unpublished_changes"] + c2_components_after = self._get_container_children(self.unit["id"]) + assert len(c2_components_after) == 2 + assert c2_components_after[0]["id"] == self.html_block["id"] + assert c2_components_after[0]["has_unpublished_changes"] is False # published since it's also in container 1 + assert c2_components_after[0]["published_by"] == self.user.username + assert c2_components_after[1]["id"] == html_block_3["id"] + assert c2_components_after[1]["has_unpublished_changes"] # unaffected + assert c2_components_after[1]["published_by"] is None diff --git a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py index 18d4ec5916..e2fec3aee1 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py @@ -3,24 +3,14 @@ Tests for Learning-Core-based Content Libraries """ from datetime import datetime, timezone from unittest import skip -from unittest.mock import Mock, patch -from uuid import uuid4 +from unittest.mock import patch import ddt from django.contrib.auth.models import Group +from django.test import override_settings from django.test.client import Client from freezegun import freeze_time from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2 -from openedx_events.content_authoring.data import ContentLibraryData, LibraryBlockData -from openedx_events.content_authoring.signals import ( - CONTENT_LIBRARY_CREATED, - CONTENT_LIBRARY_DELETED, - CONTENT_LIBRARY_UPDATED, - LIBRARY_BLOCK_CREATED, - LIBRARY_BLOCK_DELETED, - LIBRARY_BLOCK_UPDATED -) -from openedx_events.tests.utils import OpenEdxEventsTestMixin from organizations.models import Organization from rest_framework.test import APITestCase @@ -31,7 +21,7 @@ from openedx.core.djangoapps.content_libraries.tests.base import ( URL_BLOCK_METADATA_URL, URL_BLOCK_RENDER_VIEW, URL_BLOCK_XBLOCK_HANDLER, - ContentLibrariesRestApiTest + ContentLibrariesRestApiTest, ) from openedx.core.djangoapps.xblock import api as xblock_api from openedx.core.djangolib.testing.utils import skip_unless_cms @@ -39,7 +29,7 @@ from openedx.core.djangolib.testing.utils import skip_unless_cms @skip_unless_cms @ddt.ddt -class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin): +class ContentLibrariesTestCase(ContentLibrariesRestApiTest): """ General tests for Learning-Core-based Content Libraries @@ -62,26 +52,6 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix library slug and bundle UUID does not because it's assumed to be immutable and cached forever. """ - ENABLED_OPENEDX_EVENTS = [ - CONTENT_LIBRARY_CREATED.event_type, - CONTENT_LIBRARY_DELETED.event_type, - CONTENT_LIBRARY_UPDATED.event_type, - LIBRARY_BLOCK_CREATED.event_type, - LIBRARY_BLOCK_DELETED.event_type, - LIBRARY_BLOCK_UPDATED.event_type, - ] - - @classmethod - def setUpClass(cls): - """ - Set up class method for the Test class. - - TODO: It's unclear why we need to call start_events_isolation ourselves rather than relying on - OpenEdxEventsTestMixin.setUpClass to handle it. It fails it we don't, and many other test cases do it, - so we're following a pattern here. But that pattern doesn't really make sense. - """ - super().setUpClass() - cls.start_events_isolation() def test_library_crud(self): """ @@ -139,8 +109,68 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix 'slug': ['Enter a valid “slug” consisting of Unicode letters, numbers, underscores, or hyphens.'], } + def test_library_org_validation(self): + """ + Staff users can create libraries in any existing or auto-created organization. + """ + assert Organization.objects.filter(short_name='auto-created-org').count() == 0 + self._create_library(slug="auto-created-org-1", title="Library in an auto-created org", org='auto-created-org') + assert Organization.objects.filter(short_name='auto-created-org').count() == 1 + self._create_library(slug="existing-org-1", title="Library in an existing org", org="CL-TEST") + + @patch( + "openedx.core.djangoapps.content_libraries.rest_api.libraries.user_can_create_organizations", + ) + @patch( + "openedx.core.djangoapps.content_libraries.rest_api.libraries.get_allowed_organizations_for_libraries", + ) + @override_settings(ORGANIZATIONS_AUTOCREATE=False) + def test_library_org_no_autocreate(self, mock_get_allowed_organizations, mock_can_create_organizations): + """ + When org auto-creation is disabled, user must use one of their allowed orgs. + """ + mock_can_create_organizations.return_value = False + mock_get_allowed_organizations.return_value = ["CL-TEST"] + assert Organization.objects.filter(short_name='auto-created-org').count() == 0 + response = self._create_library( + slug="auto-created-org-2", + org="auto-created-org", + title="Library in an auto-created org", + expect_response=400, + ) + assert response == { + 'org': "No such organization 'auto-created-org' found.", + } + + Organization.objects.get_or_create( + short_name="not-allowed-org", + defaults={"name": "Content Libraries Test Org Membership"}, + ) + response = self._create_library( + slug="not-allowed-org", + org="not-allowed-org", + title="Library in an not-allowed org", + expect_response=400, + ) + assert response == { + 'org': "User not allowed to create libraries in 'not-allowed-org'.", + } + assert mock_can_create_organizations.call_count == 1 + assert mock_get_allowed_organizations.call_count == 1 + + self._create_library( + slug="allowed-org-2", + org="CL-TEST", + title="Library in an allowed org", + ) + assert mock_can_create_organizations.call_count == 2 + assert mock_get_allowed_organizations.call_count == 2 + @skip("This endpoint shouldn't support num_blocks and has_unpublished_*.") - @patch("openedx.core.djangoapps.content_libraries.views.LibraryRootView.pagination_class.page_size", new=2) + @patch( + "openedx.core.djangoapps.content_libraries.rest_api.libraries.LibraryRootView.pagination_class.page_size", + new=2, + ) def test_list_library(self): """ Test the /libraries API and its pagination @@ -259,6 +289,8 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix Tests with some non-ASCII chars in slugs, titles, descriptions. """ + admin = UserFactory.create(username="Admin", email="admin@example.com", is_staff=True) + lib = self._create_library(slug="téstlꜟط", title="A Tést Lꜟطrary", description="Tésting XBlocks") lib_id = lib["id"] assert lib['has_unpublished_changes'] is False @@ -281,9 +313,6 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix "last_draft_created_by": "Bob", }) block_id = block_data["id"] - # Confirm that the result contains a definition key, but don't check its value, - # which for the purposes of these tests is an implementation detail. - assert 'def_key' in block_data # now the library should contain one block and have unpublished changes: assert self._get_library_blocks(lib_id)['results'] == [block_data] @@ -298,6 +327,7 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix block_data["has_unpublished_changes"] = False block_data["last_published"] = publish_date.isoformat().replace('+00:00', 'Z') block_data["published_by"] = "Bob" + block_data["published_display_name"] = "Blank Problem" self.assertDictContainsEntries(self._get_library_block(block_id), block_data) assert self._get_library_blocks(lib_id)['results'] == [block_data] @@ -411,6 +441,7 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix block_data["has_unpublished_changes"] = False block_data["last_published"] = publish_date.isoformat().replace('+00:00', 'Z') block_data["published_by"] = "Bob" + block_data["published_display_name"] = "Text" self.assertDictContainsEntries(self._get_library_block(block_id), block_data) assert self._get_library_blocks(lib_id)['results'] == [block_data] @@ -436,14 +467,17 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix assert 'resources' in fragment assert 'Hello world!' in fragment['content'] - @patch("openedx.core.djangoapps.content_libraries.views.LibraryBlocksView.pagination_class.page_size", new=2) + @patch( + "openedx.core.djangoapps.content_libraries.rest_api.libraries.LibraryBlocksView.pagination_class.page_size", + new=2, + ) def test_list_library_blocks(self): """ Test the /libraries/{lib_key_str}/blocks API and its pagination """ lib = self._create_library(slug="list_blocks-slug", title="Library 1") block1 = self._add_block_to_library(lib["id"], "problem", "problem1") - self._add_block_to_library(lib["id"], "unit", "unit1") + self._add_block_to_library(lib["id"], "html", "html1") response = self._get_library_blocks(lib["id"]) result = response['results'] @@ -531,7 +565,7 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix Learning Core data models. """ # Create a few users to use for all of these tests: - admin = UserFactory.create(username="Admin", email="admin@example.com") + admin = UserFactory.create(username="Admin", email="admin@example.com", is_staff=True) author = UserFactory.create(username="Author", email="author@example.com") reader = UserFactory.create(username="Reader", email="reader@example.com") group = Group.objects.create(name="group1") @@ -653,14 +687,15 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix self._get_library_block_asset(block3_key, file_name="static/whatever.png", expect_response=403) # Nor can they preview the block: self._render_block_view(block3_key, view_name="student_view", expect_response=403) - # But if we grant allow_public_read, then they can: + # Even if we grant allow_public_read, then they can't: with self.as_user(admin): self._update_library(lib_id, allow_public_read=True) self._set_library_block_asset(block3_key, "static/whatever.png", b"data") with self.as_user(random_user): - self._get_library_block_olx(block3_key) + self._get_library_block_olx(block3_key, expect_response=403) + self._get_library_block_fields(block3_key, expect_response=403) + # But he can preview the block: self._render_block_view(block3_key, view_name="student_view") - f = self._get_library_block_fields(block3_key) # self._get_library_block_assets(block3_key) # self._get_library_block_asset(block3_key, file_name="whatever.png") @@ -702,7 +737,7 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix """ Test that administrators cannot be removed if they are the only administrator granted access. """ - admin = UserFactory.create(username="Admin", email="admin@example.com") + admin = UserFactory.create(username="Admin", email="admin@example.com", is_staff=True) successor = UserFactory.create(username="Successor", email="successor@example.com") with self.as_user(admin): lib = self._create_library(slug="permtest", title="Permission Test Library", description="Testing") @@ -725,299 +760,11 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix description="Testing XBlocks limits in a library" ) lib_id = lib["id"] - self._add_block_to_library(lib_id, "unit", "unit1") + self._add_block_to_library(lib_id, "html", "html1") # Second block should throw error self._add_block_to_library(lib_id, "problem", "problem1", expect_response=400) - def test_content_library_create_event(self): - """ - Check that CONTENT_LIBRARY_CREATED event is sent when a content library is created. - """ - event_receiver = Mock() - CONTENT_LIBRARY_CREATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_event_create", - title="Event Test Library", - description="Testing event in library" - ) - library_key = LibraryLocatorV2.from_string(lib['id']) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": CONTENT_LIBRARY_CREATED, - "sender": None, - "content_library": ContentLibraryData( - library_key=library_key, - update_blocks=False, - ), - }, - event_receiver.call_args.kwargs - ) - - def test_content_library_update_event(self): - """ - Check that CONTENT_LIBRARY_UPDATED event is sent when a content library is updated. - """ - event_receiver = Mock() - CONTENT_LIBRARY_UPDATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_event_update", - title="Event Test Library", - description="Testing event in library" - ) - - lib2 = self._update_library(lib["id"], title="New Title") - library_key = LibraryLocatorV2.from_string(lib2['id']) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": CONTENT_LIBRARY_UPDATED, - "sender": None, - "content_library": ContentLibraryData( - library_key=library_key, - update_blocks=False, - ), - }, - event_receiver.call_args.kwargs - ) - - def test_content_library_delete_event(self): - """ - Check that CONTENT_LIBRARY_DELETED event is sent when a content library is deleted. - """ - event_receiver = Mock() - CONTENT_LIBRARY_DELETED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_event_delete", - title="Event Test Library", - description="Testing event in library" - ) - library_key = LibraryLocatorV2.from_string(lib['id']) - - self._delete_library(lib["id"]) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": CONTENT_LIBRARY_DELETED, - "sender": None, - "content_library": ContentLibraryData( - library_key=library_key, - update_blocks=False, - ), - }, - event_receiver.call_args.kwargs - ) - - def test_library_block_create_event(self): - """ - Check that LIBRARY_BLOCK_CREATED event is sent when a library block is created. - """ - event_receiver = Mock() - LIBRARY_BLOCK_CREATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_block_event_create", - title="Event Test Library", - description="Testing event in library" - ) - lib_id = lib["id"] - self._add_block_to_library(lib_id, "problem", "problem1") - - library_key = LibraryLocatorV2.from_string(lib_id) - usage_key = LibraryUsageLocatorV2( - lib_key=library_key, - block_type="problem", - usage_id="problem1" - ) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": LIBRARY_BLOCK_CREATED, - "sender": None, - "library_block": LibraryBlockData( - library_key=library_key, - usage_key=usage_key - ), - }, - event_receiver.call_args.kwargs - ) - - def test_library_block_olx_update_event(self): - """ - Check that LIBRARY_BLOCK_CREATED event is sent when the OLX source is updated. - """ - event_receiver = Mock() - LIBRARY_BLOCK_UPDATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_block_event_olx_update", - title="Event Test Library", - description="Testing event in library" - ) - lib_id = lib["id"] - - library_key = LibraryLocatorV2.from_string(lib_id) - - block = self._add_block_to_library(lib_id, "problem", "problem1") - block_id = block["id"] - usage_key = LibraryUsageLocatorV2( - lib_key=library_key, - block_type="problem", - usage_id="problem1" - ) - - new_olx = """ - - -

    This is a normal capa problem with unicode 🔥. It has "maximum attempts" set to **5**.

    - - - XBlock metadata only - XBlock data/metadata and associated static asset files - Static asset files for XBlocks and courseware - XModule metadata only - -
    -
    - """.strip() - - self._set_library_block_olx(block_id, new_olx) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": LIBRARY_BLOCK_UPDATED, - "sender": None, - "library_block": LibraryBlockData( - library_key=library_key, - usage_key=usage_key - ), - }, - event_receiver.call_args.kwargs - ) - - def test_library_block_add_asset_update_event(self): - """ - Check that LIBRARY_BLOCK_CREATED event is sent when a static asset is - uploaded associated with the XBlock. - """ - event_receiver = Mock() - LIBRARY_BLOCK_UPDATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_block_event_add_asset_update", - title="Event Test Library", - description="Testing event in library" - ) - lib_id = lib["id"] - - library_key = LibraryLocatorV2.from_string(lib_id) - - block = self._add_block_to_library(lib_id, "unit", "u1") - block_id = block["id"] - self._set_library_block_asset(block_id, "static/test.txt", b"data") - - usage_key = LibraryUsageLocatorV2( - lib_key=library_key, - block_type="unit", - usage_id="u1" - ) - - event_receiver.assert_called_once() - self.assertDictContainsSubset( - { - "signal": LIBRARY_BLOCK_UPDATED, - "sender": None, - "library_block": LibraryBlockData( - library_key=library_key, - usage_key=usage_key - ), - }, - event_receiver.call_args.kwargs - ) - - def test_library_block_del_asset_update_event(self): - """ - Check that LIBRARY_BLOCK_CREATED event is sent when a static asset is - removed from XBlock. - """ - event_receiver = Mock() - LIBRARY_BLOCK_UPDATED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_block_event_del_asset_update", - title="Event Test Library", - description="Testing event in library" - ) - lib_id = lib["id"] - - library_key = LibraryLocatorV2.from_string(lib_id) - - block = self._add_block_to_library(lib_id, "unit", "u1") - block_id = block["id"] - self._set_library_block_asset(block_id, "static/test.txt", b"data") - - self._delete_library_block_asset(block_id, 'static/text.txt') - - usage_key = LibraryUsageLocatorV2( - lib_key=library_key, - block_type="unit", - usage_id="u1" - ) - - event_receiver.assert_called() - self.assertDictContainsSubset( - { - "signal": LIBRARY_BLOCK_UPDATED, - "sender": None, - "library_block": LibraryBlockData( - library_key=library_key, - usage_key=usage_key - ), - }, - event_receiver.call_args.kwargs - ) - - def test_library_block_delete_event(self): - """ - Check that LIBRARY_BLOCK_DELETED event is sent when a content library is deleted. - """ - event_receiver = Mock() - LIBRARY_BLOCK_DELETED.connect(event_receiver) - lib = self._create_library( - slug="test_lib_block_event_delete", - title="Event Test Library", - description="Testing event in library" - ) - - lib_id = lib["id"] - library_key = LibraryLocatorV2.from_string(lib_id) - - block = self._add_block_to_library(lib_id, "problem", "problem1") - block_id = block['id'] - - usage_key = LibraryUsageLocatorV2( - lib_key=library_key, - block_type="problem", - usage_id="problem1" - ) - - self._delete_library_block(block_id) - - event_receiver.assert_called() - self.assertDictContainsSubset( - { - "signal": LIBRARY_BLOCK_DELETED, - "sender": None, - "library_block": LibraryBlockData( - library_key=library_key, - usage_key=usage_key - ), - }, - event_receiver.call_args.kwargs - ) - - def test_library_paste_clipboard(self): + def test_library_paste_xblock(self): """ Check the a new block is created in the library after pasting from clipboard. The content of the new block should match the content of the block in the clipboard. @@ -1026,7 +773,7 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix from openedx.core.djangoapps.content_staging.api import save_xblock_to_user_clipboard # Create user to perform tests on - author = UserFactory.create(username="Author", email="author@example.com") + author = UserFactory.create(username="Author", email="author@example.com", is_staff=True) with self.as_user(author): lib = self._create_library( slug="test_lib_paste_clipboard", @@ -1056,13 +803,8 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix save_xblock_to_user_clipboard(block, author.id) # Paste the content of the clipboard into the library - pasted_block_id = str(uuid4()) - paste_data = self._paste_clipboard_content_in_library(lib_id, pasted_block_id) - pasted_usage_key = LibraryUsageLocatorV2( - lib_key=library_key, - block_type="problem", - usage_id=pasted_block_id - ) + paste_data = self._paste_clipboard_content_in_library(lib_id) + pasted_usage_key = LibraryUsageLocatorV2.from_string(paste_data["id"]) self._get_library_block_asset(pasted_usage_key, "static/hello.txt") # Compare the two text files @@ -1078,9 +820,28 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix "last_draft_created": paste_data["last_draft_created"], "created": paste_data["created"], "modified": paste_data["modified"], - "id": f"lb:CL-TEST:test_lib_paste_clipboard:problem:{pasted_block_id}", + "id": f"lb:CL-TEST:test_lib_paste_clipboard:problem:{pasted_usage_key.block_id}", }) + @override_settings(LIBRARY_ENABLED_BLOCKS=['problem', 'video', 'html']) + def test_library_get_enabled_blocks(self): + expected = [ + {"block_type": "html", "display_name": "Text"}, + {"block_type": "problem", "display_name": "Problem"}, + {"block_type": "video", "display_name": "Video"}, + ] + + author = UserFactory.create(username="Author", email="author@example.com", is_staff=True) + with self.as_user(author): + lib = self._create_library( + slug="test_lib_enabled_blocks", + title="Get Enabled Blocks Test Library", + description="Testing get enabled blocks from library" + ) + lib_id = lib["id"] + block_types = self._get_library_block_types(lib_id) + assert [dict(item) for item in block_types] == expected + @ddt.ddt class ContentLibraryXBlockValidationTest(APITestCase): diff --git a/openedx/core/djangoapps/content_libraries/tests/test_course_to_library.py b/openedx/core/djangoapps/content_libraries/tests/test_course_to_library.py new file mode 100644 index 0000000000..5c1bd57744 --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/tests/test_course_to_library.py @@ -0,0 +1,80 @@ +""" +Tests for Imports from Courses to Learning-Core-based Content Libraries +""" +import ddt +from opaque_keys.edx.locator import LibraryContainerLocator + +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import ToyCourseFactory +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest +from openedx.core.djangolib.testing.utils import skip_unless_cms + + +@skip_unless_cms +@ddt.ddt +class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase): + """ + Tests that involve copying content from courses to libraries. + """ + + def test_library_paste_unit_from_course(self): + """ + Test that we can paste a Unit from a course into a Library, and it gets + converted from an XBlock to a Container. + """ + # Create user to perform tests on + author = UserFactory.create(username="Author", email="author@example.com", is_staff=True) + with self.as_user(author): + lib = self._create_library( + slug="test_lib_paste_clipboard", + title="Paste Clipboard Test Library", + description="Testing pasting clipboard in library" + ) + lib_id = lib["id"] + + course_key = ToyCourseFactory.create().id # See xmodule/modulestore/tests/sample_courses.py + unit_key = course_key.make_usage_key("vertical", "vertical_test") + + # Copy the unit + self._api('post', "/api/content-staging/v1/clipboard/", {"usage_key": str(unit_key)}, expect_response=200) + + # Paste the content of the clipboard into the library + paste_data = self._paste_clipboard_content_in_library(lib_id) + pasted_container_key = LibraryContainerLocator.from_string(paste_data["id"]) + + self.assertDictContainsEntries(self._get_container(paste_data["id"]), { + "id": str(pasted_container_key), + "container_type": "unit", + "display_name": "Unit", + "last_draft_created_by": author.username, + "last_draft_created": paste_data["last_draft_created"], + "created": paste_data["created"], + "modified": paste_data["modified"], + "last_published": None, + "published_by": "", + "has_unpublished_changes": True, + "collections": [], + "can_stand_alone": True, + }) + + children = self._get_container_children(paste_data["id"]) + assert len(children) == 4 + + self.assertDictContainsEntries(children[0], {"display_name": "default", "block_type": "video"}) + assert children[0]["id"].startswith("lb:CL-TEST:test_lib_paste_clipboard:video:default-") + assert "container_type" not in children[0] + + self.assertDictContainsEntries(children[1], {"display_name": "default", "block_type": "video"}) + assert children[1]["id"].startswith("lb:CL-TEST:test_lib_paste_clipboard:video:default-") + assert children[0]["id"] != children[1]["id"] + + self.assertDictContainsEntries(children[2], {"display_name": "default", "block_type": "video"}) + assert children[2]["id"].startswith("lb:CL-TEST:test_lib_paste_clipboard:video:default-") + assert children[0]["id"] != children[2]["id"] + + self.assertDictContainsEntries(children[3], { + "display_name": "Change your answer", + "block_type": "poll_question", + }) + assert children[3]["id"].startswith("lb:CL-TEST:test_lib_paste_clipboard:poll_question:change-your-answer-") diff --git a/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py b/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py index e9909b7d60..41abeed829 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py @@ -8,7 +8,6 @@ import re import ddt from django.core.exceptions import ValidationError from django.test.utils import override_settings -from openedx_events.tests.utils import OpenEdxEventsTestMixin import pytest from xblock.core import XBlock @@ -22,7 +21,7 @@ from .fields_test_block import FieldsTestBlock @skip_unless_cms @ddt.ddt @override_settings(CORS_ORIGIN_WHITELIST=[]) # For some reason, this setting isn't defined in our test environment? -class LibrariesEmbedViewTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin): +class LibrariesEmbedViewTestCase(ContentLibrariesRestApiTest): """ Tests for embed_view and interacting with draft/published/past versions of Learning-Core-based XBlocks (in Content Libraries). diff --git a/openedx/core/djangoapps/content_libraries/tests/test_events.py b/openedx/core/djangoapps/content_libraries/tests/test_events.py new file mode 100644 index 0000000000..88d426d3ef --- /dev/null +++ b/openedx/core/djangoapps/content_libraries/tests/test_events.py @@ -0,0 +1,548 @@ +""" +Tests for Learning-Core-based Content Libraries +""" +from opaque_keys.edx.locator import ( + LibraryCollectionLocator, + LibraryContainerLocator, + LibraryLocatorV2, + LibraryUsageLocatorV2, +) +from openedx_events.content_authoring.signals import ( + ContentLibraryData, + LibraryBlockData, + LibraryCollectionData, + LibraryContainerData, + CONTENT_LIBRARY_CREATED, + CONTENT_LIBRARY_DELETED, + CONTENT_LIBRARY_UPDATED, + LIBRARY_BLOCK_CREATED, + LIBRARY_BLOCK_DELETED, + LIBRARY_BLOCK_UPDATED, + LIBRARY_BLOCK_PUBLISHED, + LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_DELETED, + LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_CREATED, + LIBRARY_CONTAINER_DELETED, + LIBRARY_CONTAINER_UPDATED, + LIBRARY_CONTAINER_PUBLISHED, +) + +from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest +from openedx.core.djangolib.testing.utils import skip_unless_cms + + +@skip_unless_cms +class ContentLibrariesEventsTestCase(ContentLibrariesRestApiTest): + """ + Event tests for Learning-Core-based Content Libraries + + These tests use the REST API, which in turn relies on the Python API. + """ + # Note: we assume all events are already enabled, as they should be. We do + # NOT use OpenEdxEventsTestMixin, because it disables any events that you + # don't explicitly enable and does so in a way that interferes with other + # test cases, causing flakiness and failures in *other* test modules. + ALL_EVENTS = [ + CONTENT_LIBRARY_CREATED, + CONTENT_LIBRARY_DELETED, + CONTENT_LIBRARY_UPDATED, + LIBRARY_BLOCK_CREATED, + LIBRARY_BLOCK_DELETED, + LIBRARY_BLOCK_UPDATED, + LIBRARY_BLOCK_PUBLISHED, + LIBRARY_COLLECTION_CREATED, + LIBRARY_COLLECTION_DELETED, + LIBRARY_COLLECTION_UPDATED, + LIBRARY_CONTAINER_CREATED, + LIBRARY_CONTAINER_DELETED, + LIBRARY_CONTAINER_UPDATED, + LIBRARY_CONTAINER_PUBLISHED, + ] + + def setUp(self) -> None: + super().setUp() + + # Create some useful data: + self.lib1 = self._create_library( + slug="test_lib_1", + title="Library 1", + description="First Library for testing", + ) + self.lib1_key = LibraryLocatorV2.from_string(self.lib1['id']) + + # From now on, every time an event is emitted, add it to this set: + self.new_events: list[dict] = [] + + def event_receiver(**kwargs) -> None: + self.new_events.append(kwargs) + + for e in self.ALL_EVENTS: + e.connect(event_receiver) + + def disconnect_all() -> None: + for e in self.ALL_EVENTS: + e.disconnect(event_receiver) + + self.addCleanup(disconnect_all) + + def clear_events(self) -> None: + """ Clear the log of events that we've seen so far. """ + self.new_events.clear() + + def expect_new_events(self, *expected_events: dict) -> None: + """ + assert the the specified events have been emitted since the last call to + this function. + """ + # We assume the events may not be in order. Assuming a specific order can lead to flaky tests. + for expected in expected_events: + found = False + for i, actual in enumerate(self.new_events): + if expected.items() <= actual.items(): + self.new_events.pop(i) + found = True + break + if not found: + raise AssertionError(f"Event {expected} not found among actual events: {self.new_events}") + if len(self.new_events) > 0: + raise AssertionError(f"Events were emitted but not expected: {self.new_events}") + self.clear_events() + + ############################## Libraries ################################## + + def test_content_library_crud_events(self) -> None: + """ + Check that CONTENT_LIBRARY_CREATED event is sent when a content library is created, updated, and deleted + """ + # Setup: none + # Action - create a library + new_lib = self._create_library( + slug="new_lib", + title="New Testing Library", + description="New Library for testing", + ) + lib_key = LibraryLocatorV2.from_string(new_lib['id']) + + # Expect a CREATED event: + self.expect_new_events({ + "signal": CONTENT_LIBRARY_CREATED, + "content_library": ContentLibraryData(library_key=lib_key), + }) + + # Action - change the library name: + self._update_library(lib_key=str(lib_key), title="New title") + # Expect an UPDATED event: + self.expect_new_events({ + "signal": CONTENT_LIBRARY_UPDATED, + "content_library": ContentLibraryData(library_key=lib_key), + }) + + # Action - delete the library: + self._delete_library(str(lib_key)) + # Expect a DELETED event: + self.expect_new_events({ + "signal": CONTENT_LIBRARY_DELETED, + "content_library": ContentLibraryData(library_key=lib_key), + }) + + # Should deleting a library send out _DELETED events for all the items in the library too? + + ############################## Components (XBlocks) ################################## + + def test_library_block_create_event(self) -> None: + """ + Check that LIBRARY_BLOCK_CREATED event is sent when a library block is created. + """ + add_result = self._add_block_to_library(self.lib1_key, "problem", "problem1") + usage_key = LibraryUsageLocatorV2.from_string(add_result["id"]) + + self.expect_new_events({ + "signal": LIBRARY_BLOCK_CREATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + def test_library_block_update_and_publish_events(self) -> None: + """ + Check that appropriate events are emitted when an existing block is updated. + """ + # This block should be ignored: + self._add_block_to_library(self.lib1_key, "problem", "problem1") + # This block will be used in the tests: + add_result = self._add_block_to_library(self.lib1_key, "problem", "problem2") + usage_key = LibraryUsageLocatorV2.from_string(add_result["id"]) + # Clear events from creating the blocks: + self.clear_events() + + # Now update the block's OLX: + new_olx = """ + + ... + + """.strip() + self._set_library_block_olx(usage_key, new_olx) + self.expect_new_events({ + "signal": LIBRARY_BLOCK_UPDATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + # Now add a static asset file to the block: + self._set_library_block_asset(usage_key, "static/test.txt", b"data") + self.expect_new_events({ + "signal": LIBRARY_BLOCK_UPDATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + # Then delete the static asset: + self._delete_library_block_asset(usage_key, 'static/text.txt') + self.expect_new_events({ + "signal": LIBRARY_BLOCK_UPDATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + # Then publish the block: + self._publish_library_block(usage_key) + self.expect_new_events({ + "signal": LIBRARY_BLOCK_PUBLISHED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + def test_revert_delete(self) -> None: + """ + Test that when a block is deleted and then the delete is reverted, a + _CREATED event is sent. + """ + # This block should be ignored: + self._add_block_to_library(self.lib1_key, "problem", "problem1") + # This block will be used in the tests: + add_result = self._add_block_to_library(self.lib1_key, "problem", "problem2") + usage_key = LibraryUsageLocatorV2.from_string(add_result["id"]) + # Publish changes + self._commit_library_changes(self.lib1_key) + # Clear events from creating the blocks: + self.clear_events() + + # Delete the block: + self._delete_library_block(usage_key) + # That should emit a _DELETED event: + self.expect_new_events({ + "signal": LIBRARY_BLOCK_DELETED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + # Revert the change: + self._revert_library_changes(self.lib1_key) + # That should result in a _CREATED event: + self.expect_new_events({ + "signal": LIBRARY_BLOCK_CREATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + def test_revert_create(self) -> None: + """ + Test that when a block is created and then the changes are reverted, a + _DELETED event is sent. + """ + # Publish any changes from setUp() + self._commit_library_changes(self.lib1_key) + # Clear events: + self.clear_events() + + # Create the block: + add_result = self._add_block_to_library(self.lib1_key, "problem", "problem2") + usage_key = LibraryUsageLocatorV2.from_string(add_result["id"]) + # That should result in a _CREATED event: + self.expect_new_events({ + "signal": LIBRARY_BLOCK_CREATED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + # Revert the change: + self._revert_library_changes(self.lib1_key) + # That should result in a _DELETED event: + self.expect_new_events({ + "signal": LIBRARY_BLOCK_DELETED, + "library_block": LibraryBlockData(self.lib1_key, usage_key), + }) + + ############################## Containers ################################## + + def test_unit_crud(self) -> None: + """ + Test Create, Read, Update, and Delete of a Unit + """ + # Create a unit: + container_data = self._create_container(self.lib1_key, "unit", slug="u1", display_name="Test Unit") + container_key = LibraryContainerLocator.from_string(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_CREATED, + "library_container": LibraryContainerData(container_key), + }) + + # Update the unit: + self._update_container(container_key, display_name="Unit ABC") + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_UPDATED, + "library_container": LibraryContainerData(container_key), + }) + + # Delete the unit + self._delete_container(container_key) + self._get_container(container_key, expect_response=404) + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_DELETED, + "library_container": LibraryContainerData(container_key), + }) + + def test_publish_all_lib_changes(self) -> None: + """ + Test the events that get emitted when we publish all changes in the library + """ + # Create two containers and add some components + # -> container 1: problem_block, html_block + # -> container 2: html_block, html_block2 + container1 = self._create_container(self.lib1_key, "unit", display_name="Alpha Unit", slug=None) + container2 = self._create_container(self.lib1_key, "unit", display_name="Bravo Unit", slug=None) + problem_block = self._add_block_to_library(self.lib1_key, "problem", "Problem1", can_stand_alone=False) + html_block = self._add_block_to_library(self.lib1_key, "html", "Html1", can_stand_alone=False) + html_block2 = self._add_block_to_library(self.lib1_key, "html", "Html2", can_stand_alone=False) + self._add_container_children(container1["id"], children_ids=[problem_block["id"], html_block["id"]]) + self._add_container_children(container2["id"], children_ids=[html_block["id"], html_block2["id"]]) + + # Now publish only Container 2 (which will auto-publish both HTML blocks since they're children) + self._publish_container(container2["id"]) + # Container 2 is published, container 1 and its contents is unpublished: + assert self._get_container(container2["id"])["has_unpublished_changes"] is False + assert self._get_container(container1["id"])["has_unpublished_changes"] + assert self._get_library_block(problem_block["id"])["has_unpublished_changes"] + assert self._get_library_block(html_block["id"])["has_unpublished_changes"] is False # in containers 1+2 + + # clear event log up to this point + self.clear_events() + + # Now publish ALL remaining changes in the library: + self._commit_library_changes(self.lib1_key) + # Container 1 is now published: + assert self._get_container(container1["id"])["has_unpublished_changes"] is False + # And publish events were emitted: + self.expect_new_events( + { # An event for container 1 being published: + "signal": LIBRARY_CONTAINER_PUBLISHED, + "library_container": LibraryContainerData( + container_key=LibraryContainerLocator.from_string(container1["id"]), + ), + }, + { # An event for the problem block in container 1: + "signal": LIBRARY_BLOCK_PUBLISHED, + "library_block": LibraryBlockData( + self.lib1_key, LibraryUsageLocatorV2.from_string(problem_block["id"]), + ), + }, + # The HTML block in container 1 is not part of this publish event group, because it was + # already published when we published container 2 + ) + + def test_publish_child_block(self) -> None: + """ + Test the events that get emitted when we publish changes to a child of a container + """ + # Create a container and a block + container1 = self._create_container(self.lib1_key, "unit", display_name="Alpha Unit", slug=None) + problem_block = self._add_block_to_library(self.lib1_key, "problem", "Problem1", can_stand_alone=False) + self._add_container_children(container1["id"], children_ids=[problem_block["id"]]) + # Publish all changes + self._commit_library_changes(self.lib1_key) + assert self._get_container(container1["id"])["has_unpublished_changes"] is False + + # Change only the block, not the container: + self._set_library_block_olx(problem_block["id"], "UPDATED") + # Since we modified the block, the container now contains changes (technically it is unchanged and its + # version is the same, but it *contains* unpublished changes) + assert self._get_library_block(problem_block["id"])["has_unpublished_changes"] + assert self._get_container(container1["id"])["has_unpublished_changes"] + # clear event log up to this point + self.clear_events() + + # Now publish ALL remaining changes in the library - should only affect the problem block + self._commit_library_changes(self.lib1_key) + # The container no longer contains unpublished changes: + assert self._get_container(container1["id"])["has_unpublished_changes"] is False + # And publish events were emitted: + self.expect_new_events( + { # An event for container 1 being affected indirectly by the child being published: + # TODO: should this be a CONTAINER_CHILD_PUBLISHED event? + "signal": LIBRARY_CONTAINER_PUBLISHED, + "library_container": LibraryContainerData( + container_key=LibraryContainerLocator.from_string(container1["id"]), + ), + }, + { # An event for the problem block: + "signal": LIBRARY_BLOCK_PUBLISHED, + "library_block": LibraryBlockData( + self.lib1_key, LibraryUsageLocatorV2.from_string(problem_block["id"]), + ), + }, + ) + + def test_publish_container(self) -> None: + """ + Test the events that get emitted when we publish the changes to a specific container + """ + # Create two containers and add some components + container1 = self._create_container(self.lib1_key, "unit", display_name="Alpha Unit", slug=None) + container2 = self._create_container(self.lib1_key, "unit", display_name="Bravo Unit", slug=None) + problem_block = self._add_block_to_library(self.lib1_key, "problem", "Problem1", can_stand_alone=False) + html_block = self._add_block_to_library(self.lib1_key, "html", "Html1", can_stand_alone=False) + html_block2 = self._add_block_to_library(self.lib1_key, "html", "Html2", can_stand_alone=False) + self._add_container_children(container1["id"], children_ids=[problem_block["id"], html_block["id"]]) + self._add_container_children(container2["id"], children_ids=[html_block["id"], html_block2["id"]]) + # At first everything is unpublished: + c1_before = self._get_container(container1["id"]) + assert c1_before["has_unpublished_changes"] + c2_before = self._get_container(container2["id"]) + assert c2_before["has_unpublished_changes"] + + # clear event log after the initial mock data setup is complete: + self.clear_events() + + # Now publish only Container 1 + self._publish_container(container1["id"]) + + # Now it is published: + c1_after = self._get_container(container1["id"]) + assert c1_after["has_unpublished_changes"] is False + # And publish events were emitted: + self.expect_new_events( + { # An event for container 1 being published: + "signal": LIBRARY_CONTAINER_PUBLISHED, + "library_container": LibraryContainerData( + container_key=LibraryContainerLocator.from_string(container1["id"]), + ), + }, + { # An event for the problem block in container 1: + "signal": LIBRARY_BLOCK_PUBLISHED, + "library_block": LibraryBlockData( + self.lib1_key, LibraryUsageLocatorV2.from_string(problem_block["id"]), + ), + }, + { # An event for the html block in container 1 (and container 2): + "signal": LIBRARY_BLOCK_PUBLISHED, + "library_block": LibraryBlockData( + self.lib1_key, LibraryUsageLocatorV2.from_string(html_block["id"]), + ), + }, + { # Not 100% sure we want this, but a PUBLISHED event is emitted for container 2 + # because one of its children's published versions has changed, so whether or + # not it contains unpublished changes may have changed and the search index + # may need to be updated. It is not actually published though. + # TODO: should this be a CONTAINER_CHILD_PUBLISHED event? + "signal": LIBRARY_CONTAINER_PUBLISHED, + "library_container": LibraryContainerData( + container_key=LibraryContainerLocator.from_string(container2["id"]), + ), + }, + ) + + # note that container 2 is still unpublished + c2_after = self._get_container(container2["id"]) + assert c2_after["has_unpublished_changes"] + + def test_restore_unit(self) -> None: + """ + Test restoring a deleted unit via the "restore" API. + """ + # Create a unit: + container_data = self._create_container(self.lib1_key, "unit", slug="u1", display_name="Test Unit") + container_key = LibraryContainerLocator.from_string(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_CREATED, + "library_container": LibraryContainerData(container_key), + }) + + # Delete the unit + self._delete_container(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_DELETED, + "library_container": LibraryContainerData(container_key), + }) + + # Restore the unit + self._restore_container(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_CREATED, + "library_container": LibraryContainerData(container_key), + }) + + def test_restore_unit_via_revert(self) -> None: + """ + Test restoring a deleted unit by reverting changes. + """ + # Publish the existing setup and clear events + self._commit_library_changes(self.lib1_key) + self.clear_events() + + # Create a unit: + container_data = self._create_container(self.lib1_key, "unit", slug="u1", display_name="Test Unit") + container_key = LibraryContainerLocator.from_string(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_CREATED, + "library_container": LibraryContainerData(container_key), + }) + + # Publish changes + self._publish_container(container_key) + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_PUBLISHED, + "library_container": LibraryContainerData(container_key), + }) + + # Delete the unit + self._delete_container(container_data["id"]) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_DELETED, + "library_container": LibraryContainerData(container_key), + }) + + # Revert changes, which will re-create the unit: + self._revert_library_changes(self.lib1_key) + + self.expect_new_events({ + "signal": LIBRARY_CONTAINER_CREATED, + "library_container": LibraryContainerData(container_key), + }) + + ############################## Collections ################################## + + def test_collection_crud(self) -> None: + """ Test basic create, update, and delete events for collections """ + collection = self._create_collection(self.lib1_key, "Test Collection") + # To fix? The response from _create_collection should have the opaque key as the "id" field, not an integer. + collection_key = LibraryCollectionLocator(lib_key=self.lib1_key, collection_id=collection["key"]) + self.expect_new_events({ + "signal": LIBRARY_COLLECTION_CREATED, + "library_collection": LibraryCollectionData(collection_key), + }) + + # Update the collection: + self._update_collection(collection_key, description="Updated description") + self.expect_new_events({ + "signal": LIBRARY_COLLECTION_UPDATED, + "library_collection": LibraryCollectionData(collection_key), + }) + + # Soft delete the collection. NOTE: at the moment, it's only possible to "soft delete" collections via + # the REST API, which sends an UPDATED event because the collection is now "disabled" but not deleted. + self._soft_delete_collection(collection_key) + self.expect_new_events({ + "signal": LIBRARY_COLLECTION_UPDATED, # UPDATED not DELETED. If we do a hard delete, it should be DELETED. + "library_collection": LibraryCollectionData(collection_key), + }) + + # TODO: move more of the event-related collection tests from test_api.py to here, and convert them to use REST APIs diff --git a/openedx/core/djangoapps/content_libraries/tests/test_static_assets.py b/openedx/core/djangoapps/content_libraries/tests/test_static_assets.py index 69f5cd2d79..91d9556b01 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_static_assets.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_static_assets.py @@ -63,6 +63,13 @@ class ContentLibrariesStaticAssetsTest(ContentLibrariesRestApiTest): file_name = "a////////b" self._set_library_block_asset(block_id, file_name, SVG_DATA, expect_response=400) + # Names with spaces are allowed but replaced with underscores + file_name_with_space = "o w o.svg" + self._set_library_block_asset(block_id, file_name_with_space, SVG_DATA) + file_name = "o_w_o.svg" + assert self._get_library_block_asset(block_id, file_name)['path'] == file_name + assert self._get_library_block_asset(block_id, file_name)['size'] == file_size + def test_video_transcripts(self): """ Test that video blocks can read transcript files out of learning core. diff --git a/openedx/core/djangoapps/content_libraries/tests/test_versioned_apis.py b/openedx/core/djangoapps/content_libraries/tests/test_versioned_apis.py index ad7ea54d8d..20d0b38f0b 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_versioned_apis.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_versioned_apis.py @@ -2,7 +2,6 @@ Tests that several XBlock APIs support versioning """ from django.test.utils import override_settings -from openedx_events.tests.utils import OpenEdxEventsTestMixin from xblock.core import XBlock from openedx.core.djangoapps.content_libraries.tests.base import ( @@ -14,7 +13,7 @@ from .fields_test_block import FieldsTestBlock @skip_unless_cms @override_settings(CORS_ORIGIN_WHITELIST=[]) # For some reason, this setting isn't defined in our test environment? -class VersionedXBlockApisTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin): +class VersionedXBlockApisTestCase(ContentLibrariesRestApiTest): """ Tests for three APIs implemented by djangoapps.xblock, and used by content libraries. These tests focus on versioning. diff --git a/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py b/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py index 43c1627c2c..ce9b0e880d 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_views_collections.py @@ -18,7 +18,7 @@ URL_PREFIX = '/api/libraries/v2/{lib_key}/' URL_LIB_COLLECTIONS = URL_PREFIX + 'collections/' URL_LIB_COLLECTION = URL_LIB_COLLECTIONS + '{collection_key}/' URL_LIB_COLLECTION_RESTORE = URL_LIB_COLLECTIONS + '{collection_key}/restore/' -URL_LIB_COLLECTION_COMPONENTS = URL_LIB_COLLECTION + 'components/' +URL_LIB_COLLECTION_COMPONENTS = URL_LIB_COLLECTION + 'items/' @ddt.ddt @@ -74,6 +74,14 @@ class ContentLibraryCollectionsViewsTest(ContentLibrariesRestApiTest): self.lib2_html_block = self._add_block_to_library( self.lib2.library_key, "html", "html2", ) + self.unit = self._create_container(self.lib1.library_key, "unit", display_name="Unit 1", slug=None) + self.subsection = self._create_container( + self.lib1.library_key, + "subsection", + display_name="Subsection 1", + slug=None, + ) + self.section = self._create_container(self.lib1.library_key, "section", display_name="Section 1", slug=None) def test_get_library_collection(self): """ @@ -407,6 +415,43 @@ class ContentLibraryCollectionsViewsTest(ContentLibrariesRestApiTest): assert resp.status_code == 200 assert resp.data == {"count": 1} + def test_update_containers(self): + """ + Test adding and removing containers from a collection. + """ + # Add containers to col1 + resp = self.client.patch( + URL_LIB_COLLECTION_COMPONENTS.format( + lib_key=self.lib1.library_key, + collection_key=self.col1.key, + ), + data={ + "usage_keys": [ + self.unit["id"], + self.subsection["id"], + self.section["id"], + ] + } + ) + assert resp.status_code == 200 + assert resp.data == {"count": 3} + + # Remove one of the added containers from col1 + resp = self.client.delete( + URL_LIB_COLLECTION_COMPONENTS.format( + lib_key=self.lib1.library_key, + collection_key=self.col1.key, + ), + data={ + "usage_keys": [ + self.unit["id"], + self.subsection["id"], + ] + } + ) + assert resp.status_code == 200 + assert resp.data == {"count": 2} + @ddt.data("patch", "delete") def test_update_components_wrong_collection(self, method): """ diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py index 857126eef7..2b9cd59af7 100644 --- a/openedx/core/djangoapps/content_libraries/urls.py +++ b/openedx/core/djangoapps/content_libraries/urls.py @@ -2,26 +2,27 @@ URL configuration for Studio's Content Libraries REST API """ -from django.urls import include, path, re_path - +from django.urls import include, path, re_path, register_converter from rest_framework import routers -from . import views -from . import views_collections - +from .rest_api import blocks, collections, containers, libraries, url_converters # Django application name. app_name = 'openedx.core.djangoapps.content_libraries' +# URL converters + +register_converter(url_converters.LibraryContainerLocatorConverter, "lib_container_key") + # Router for importing blocks from courseware. import_blocks_router = routers.DefaultRouter() -import_blocks_router.register(r'tasks', views.LibraryImportTaskViewSet, basename='import-block-task') +import_blocks_router.register(r'tasks', libraries.LibraryImportTaskViewSet, basename='import-block-task') library_collections_router = routers.DefaultRouter() library_collections_router.register( - r'collections', views_collections.LibraryCollectionsView, basename="library-collections" + r'collections', collections.LibraryCollectionsView, basename="library-collections" ) # These URLs are only used in Studio. The LMS already provides all the @@ -31,60 +32,76 @@ library_collections_router.register( urlpatterns = [ path('api/libraries/v2/', include([ # list of libraries / create a library: - path('', views.LibraryRootView.as_view()), + path('', libraries.LibraryRootView.as_view()), path('/', include([ # get data about a library, update a library, or delete a library: - path('', views.LibraryDetailsView.as_view()), + path('', libraries.LibraryDetailsView.as_view()), # Get the list of XBlock types that can be added to this library - path('block_types/', views.LibraryBlockTypesView.as_view()), + path('block_types/', libraries.LibraryBlockTypesView.as_view()), # Get the list of XBlocks in this library, or add a new one: - path('blocks/', views.LibraryBlocksView.as_view()), - # Commit (POST) or revert (DELETE) all pending changes to this library: - path('commit/', views.LibraryCommitView.as_view()), + path('blocks/', blocks.LibraryBlocksView.as_view()), + # Add a new container (unit etc.) to this library: + path('containers/', containers.LibraryContainersView.as_view()), + # Publish (POST) or revert (DELETE) all pending changes to this library: + path('commit/', libraries.LibraryCommitView.as_view()), # Get the list of users/groups who have permission to view/edit/administer this library: - path('team/', views.LibraryTeamView.as_view()), + path('team/', libraries.LibraryTeamView.as_view()), # Add/Edit (PUT) or remove (DELETE) a user's permission to use this library - path('team/user//', views.LibraryTeamUserView.as_view()), + path('team/user//', libraries.LibraryTeamUserView.as_view()), # Add/Edit (PUT) or remove (DELETE) a group's permission to use this library - path('team/group//', views.LibraryTeamGroupView.as_view()), + path('team/group//', libraries.LibraryTeamGroupView.as_view()), # Import blocks into this library. path('import_blocks/', include(import_blocks_router.urls)), # Paste contents of clipboard into library - path('paste_clipboard/', views.LibraryPasteClipboardView.as_view()), + path('paste_clipboard/', libraries.LibraryPasteClipboardView.as_view()), # Library Collections path('', include(library_collections_router.urls)), ])), path('blocks//', include([ # Get metadata about a specific XBlock in this library, or delete the block: - path('', views.LibraryBlockView.as_view()), + path('', blocks.LibraryBlockView.as_view()), + # Restore a soft-deleted block + path('restore/', blocks.LibraryBlockRestore.as_view()), # Update collections for a given component - path('collections/', views.LibraryBlockCollectionsView.as_view(), name='update-collections'), + path('collections/', blocks.LibraryBlockCollectionsView.as_view(), name='update-collections'), # Get the LTI URL of a specific XBlock - path('lti/', views.LibraryBlockLtiUrlView.as_view(), name='lti-url'), + path('lti/', blocks.LibraryBlockLtiUrlView.as_view(), name='lti-url'), # Get the OLX source code of the specified block: - path('olx/', views.LibraryBlockOlxView.as_view()), + path('olx/', blocks.LibraryBlockOlxView.as_view()), # CRUD for static asset files associated with a block in the library: - path('assets/', views.LibraryBlockAssetListView.as_view()), - path('assets/', views.LibraryBlockAssetView.as_view()), - path('publish/', views.LibraryBlockPublishView.as_view()), + path('assets/', blocks.LibraryBlockAssetListView.as_view()), + path('assets/', blocks.LibraryBlockAssetView.as_view()), + path('publish/', blocks.LibraryBlockPublishView.as_view()), # Future: discard changes for just this one block - # Future: set a block's tags (tags are stored in a Tag bundle and linked in) + ])), + # Containers are Sections, Subsections, and Units + path('containers//', include([ + # Get metadata about a specific container in this library, update or delete the container: + path('', containers.LibraryContainerView.as_view()), + # update components under container + path('children/', containers.LibraryContainerChildrenView.as_view()), + # Restore a soft-deleted container + path('restore/', containers.LibraryContainerRestore.as_view()), + # Update collections for a given container + path('collections/', containers.LibraryContainerCollectionsView.as_view(), name='update-collections-ct'), + # Publish a container (or reset to last published) + path('publish/', containers.LibraryContainerPublishView.as_view()), ])), re_path(r'^lti/1.3/', include([ - path('login/', views.LtiToolLoginView.as_view(), name='lti-login'), - path('launch/', views.LtiToolLaunchView.as_view(), name='lti-launch'), - path('pub/jwks/', views.LtiToolJwksView.as_view(), name='lti-pub-jwks'), + path('login/', libraries.LtiToolLoginView.as_view(), name='lti-login'), + path('launch/', libraries.LtiToolLaunchView.as_view(), name='lti-launch'), + path('pub/jwks/', libraries.LtiToolJwksView.as_view(), name='lti-pub-jwks'), ])), ])), path('library_assets/', include([ path( 'component_versions//', - views.LibraryComponentAssetView.as_view(), + blocks.LibraryComponentAssetView.as_view(), name='library-assets', ), path( 'blocks//', - views.LibraryComponentDraftAssetView.as_view(), + blocks.LibraryComponentDraftAssetView.as_view(), name='library-draft-assets', ), ]) diff --git a/openedx/core/djangoapps/content_staging/api.py b/openedx/core/djangoapps/content_staging/api.py index f0432922dc..7baae10bae 100644 --- a/openedx/core/djangoapps/content_staging/api.py +++ b/openedx/core/djangoapps/content_staging/api.py @@ -14,29 +14,36 @@ from opaque_keys.edx.keys import AssetKey, UsageKey from xblock.core import XBlock from openedx.core.lib.xblock_serializer.api import StaticFile, XBlockSerializer -from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none from xmodule import block_metadata_utils from xmodule.contentstore.content import StaticContent from xmodule.contentstore.django import contentstore from .data import ( CLIPBOARD_PURPOSE, - StagedContentData, StagedContentFileData, StagedContentStatus, UserClipboardData + StagedContentData, + StagedContentFileData, + StagedContentStatus, + UserClipboardData, ) from .models import ( UserClipboard as _UserClipboard, StagedContent as _StagedContent, StagedContentFile as _StagedContentFile, ) -from .serializers import UserClipboardSerializer as _UserClipboardSerializer +from .serializers import ( + UserClipboardSerializer as _UserClipboardSerializer, +) from .tasks import delete_expired_clipboards log = logging.getLogger(__name__) -def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int | None = None) -> UserClipboardData: +def _save_xblock_to_staged_content( + block: XBlock, user_id: int, purpose: str, version_num: int | None = None +) -> _StagedContent: """ - Copy an XBlock's OLX to the user's clipboard. + Generic function to save an XBlock's OLX to staged content. + Used by both clipboard and library sync functionality. """ block_data = XBlockSerializer( block, @@ -46,46 +53,41 @@ def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int expired_ids = [] with transaction.atomic(): - # Mark all of the user's existing StagedContent rows as EXPIRED - to_expire = _StagedContent.objects.filter( - user_id=user_id, - purpose=CLIPBOARD_PURPOSE, - ).exclude( - status=StagedContentStatus.EXPIRED, - ) - for sc in to_expire: - expired_ids.append(sc.id) - sc.status = StagedContentStatus.EXPIRED - sc.save() + if purpose == CLIPBOARD_PURPOSE: + # Mark all of the user's existing StagedContent rows as EXPIRED + to_expire = _StagedContent.objects.filter( + user_id=user_id, + purpose=purpose, + ).exclude( + status=StagedContentStatus.EXPIRED, + ) + for sc in to_expire: + expired_ids.append(sc.id) + sc.status = StagedContentStatus.EXPIRED + sc.save() + # Insert a new StagedContent row for this staged_content = _StagedContent.objects.create( user_id=user_id, - purpose=CLIPBOARD_PURPOSE, + purpose=purpose, status=StagedContentStatus.READY, block_type=usage_key.block_type, olx=block_data.olx_str, display_name=block_metadata_utils.display_name_with_default(block), suggested_url_name=usage_key.block_id, - tags=block_data.tags, + tags=block_data.tags or {}, version_num=(version_num or 0), ) - (clipboard, _created) = _UserClipboard.objects.update_or_create(user_id=user_id, defaults={ - "content": staged_content, - "source_usage_key": usage_key, - }) # Log an event so we can analyze how this feature is used: - log.info(f"Copied {usage_key.block_type} component \"{usage_key}\" to their clipboard.") + log.info(f'Saved {usage_key.block_type} component "{usage_key}" to staged content for {purpose}.') - # Try to copy the static files. If this fails, we still consider the overall copy attempt to have succeeded, - # because intra-course pasting will still work fine, and in any case users can manually resolve the file issue. + # Try to copy the static files. If this fails, we still consider the overall save attempt to have succeeded, + # because intra-course operations will still work fine, and users can manually resolve file issues. try: - _save_static_assets_to_user_clipboard(block_data.static_files, usage_key, staged_content) + _save_static_assets_to_staged_content(block_data.static_files, usage_key, staged_content) except Exception: # pylint: disable=broad-except - # Regardless of what happened, with get_asset_key_from_path or contentstore or run_filter, we don't want the - # whole "copy to clipboard" operation to fail, which would be a bad user experience. For copying and pasting - # within a single course, static assets don't even matter. So any such errors become warnings here. - log.exception(f"Unable to copy static files to clipboard for component {usage_key}") + log.exception(f"Unable to copy static files to staged content for component {usage_key}") # Enqueue a (potentially slow) task to delete the old staged content try: @@ -93,14 +95,15 @@ def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int except Exception: # pylint: disable=broad-except log.exception(f"Unable to enqueue cleanup task for StagedContents: {','.join(str(x) for x in expired_ids)}") - return _user_clipboard_model_to_data(clipboard) + return staged_content -def _save_static_assets_to_user_clipboard( +def _save_static_assets_to_staged_content( static_files: list[StaticFile], usage_key: UsageKey, staged_content: _StagedContent ): """ - Helper method for save_xblock_to_user_clipboard endpoint. This deals with copying static files into the clipboard. + Helper method for saving static files into staged content. + Used by both clipboard and library sync functionality. """ for f in static_files: source_key = ( @@ -109,17 +112,17 @@ def _save_static_assets_to_user_clipboard( ) # Compute the MD5 hash and get the content: content: bytes | None = f.data - md5_hash = "" # Unknown if content: - md5_hash = hashlib.md5(content).hexdigest() # This asset came from the XBlock's filesystem, e.g. a video block's transcript file source_key = usage_key # Check if the asset file exists. It can be absent if an XBlock contains an invalid link. elif source_key and (sc := contentstore().find(source_key, throw_on_not_found=False)): - md5_hash = sc.content_digest content = sc.data - else: + # Note that sc.content_digest has an md5_hash but it's sometimes NULL so we just compute it ourselves. + if not content: continue # Skip this file - we don't need a reference to a non-existent file. + # Compute the md5 hash + md5_hash = hashlib.md5(content).hexdigest() # Because we store clipboard files on S3, uploading really large files will be too slow. And it's wasted if # the copy-paste is just happening within a single course. So for files > 10MB, users must copy the files @@ -144,6 +147,37 @@ def _save_static_assets_to_user_clipboard( log.exception(f"Unable to copy static file {f.name} to clipboard for component {usage_key}") +def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int | None = None) -> UserClipboardData: + """ + Copy an XBlock's OLX to the user's clipboard. + """ + staged_content = _save_xblock_to_staged_content(block, user_id, CLIPBOARD_PURPOSE, version_num) + usage_key = block.usage_key + + # Create/update the clipboard entry + (clipboard, _created) = _UserClipboard.objects.update_or_create( + user_id=user_id, + defaults={ + "content": staged_content, + "source_usage_key": usage_key, + }, + ) + + return _user_clipboard_model_to_data(clipboard) + + +def stage_xblock_temporarily( + block: XBlock, user_id: int, purpose: str, version_num: int | None = None, +) -> _StagedContent: + """ + "Stage" an XBlock by copying it (and its associated children + static assets) + into the content staging area. This XBlock can then be accessed and manipulated + using any of the staged content APIs, before being deleted. + """ + staged_content = _save_xblock_to_staged_content(block, user_id, purpose, version_num) + return staged_content + + def get_user_clipboard(user_id: int, only_ready: bool = True) -> UserClipboardData | None: """ Get the details of the user's clipboard. @@ -190,28 +224,29 @@ def get_user_clipboard_json(user_id: int, request: HttpRequest | None = None): return serializer.data +def _staged_content_to_data(content: _StagedContent) -> StagedContentData: + """ + Convert a StagedContent model instance to an immutable data object. + """ + return StagedContentData( + id=content.id, + user_id=content.user_id, + created=content.created, + purpose=content.purpose, + status=content.status, + block_type=content.block_type, + display_name=content.display_name, + tags=content.tags or {}, + version_num=content.version_num, + ) + + def _user_clipboard_model_to_data(clipboard: _UserClipboard) -> UserClipboardData: """ Convert a UserClipboard model instance to an immutable data object. """ - content = clipboard.content - source_context_key = clipboard.source_usage_key.context_key - if source_context_key.is_course and (course_overview := get_course_overview_or_none(source_context_key)): - source_context_title = course_overview.display_name_with_default - else: - source_context_title = str(source_context_key) # Fall back to stringified context key as a title return UserClipboardData( - content=StagedContentData( - id=content.id, - user_id=content.user_id, - created=content.created, - purpose=content.purpose, - status=content.status, - block_type=content.block_type, - display_name=content.display_name, - tags=content.tags, - version_num=content.version_num, - ), + content=_staged_content_to_data(clipboard.content), source_usage_key=clipboard.source_usage_key, source_context_title=clipboard.get_source_context_title(), ) diff --git a/openedx/core/djangoapps/content_staging/data.py b/openedx/core/djangoapps/content_staging/data.py index d077d05a0a..d095f2506b 100644 --- a/openedx/core/djangoapps/content_staging/data.py +++ b/openedx/core/djangoapps/content_staging/data.py @@ -25,6 +25,10 @@ class StagedContentStatus(TextChoices): # Value of the "purpose" field on StagedContent objects used for clipboards. CLIPBOARD_PURPOSE = "clipboard" + +# Value of the "purpose" field on StagedContent objects used for library to course sync. +LIBRARY_SYNC_PURPOSE = "library_sync" + # There may be other valid values of "purpose" which aren't defined within this app. diff --git a/openedx/core/djangoapps/content_staging/models.py b/openedx/core/djangoapps/content_staging/models.py index 2eab7954e8..1fd02cb437 100644 --- a/openedx/core/djangoapps/content_staging/models.py +++ b/openedx/core/djangoapps/content_staging/models.py @@ -67,7 +67,9 @@ class StagedContent(models.Model): version_num = models.PositiveIntegerField(default=0) # Tags applied to the original source block(s) will be copied to the new block(s) on paste. - tags = models.JSONField(null=True, help_text=_("Content tags applied to these blocks")) + tags: models.JSONField[dict | None, dict | None] = models.JSONField( + null=True, help_text=_("Content tags applied to these blocks") + ) @property def olx_filename(self) -> str: @@ -129,7 +131,6 @@ class UserClipboard(models.Model): def clean(self): """ Check that this model is being used correctly. """ - # These could probably be replaced with constraints in Django 4.1+ if self.user.id != self.content.user.id: raise ValidationError("User ID mismatch.") if self.content.purpose != CLIPBOARD_PURPOSE: diff --git a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py index 551f94e90e..ab65d444ed 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/content_staging/views.py b/openedx/core/djangoapps/content_staging/views.py index 2a08ccfd38..f0a20540ba 100644 --- a/openedx/core/djangoapps/content_staging/views.py +++ b/openedx/core/djangoapps/content_staging/views.py @@ -81,6 +81,12 @@ class ClipboardEndpoint(APIView): def post(self, request): """ Put some piece of content into the user's clipboard. + + FIXME: This API needs to be deprecated and replaced by dedicated APIs + within each learning context (POST /course/foo/bar/copy, POST + /library/foo/bar/copy, etc.) We don't want to encode course- and + library-specific logic in content staging, and it shouldn't import + course or library modules. """ # Check if the content exists and the user has permission to read it. # Parse the usage key: diff --git a/openedx/core/djangoapps/content_tagging/api.py b/openedx/core/djangoapps/content_tagging/api.py index 8a06e483ab..0bd07a3db5 100644 --- a/openedx/core/djangoapps/content_tagging/api.py +++ b/openedx/core/djangoapps/content_tagging/api.py @@ -7,12 +7,11 @@ import io from itertools import groupby import csv from typing import Iterator -from opaque_keys.edx.keys import UsageKey +from opaque_keys.edx.keys import CourseKey, CollectionKey, ContainerKey, UsageKey import openedx_tagging.core.tagging.api as oel_tagging from django.db.models import Exists, OuterRef, Q, QuerySet from django.utils.timezone import now -from opaque_keys.edx.keys import CourseKey, LibraryCollectionKey from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy from openedx_tagging.core.tagging.models.utils import TAGS_CSV_SEPARATOR @@ -230,8 +229,8 @@ def generate_csv_rows(object_id, buffer) -> Iterator[str]: """ content_key = get_content_key_from_string(object_id) - if isinstance(content_key, (UsageKey, LibraryCollectionKey)): - raise ValueError("The object_id must be a CourseKey or a LibraryLocatorV2.") + if isinstance(content_key, (UsageKey, CollectionKey, ContainerKey)): + raise ValueError("The object_id must be a component, collection, or container.") all_object_tags, taxonomies = get_all_object_tags(content_key) tagged_content = build_object_tree_with_objecttags(content_key, all_object_tags) @@ -305,6 +304,8 @@ def set_exported_object_tags( taxonomy_export_id=str(taxonomy_export_id), ) + # .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED + # .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1 CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( time=now(), content_object=ContentObjectChangedData( @@ -314,6 +315,8 @@ def set_exported_object_tags( ) # Emit a (deprecated) CONTENT_OBJECT_TAGS_CHANGED event too + # .. event_implemented_name: CONTENT_OBJECT_TAGS_CHANGED + # .. event_type: org.openedx.content_authoring.content.object.tags.changed.v1 CONTENT_OBJECT_TAGS_CHANGED.send_event( time=now(), content_object=ContentObjectData(object_id=content_key_str) @@ -412,6 +415,8 @@ def tag_object( taxonomy=taxonomy, tags=tags, ) + # .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED + # .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1 CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( time=now(), content_object=ContentObjectChangedData( @@ -421,6 +426,8 @@ def tag_object( ) # Emit a (deprecated) CONTENT_OBJECT_TAGS_CHANGED event too + # .. event_implemented_name: CONTENT_OBJECT_TAGS_CHANGED + # .. event_type: org.openedx.content_authoring.content.object.tags.changed.v1 CONTENT_OBJECT_TAGS_CHANGED.send_event( time=now(), content_object=ContentObjectData(object_id=object_id) @@ -441,3 +448,4 @@ resync_object_tags = oel_tagging.resync_object_tags get_object_tags = oel_tagging.get_object_tags add_tag_to_taxonomy = oel_tagging.add_tag_to_taxonomy copy_tags_as_read_only = oel_tagging.copy_tags +make_copied_tags_editable = oel_tagging.unmark_copied_tags diff --git a/openedx/core/djangoapps/content_tagging/handlers.py b/openedx/core/djangoapps/content_tagging/handlers.py index cc86f7e0dc..86cbb7167c 100644 --- a/openedx/core/djangoapps/content_tagging/handlers.py +++ b/openedx/core/djangoapps/content_tagging/handlers.py @@ -20,7 +20,6 @@ from openedx_events.content_authoring.signals import ( XBLOCK_DUPLICATED, LIBRARY_BLOCK_CREATED, LIBRARY_BLOCK_UPDATED, - LIBRARY_BLOCK_DELETED, ) from .api import copy_object_tags @@ -30,7 +29,6 @@ from .tasks import ( update_course_tags, update_xblock_tags, update_library_block_tags, - delete_library_block_tags, ) from .toggles import CONTENT_TAGGING_AUTO @@ -119,22 +117,6 @@ def auto_tag_library_block(**kwargs): ) -@receiver(LIBRARY_BLOCK_DELETED) -def delete_tag_library_block(**kwargs): - """ - Delete tags associated with a Library XBlock whenever the block is deleted. - """ - library_block_data = kwargs.get("library_block", None) - if not library_block_data or not isinstance(library_block_data, LibraryBlockData): - log.error("Received null or incorrect data for event") - return - - try: - delete_library_block_tags(str(library_block_data.usage_key)) - except Exception as err: # pylint: disable=broad-except - log.error(f"Failed to delete library block tags: {err}") - - @receiver(XBLOCK_DUPLICATED) def duplicate_tags(**kwargs): """ diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py index 463ea42d08..6ba1049082 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py @@ -13,7 +13,8 @@ from urllib.parse import parse_qs, urlparse import ddt from django.contrib.auth import get_user_model from django.core.files.uploadedfile import SimpleUploadedFile -from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator, LibraryCollectionLocator +from edx_django_utils.cache import RequestCache +from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator, LibraryCollectionLocator, LibraryContainerLocator from openedx_tagging.core.tagging.models import Tag, Taxonomy from openedx_tagging.core.tagging.models.system_defined import SystemDefinedTaxonomy from openedx_tagging.core.tagging.rest_api.v1.serializers import TaxonomySerializer @@ -34,7 +35,6 @@ from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.content_libraries.api import AccessLevel, create_library, set_library_user_permissions from openedx.core.djangoapps.content_tagging import api as tagging_api from openedx.core.djangoapps.content_tagging.models import TaxonomyOrg -from openedx.core.djangoapps.content_tagging.utils import rules_cache from openedx.core.djangolib.testing.utils import skip_unless_cms from ....tests.test_objecttag_export_helpers import TaggedCourseMixin @@ -114,6 +114,9 @@ class TestTaxonomyObjectsMixin: def _setUp_collection(self): self.collection_key = str(LibraryCollectionLocator(self.content_libraryA.key, 'test-collection')) + def _setUp_container(self): + self.container_key = str(LibraryContainerLocator(self.content_libraryA.key, 'unit', 'unit1')) + def _setUp_users(self): """ Create users for testing @@ -288,9 +291,10 @@ class TestTaxonomyObjectsMixin: self._setUp_users() self._setUp_taxonomies() self._setUp_collection() + self._setUp_container() - # Clear the rules cache in between test runs to keep query counts consistent. - rules_cache.clear() + # Clear all request caches in between test runs to keep query counts consistent. + RequestCache.clear_all_namespaces() @skip_unless_cms @@ -510,12 +514,12 @@ class TestTaxonomyListCreateViewSet(TestTaxonomyObjectsMixin, APITestCase): @ddt.data( ('staff', 11), - ("content_creatorA", 16), - ("library_staffA", 16), - ("library_userA", 16), - ("instructorA", 16), - ("course_instructorA", 16), - ("course_staffA", 16), + ("content_creatorA", 17), + ("library_staffA", 17), + ("library_userA", 17), + ("instructorA", 17), + ("course_instructorA", 17), + ("course_staffA", 17), ) @ddt.unpack def test_list_taxonomy_query_count(self, user_attr: str, expected_queries: int): @@ -1701,6 +1705,50 @@ class TestObjectTagViewSet(TestObjectTagMixin, APITestCase): assert status.is_success(new_response.status_code) assert new_response.data == response.data + @ddt.data( + # staffA and staff are staff in collection and can tag using enabled taxonomies + ("user", "tA1", ["Tag 1"], status.HTTP_403_FORBIDDEN), + ("staffA", "tA1", ["Tag 1"], status.HTTP_200_OK), + ("staff", "tA1", ["Tag 1"], status.HTTP_200_OK), + ("user", "tA1", [], status.HTTP_403_FORBIDDEN), + ("staffA", "tA1", [], status.HTTP_200_OK), + ("staff", "tA1", [], status.HTTP_200_OK), + ("user", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_403_FORBIDDEN), + ("staffA", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), + ("staff", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), + ("user", "open_taxonomy", ["tag1"], status.HTTP_403_FORBIDDEN), + ("staffA", "open_taxonomy", ["tag1"], status.HTTP_200_OK), + ("staff", "open_taxonomy", ["tag1"], status.HTTP_200_OK), + ) + @ddt.unpack + def test_tag_container(self, user_attr, taxonomy_attr, tag_values, expected_status): + """ + Tests that only staff and org level users can tag containers + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + taxonomy = getattr(self, taxonomy_attr) + + response = self._call_put_request(self.container_key, taxonomy.pk, tag_values) + + assert response.status_code == expected_status + if status.is_success(expected_status): + tags_by_taxonomy = response.data[str(self.container_key)]["taxonomies"] + if tag_values: + response_taxonomy = tags_by_taxonomy[0] + assert response_taxonomy["name"] == taxonomy.name + response_tags = response_taxonomy["tags"] + assert [t["value"] for t in response_tags] == tag_values + else: + assert tags_by_taxonomy == [] # No tags are set from any taxonomy + + # Check that re-fetching the tags returns what we set + url = OBJECT_TAG_UPDATE_URL.format(object_id=self.container_key) + new_response = self.client.get(url, format="json") + assert status.is_success(new_response.status_code) + assert new_response.data == response.data + @ddt.data( "staffA", "staff", @@ -1879,16 +1927,16 @@ class TestObjectTagViewSet(TestObjectTagMixin, APITestCase): ('staff', 'courseA', 8), ('staff', 'libraryA', 8), ('staff', 'collection_key', 8), - ("content_creatorA", 'courseA', 11, False), - ("content_creatorA", 'libraryA', 11, False), - ("content_creatorA", 'collection_key', 11, False), - ("library_staffA", 'libraryA', 11, False), # Library users can only view objecttags, not change them? - ("library_staffA", 'collection_key', 11, False), - ("library_userA", 'libraryA', 11, False), - ("library_userA", 'collection_key', 11, False), - ("instructorA", 'courseA', 11), - ("course_instructorA", 'courseA', 11), - ("course_staffA", 'courseA', 11), + ("content_creatorA", 'courseA', 12, False), + ("content_creatorA", 'libraryA', 12, False), + ("content_creatorA", 'collection_key', 12, False), + ("library_staffA", 'libraryA', 12, False), # Library users can only view objecttags, not change them? + ("library_staffA", 'collection_key', 12, False), + ("library_userA", 'libraryA', 12, False), + ("library_userA", 'collection_key', 12, False), + ("instructorA", 'courseA', 12), + ("course_instructorA", 'courseA', 12), + ("course_staffA", 'courseA', 12), ) @ddt.unpack def test_object_tags_query_count( diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py index c2f79ef677..578b9a2c58 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py @@ -10,7 +10,6 @@ from openedx_tagging.core.tagging.rest_api.v1.views import ObjectTagView, Taxono from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied, ValidationError -from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView from openedx_events.content_authoring.data import ContentObjectData, ContentObjectChangedData @@ -19,6 +18,8 @@ from openedx_events.content_authoring.signals import ( CONTENT_OBJECT_TAGS_CHANGED, ) +from openedx.core.types.http import RestRequest + from ...auth import has_view_object_tags_access from ...api import ( create_taxonomy, @@ -99,7 +100,7 @@ class TaxonomyOrgView(TaxonomyView): serializer.instance = create_taxonomy(**serializer.validated_data, orgs=user_admin_orgs) @action(detail=False, url_path="import", methods=["post"]) - def create_import(self, request: Request, **kwargs) -> Response: # type: ignore + def create_import(self, request: RestRequest, **kwargs) -> Response: # type: ignore """ Creates a new taxonomy with the given orgs and imports the tags from the uploaded file. """ @@ -164,6 +165,8 @@ class ObjectTagOrgView(ObjectTagView): if response.status_code == 200: object_id = kwargs.get('object_id') + # .. event_implemented_name: CONTENT_OBJECT_ASSOCIATIONS_CHANGED + # .. event_type: org.openedx.content_authoring.content.object.associations.changed.v1 CONTENT_OBJECT_ASSOCIATIONS_CHANGED.send_event( content_object=ContentObjectChangedData( object_id=object_id, @@ -172,6 +175,8 @@ class ObjectTagOrgView(ObjectTagView): ) # Emit a (deprecated) CONTENT_OBJECT_TAGS_CHANGED event too + # .. event_implemented_name: CONTENT_OBJECT_TAGS_CHANGED + # .. event_type: org.openedx.content_authoring.content.object.tags.changed.v1 CONTENT_OBJECT_TAGS_CHANGED.send_event( content_object=ContentObjectData(object_id=object_id) ) @@ -183,7 +188,7 @@ class ObjectTagExportView(APIView): """" View to export a CSV with all children and tags for a given course/context. """ - def get(self, request: Request, **kwargs) -> StreamingHttpResponse: + def get(self, request: RestRequest, **kwargs) -> StreamingHttpResponse: """ Export a CSV with all children and tags for a given course/context. """ diff --git a/openedx/core/djangoapps/content_tagging/tests/test_api.py b/openedx/core/djangoapps/content_tagging/tests/test_api.py index b693f7ee0f..2661c02fd1 100644 --- a/openedx/core/djangoapps/content_tagging/tests/test_api.py +++ b/openedx/core/djangoapps/content_tagging/tests/test_api.py @@ -5,8 +5,8 @@ import tempfile import ddt from django.test.testcases import TestCase from fs.osfs import OSFS -from opaque_keys.edx.keys import CourseKey, UsageKey, LibraryCollectionKey -from opaque_keys.edx.locator import LibraryLocatorV2 +from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.locator import LibraryLocatorV2, LibraryCollectionLocator, LibraryContainerLocator from openedx_tagging.core.tagging.models import ObjectTag from organizations.models import Organization from .test_objecttag_export_helpers import TestGetAllObjectTagsMixin, TaggedCourseMixin @@ -381,7 +381,7 @@ class TestAPIObjectTags(TestGetAllObjectTagsMixin, TestCase): self._test_copy_object_tags(src_key, dst_key, expected_tags) def test_tag_collection(self): - collection_key = LibraryCollectionKey.from_string("lib-collection:orgA:libX:1") + collection_key = LibraryCollectionLocator.from_string("lib-collection:orgA:libX:1") api.tag_object( object_id=str(collection_key), @@ -397,6 +397,23 @@ class TestAPIObjectTags(TestGetAllObjectTagsMixin, TestCase): self.taxonomy_3.id: self.taxonomy_3, } + def test_tag_container(self): + unit_key = LibraryContainerLocator.from_string('lct:orgA:libX:unit:unit1') + + api.tag_object( + object_id=str(unit_key), + taxonomy=self.taxonomy_3, + tags=["Tag 3.1"], + ) + + with self.assertNumQueries(1): + object_tags, taxonomies = api.get_all_object_tags(unit_key) + + assert object_tags == {'lct:orgA:libX:unit:unit1': {3: ['Tag 3.1']}} + assert taxonomies == { + self.taxonomy_3.id: self.taxonomy_3, + } + class TestExportImportTags(TaggedCourseMixin): """ diff --git a/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py b/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py index f196549dd1..eaeea3bda2 100644 --- a/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py +++ b/openedx/core/djangoapps/content_tagging/tests/test_objecttag_export_helpers.py @@ -442,7 +442,7 @@ class TestContentTagChildrenExport(TaggedCourseMixin): # type: ignore[misc] """ Test if we can export a library """ - with self.assertNumQueries(8): + with self.assertNumQueries(11): tagged_library = build_object_tree_with_objecttags(self.library.key, self.all_library_object_tags) assert tagged_library == self.expected_library_tagged_xblock diff --git a/openedx/core/djangoapps/content_tagging/tests/test_tasks.py b/openedx/core/djangoapps/content_tagging/tests/test_tasks.py index c14adfcce1..d0e10ecfb7 100644 --- a/openedx/core/djangoapps/content_tagging/tests/test_tasks.py +++ b/openedx/core/djangoapps/content_tagging/tests/test_tasks.py @@ -14,7 +14,9 @@ from organizations.models import Organization from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangolib.testing.utils import skip_unless_cms from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase -from openedx.core.djangoapps.content_libraries.api import create_library, create_library_block, delete_library_block +from openedx.core.djangoapps.content_libraries.api import ( + create_library, create_library_block, delete_library_block, restore_library_block +) from .. import api from ..models.base import TaxonomyOrg @@ -267,7 +269,7 @@ class TestAutoTagging( # type: ignore[misc] # Still no tags assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, None) - def test_create_delete_library_block(self): + def test_create_delete_restore_library_block(self): # Create library library = create_library( org=self.orgA, @@ -287,11 +289,17 @@ class TestAutoTagging( # type: ignore[misc] # Check if the tags are created in the Library Block with the user's preferred language assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, 'Português (Brasil)') - # Delete the XBlock + # Soft delete the XBlock delete_library_block(library_block.usage_key) - # Check if the tags are deleted - assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, None) + # Check that the tags are not deleted + assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, 'Português (Brasil)') + + # Restore the XBlock + restore_library_block(library_block.usage_key) + + # Check if the tags are still present in the Library Block with the user's preferred language + assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, 'Português (Brasil)') @override_waffle_flag(CONTENT_TAGGING_AUTO, active=False) def test_waffle_disabled_create_delete_library_block(self): @@ -319,3 +327,10 @@ class TestAutoTagging( # type: ignore[misc] # Still no tags assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, None) + + # Restore the XBlock + with patch('crum.get_current_request', return_value=fake_request): + restore_library_block(library_block.usage_key) + + # Still no tags + assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, None) diff --git a/openedx/core/djangoapps/content_tagging/types.py b/openedx/core/djangoapps/content_tagging/types.py index 9ffb090d61..6e54caa051 100644 --- a/openedx/core/djangoapps/content_tagging/types.py +++ b/openedx/core/djangoapps/content_tagging/types.py @@ -5,11 +5,11 @@ from __future__ import annotations from typing import Dict, List, Union -from opaque_keys.edx.keys import CourseKey, UsageKey, LibraryCollectionKey +from opaque_keys.edx.keys import CourseKey, UsageKey, CollectionKey, ContainerKey from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_tagging.core.tagging.models import Taxonomy -ContentKey = Union[LibraryLocatorV2, CourseKey, UsageKey, LibraryCollectionKey] +ContentKey = Union[LibraryLocatorV2, CourseKey, UsageKey, CollectionKey, ContainerKey] ContextKey = Union[LibraryLocatorV2, CourseKey] TagValuesByTaxonomyIdDict = Dict[int, List[str]] diff --git a/openedx/core/djangoapps/content_tagging/utils.py b/openedx/core/djangoapps/content_tagging/utils.py index 39dd925c1a..f6e4883251 100644 --- a/openedx/core/djangoapps/content_tagging/utils.py +++ b/openedx/core/djangoapps/content_tagging/utils.py @@ -5,7 +5,7 @@ from __future__ import annotations from edx_django_utils.cache import RequestCache from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import CourseKey, UsageKey, LibraryCollectionKey +from opaque_keys.edx.keys import CourseKey, CollectionKey, ContainerKey, UsageKey from opaque_keys.edx.locator import LibraryLocatorV2 from openedx_tagging.core.tagging.models import Taxonomy from organizations.models import Organization @@ -18,22 +18,16 @@ def get_content_key_from_string(key_str: str) -> ContentKey: """ Get content key from string """ - try: - return CourseKey.from_string(key_str) - except InvalidKeyError: + for key_type in (LibraryLocatorV2, CourseKey, UsageKey, ContainerKey, CollectionKey): try: - return LibraryLocatorV2.from_string(key_str) + return key_type.from_string(key_str) except InvalidKeyError: - try: - return UsageKey.from_string(key_str) - except InvalidKeyError: - try: - return LibraryCollectionKey.from_string(key_str) - except InvalidKeyError as usage_key_error: - raise ValueError( - "object_id must be one of the following " - "keys: CourseKey, LibraryLocatorV2, UsageKey or LibCollectionKey" - ) from usage_key_error + continue + raise ValueError( + "For tagging, object_id must be one of the following " + "key types: LibraryLocatorV2, CourseKey, UsageKey, ContainerKey, CollectionKey. " + f"got: {key_str}" + ) def get_context_key_from_key(content_key: ContentKey) -> ContextKey: @@ -41,20 +35,11 @@ def get_context_key_from_key(content_key: ContentKey) -> ContextKey: Returns the context key from a given content key. """ # If the content key is a CourseKey or a LibraryLocatorV2, return it - if isinstance(content_key, (CourseKey, LibraryLocatorV2)): + if isinstance(content_key, (LibraryLocatorV2, CourseKey)): return content_key - - # If the content key is a LibraryCollectionKey, return the LibraryLocatorV2 - if isinstance(content_key, LibraryCollectionKey): - return content_key.library_key - - # If the content key is a UsageKey, return the context key - context_key = content_key.context_key - - if isinstance(context_key, (CourseKey, LibraryLocatorV2)): - return context_key - - raise ValueError("context must be a CourseKey or a LibraryLocatorV2") + else: + assert isinstance(content_key.context_key, (CourseKey, LibraryLocatorV2)) # for type checker + return content_key.context_key def get_context_key_from_key_string(key_str: str) -> ContextKey: diff --git a/openedx/core/djangoapps/contentserver/test/test_contentserver.py b/openedx/core/djangoapps/contentserver/test/test_contentserver.py index 4c0180c402..df29b64d27 100644 --- a/openedx/core/djangoapps/contentserver/test/test_contentserver.py +++ b/openedx/core/djangoapps/contentserver/test/test_contentserver.py @@ -102,6 +102,11 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase): cls.url_unlocked_versioned_old_style = get_old_style_versioned_asset_url(cls.url_unlocked) cls.length_unlocked = cls.contentstore.get_attr(cls.unlocked_asset, 'length') + # Special case: python_lib.zip + cls.pylib_asset = cls.course_key.make_asset_key('asset', 'python_lib.zip') + cls.url_pylib = '/' + str(cls.pylib_asset) + cls.contentstore.set_attr(cls.pylib_asset, 'locked', False) + def setUp(self): """ Create user and login. @@ -208,6 +213,83 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase): resp = self.client.get(self.url_locked) assert resp.status_code == 200 + def test_python_lib_zip_staff(self): + """ + Test that staff can download python_lib.zip. + """ + self.client.login(username=self.staff_usr, password=self.TEST_PASSWORD) + resp = self.client.get(self.url_pylib) + assert resp.status_code == 200 + assert resp['Cache-Control'] == 'private, no-cache, no-store' + + def test_python_lib_zip_not_staff(self): + """ + Test that python_lib.zip cannot be downloaded by non-staff by default. + """ + self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD) + resp = self.client.get(self.url_pylib) + assert resp.status_code == 403 + # We should be sending a no-cache header, but the contentserver + # currently doesn't set caching headers for "unauthorized" responses. So + # this test allows either in order to make the transition easier if we + # fix that. + assert 'Cache-Control' not in resp or resp['Cache-Control'] == 'private, no-cache, no-store' + + @patch( + 'openedx.core.djangoapps.contentserver.views.COURSE_CODE_LIBRARY_DOWNLOAD_ALLOWED.is_enabled', + return_value=True, + ) + def test_python_lib_zip_not_staff_but_course_allows_it(self, mock_download_allowed_flag): + """ + Test that python_lib.zip can be downloaded by non-staff when flag enabled. + """ + self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD) + resp = self.client.get(self.url_pylib) + assert resp.status_code == 200 + assert resp['Cache-Control'] == 'private, no-cache, no-store' + + mock_download_allowed_flag.assert_called_once_with(self.course_key) + + @patch( + 'openedx.core.djangoapps.contentserver.views.COURSE_CODE_LIBRARY_DOWNLOAD_ALLOWED.is_enabled', + return_value=True, + ) + @patch( + 'openedx.core.djangoapps.contentserver.views.is_content_locked', + return_value=True, + ) + def test_python_lib_zip_can_be_locked(self, mock_is_locked, mock_download_allowed_flag): + """ + Even when python_lib.zip download is broadly allowed, it can be locked. + """ + self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD) + resp = self.client.get(self.url_pylib) + assert resp.status_code == 403 + assert 'Cache-Control' not in resp or resp['Cache-Control'] == 'private, no-cache, no-store' + + assert mock_is_locked.call_count == 2 # for auth check, then caching check + mock_download_allowed_flag.assert_called_once_with(self.course_key) + + @ddt.data(True, False) + def test_python_lib_zip_uses_studio_read_check(self, allow): + """ + Specifically check that python_lib.zip is gated on studio read access. + + Ideally this test would actually check access for a course team member + who is *not* site staff/superuser, but that would require more + complicated setup. + """ + self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD) + with patch('openedx.core.djangoapps.contentserver.views.has_studio_read_access', return_value=allow): + resp = self.client.get(self.url_pylib) + + if allow: + assert resp.status_code == 200 + assert resp['Cache-Control'] == 'private, no-cache, no-store' + else: + assert resp.status_code == 403 + assert 'Cache-Control' not in resp or resp['Cache-Control'] == 'private, no-cache, no-store' + def test_range_request_full_file(self): """ Test that a range request from byte 0 to last, diff --git a/openedx/core/djangoapps/contentserver/views.py b/openedx/core/djangoapps/contentserver/views.py index 3a267f0852..cbdb4124fe 100644 --- a/openedx/core/djangoapps/contentserver/views.py +++ b/openedx/core/djangoapps/contentserver/views.py @@ -21,13 +21,16 @@ from edx_django_utils.monitoring import set_custom_attribute from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import AssetLocator +from common.djangoapps.student.auth import has_studio_read_access from common.djangoapps.student.models import CourseEnrollment from openedx.core.djangoapps.header_control import force_header_for_response +from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag from xmodule.assetstore.assetmgr import AssetManager from xmodule.contentstore.content import XASSET_LOCATION_TAG, StaticContent from xmodule.exceptions import NotFoundError from xmodule.modulestore import InvalidLocationError from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.util.sandboxing import course_code_library_asset_name from .caching import get_cached_content, set_cached_content from .models import CdnUserAgentsConfig, CourseAssetCacheTtlConfig @@ -197,17 +200,21 @@ def process_request(request): # middleware we have in place, there's no easy way to use the built-in Django # utilities and properly sanitize and modify a response to ensure that it is as # cacheable as possible, which is why we do it ourselves. - set_caching_headers(content, response) + set_caching_headers(content, loc, response) return response -def set_caching_headers(content, response): +def set_caching_headers(content, location, response): """ - Sets caching headers based on whether or not the asset is locked. + Sets caching headers based on whether or not the asset is restricted. """ - is_locked = getattr(content, "locked", False) + is_pylib = location.path == course_code_library_asset_name() + + # All classes of asset that have any kind of access control should be marked + # as non-cacheable. + is_restricted = is_locked or is_pylib # We want to signal to the end user's browser, and to any intermediate proxies/caches, # whether or not this asset is cacheable. If we have a TTL configured, we inform the @@ -215,12 +222,12 @@ def set_caching_headers(content, response): # assets should be restricted to enrolled students, we simply send headers that # indicate there should be no caching whatsoever. cache_ttl = CourseAssetCacheTtlConfig.get_cache_ttl() - if cache_ttl > 0 and not is_locked: + if cache_ttl > 0 and not is_restricted: set_custom_attribute('contentserver.cacheable', True) response['Expires'] = get_expiration_value(datetime.datetime.utcnow(), cache_ttl) response['Cache-Control'] = "public, max-age={ttl}, s-maxage={ttl}".format(ttl=cache_ttl) - elif is_locked: + elif is_restricted: set_custom_attribute('contentserver.cacheable', False) response['Cache-Control'] = "private, no-cache, no-store" @@ -264,10 +271,43 @@ def is_content_locked(content): return bool(getattr(content, "locked", False)) +# .. toggle_name: course_assets.allow_download_code_library +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Whether to allow learners to download the course code library +# that is used for custom Python-graded problem blocks. (This is conventionally +# ``python_lib.zip``, but configurable with Django setting ``PYTHON_LIB_FILENAME``). +# This file may contain custom grading code or problem answers that should not be +# revealed to learners. +# .. toggle_warning: This flag is only intended as a temporary override for use +# in rollout, to be removed before Ulmo. Courses that rely on learners being able +# to download the code library should find an alternative workflow, or the toggle +# should be re-documented as permanent. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2025-05-01 +# .. toggle_target_removal_date: 2025-10-01 +COURSE_CODE_LIBRARY_DOWNLOAD_ALLOWED = CourseWaffleFlag( + 'course_assets.allow_download_code_library', module_name=__name__, +) + + def is_user_authorized(request, content, location): """ Determines whether or not the user for this request is authorized to view the given asset. + + Any asset classes that have restrictions placed on them should also + be marked as no-cache in `set_caching_headers`. """ + # Special-case python_lib.zip, since it often contains grading code that + # shouldn't be revealed to learners. + if location.path == course_code_library_asset_name(): + if has_studio_read_access(request.user, location.course_key) or \ + COURSE_CODE_LIBRARY_DOWNLOAD_ALLOWED.is_enabled(location.course_key): + # Fall through to other access checks + pass + else: + return False + if not is_content_locked(content): return True diff --git a/openedx/core/djangoapps/cookie_metadata/middleware.py b/openedx/core/djangoapps/cookie_metadata/middleware.py deleted file mode 100644 index f54839da8f..0000000000 --- a/openedx/core/djangoapps/cookie_metadata/middleware.py +++ /dev/null @@ -1,98 +0,0 @@ -"""Middleware to change name of an incoming cookie""" -from django.conf import settings - -from edx_django_utils.monitoring import set_custom_attribute - - -class CookieNameChange: - """Changes name of an incoming cookie""" - - def __init__(self, get_response): - self.get_response = get_response - - def __call__(self, request): - """ - Changes the names of a cookie in request.COOKIES - - For this middleware to run: - - set COOKIE_NAME_CHANGE_ACTIVATE = True - - COOKIE_NAME_CHANGE_EXPAND_INFO is a dict and has following info: - - "current": Cookie name that will be used by relying code - - "alternate": Other cookie name, to be renamed to current name if present - - Actions taken by middleware, during request phase: - - Delete alternate-name cookie from request.COOKIES - - Preserve any cookie with current name, or create one with value of - cookie with alternate name (if alt cookie was present) - - To perform a seamless name change for a cookie, follow this - expand-contract procedure: - - 1. Baseline configuration:: - - SOME_COOKIE_NAME: old - - 2. Enable servers to understand both names by renaming the *new* name - to the *old* (current) name, which should have no immediate effect:: - - COOKIE_NAME_CHANGE_ACTIVATE: True - COOKIE_NAME_CHANGE_EXPAND_INFO: - current: old - alternate: new - SOME_COOKIE_NAME: old - - 3. Swap the new and old cookie names in all three places they occur (the - main setting and the two dictionary elements), now that all servers - are capable of reading either name:: - - COOKIE_NAME_CHANGE_ACTIVATE: True - COOKIE_NAME_CHANGE_EXPAND_INFO: - current: new - alternate: old - SOME_COOKIE_NAME: new - - 4. After some time period to allow old cookies to age out, remove the - transition settings:: - - SOME_COOKIE_NAME: new - """ - - # .. toggle_name: COOKIE_NAME_CHANGE_ACTIVATE - # .. toggle_implementation: DjangoSetting - # .. toggle_default: False - # .. toggle_description: Used to enable CookieNameChange middleware which changes a cookie name in request.COOKIES - # .. toggle_warning: This should be set at the same time you set COOKIE_NAME_CHANGE_EXPAND_INFO setting - # .. toggle_use_cases: temporary - # .. toggle_creation_date: 2021-08-04 - # .. toggle_target_removal_date: 2021-10-01 - # .. toggle_tickets: https://openedx.atlassian.net/browse/ARCHBOM-1872 - if getattr(settings, "COOKIE_NAME_CHANGE_ACTIVATE", False): - alt_cookie_in_request = False - expand_settings = getattr(settings, "COOKIE_NAME_CHANGE_EXPAND_INFO", None) - - if ( - expand_settings is not None - and isinstance(expand_settings, dict) - and "current" in expand_settings - and "alternate" in expand_settings - ): - if expand_settings["alternate"] in request.COOKIES: - alt_cookie_in_request = True - alt_cookie_value = request.COOKIES[expand_settings["alternate"]] - del request.COOKIES[expand_settings["alternate"]] - # Adding custom attribute: cookie.change_name - # if cookie.change_name in transaction and equal 0, - # cookie with alternate name was detected and deleted - # if cookie.change_name in transaction and equal 1, - # cookie with current name was added - set_custom_attribute("cookie.change_name", 0) - - if ( - expand_settings["current"] not in request.COOKIES - and alt_cookie_in_request - ): - request.COOKIES[expand_settings["current"]] = alt_cookie_value - set_custom_attribute("cookie.change_name", 1) - - response = self.get_response(request) - return response diff --git a/openedx/core/djangoapps/cookie_metadata/tests/test_cookie_name_change.py b/openedx/core/djangoapps/cookie_metadata/tests/test_cookie_name_change.py deleted file mode 100644 index be877a8823..0000000000 --- a/openedx/core/djangoapps/cookie_metadata/tests/test_cookie_name_change.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -Test Module to test CookieNameChange class -""" -from unittest.mock import Mock - -from django.test import TestCase - - -from ..middleware import CookieNameChange - - -class TestCookieNameChange(TestCase): - """ - Test class for CookieNameChange - """ - - def setUp(self): - super().setUp() - self.mock_response = Mock() - self.cookie_name_change_middleware = CookieNameChange(self.mock_response) - self.mock_request = Mock() - - self.old_value = "." * 100 - self.old_key = 'a' - self.extra_cookies = { - "_b": "." * 13, - "_c_": "." * 13, - "a.b": "." * 10, - } - self.old_dict = { - self.old_key: self.old_value, - } - - self.expand_settings = { - "alternate": self.old_key, - "current": "b", - } - - def test_cookie_swap(self): - """Check to make sure self.Middleware correctly swaps keys""" - - self.old_dict.update(self.extra_cookies) - - self.mock_request.COOKIES = self.old_dict.copy() - - with self.settings( - COOKIE_NAME_CHANGE_ACTIVATE=True - ), self.settings(COOKIE_NAME_CHANGE_EXPAND_INFO=self.expand_settings): - self.cookie_name_change_middleware(self.mock_request) - - assert self.expand_settings["alternate"] not in self.mock_request.COOKIES.keys() - assert self.expand_settings["current"] in self.mock_request.COOKIES.keys() - assert self.mock_request.COOKIES[self.expand_settings["current"]] == self.old_value - test_dict = self.extra_cookies.copy() - test_dict[self.expand_settings['current']] = self.old_value - assert self.mock_request.COOKIES == test_dict - - # make sure response function is called once - self.mock_response.assert_called_once() - - def test_cookie_no_swap(self): - """Make sure self.cookie_name_change_middleware does not change cookie if current cookie is already present""" - - new_value = "." * 13 - no_change_cookies = { - self.expand_settings['current']: new_value, - "_c_": "." * 13, - "a.b": "." * 10, - } - - self.old_dict.update(no_change_cookies) - - self.mock_request.COOKIES = self.old_dict.copy() - - with self.settings( - COOKIE_NAME_CHANGE_ACTIVATE=True - ), self.settings(COOKIE_NAME_CHANGE_EXPAND_INFO=self.expand_settings): - self.cookie_name_change_middleware(self.mock_request) - - assert self.expand_settings["alternate"] not in self.mock_request.COOKIES.keys() - assert self.expand_settings["current"] in self.mock_request.COOKIES.keys() - assert self.mock_request.COOKIES[self.expand_settings["current"]] == new_value - assert self.mock_request.COOKIES == no_change_cookies - - # make sure response function is called once - self.mock_response.assert_called_once() - - def test_does_nothing(self): - """Make sure turning off toggle turns off self.cookie_name_change_middleware""" - - new_value = "." * 13 - no_change_cookies = { - self.expand_settings['current']: new_value, - "_c_": "." * 13, - "a.b": "." * 10, - } - self.old_dict.update(no_change_cookies) - - self.mock_request.COOKIES = self.old_dict.copy() - - with self.settings( - COOKIE_NAME_CHANGE_ACTIVATE=False - ), self.settings(COOKIE_NAME_CHANGE_EXPAND_INFO=self.expand_settings): - self.cookie_name_change_middleware(self.mock_request) - - assert self.mock_request.COOKIES == self.old_dict - - # make sure response function is called once - self.mock_response.assert_called_once() diff --git a/openedx/core/djangoapps/course_groups/models.py b/openedx/core/djangoapps/course_groups/models.py index 0b731cc6b6..e9b6896e43 100644 --- a/openedx/core/djangoapps/course_groups/models.py +++ b/openedx/core/djangoapps/course_groups/models.py @@ -163,6 +163,7 @@ class CohortMembership(models.Model): self.full_clean(validate_unique=False) # .. event_implemented_name: COHORT_MEMBERSHIP_CHANGED + # .. event_type: org.openedx.learning.cohort_membership.changed.v1 COHORT_MEMBERSHIP_CHANGED.send_event( cohort=CohortData( user=UserData( diff --git a/openedx/core/djangoapps/course_groups/tests/test_cohorts.py b/openedx/core/djangoapps/course_groups/tests/test_cohorts.py index 0f6dd85863..910eff2b0d 100644 --- a/openedx/core/djangoapps/course_groups/tests/test_cohorts.py +++ b/openedx/core/djangoapps/course_groups/tests/test_cohorts.py @@ -44,6 +44,12 @@ class TestCohortSignals(TestCase, OpenEdxEventsTestMixin): super().setUpClass() cls.start_events_isolation() + @classmethod + def tearDownClass(cls): + """ Don't let our event isolation affect other test cases """ + super().tearDownClass() + cls.enable_all_events() # Re-enable events other than the ENABLED_OPENEDX_EVENTS subset we isolated. + def setUp(self): super().setUp() self.course_key = CourseLocator("dummy", "dummy", "dummy") diff --git a/openedx/core/djangoapps/course_groups/tests/test_events.py b/openedx/core/djangoapps/course_groups/tests/test_events.py index 11ec0e3652..616a7bb3f1 100644 --- a/openedx/core/djangoapps/course_groups/tests/test_events.py +++ b/openedx/core/djangoapps/course_groups/tests/test_events.py @@ -46,6 +46,12 @@ class CohortEventTest(SharedModuleStoreTestCase, OpenEdxEventsTestMixin): super().setUpClass() cls.start_events_isolation() + @classmethod + def tearDownClass(cls): + """ Don't let our event isolation affect other test cases """ + super().tearDownClass() + cls.enable_all_events() # Re-enable events other than the ENABLED_OPENEDX_EVENTS subset we isolated. + def setUp(self): # pylint: disable=arguments-differ super().setUp() self.course = CourseOverviewFactory() diff --git a/openedx/core/djangoapps/courseware_api/serializers.py b/openedx/core/djangoapps/courseware_api/serializers.py index 47b977532f..191d832c5f 100644 --- a/openedx/core/djangoapps/courseware_api/serializers.py +++ b/openedx/core/djangoapps/courseware_api/serializers.py @@ -80,7 +80,6 @@ class CourseInfoSerializer(serializers.Serializer): # pylint: disable=abstract- """ access_expiration = serializers.DictField() - can_show_upgrade_sock = serializers.BooleanField() content_type_gating_enabled = serializers.BooleanField() course_goals = CourseGoalsSerializer() effort = serializers.CharField() diff --git a/openedx/core/djangoapps/courseware_api/views.py b/openedx/core/djangoapps/courseware_api/views.py index 07e219f83d..6b5e12257b 100644 --- a/openedx/core/djangoapps/courseware_api/views.py +++ b/openedx/core/djangoapps/courseware_api/views.py @@ -55,7 +55,6 @@ from openedx.core.djangoapps.programs.utils import ProgramProgressMeter from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from openedx.core.lib.courses import get_course_by_id -from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG from openedx.features.course_experience import ENABLE_COURSE_GOALS from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.course_duration_limits.access import get_access_expiration_data @@ -127,10 +126,6 @@ class CoursewareMeta: course_key=self.course_key, ) - @property - def can_show_upgrade_sock(self): - return DISPLAY_COURSE_SOCK_FLAG.is_enabled(self.course_key) - @property def license(self): return self.course.license @@ -610,25 +605,28 @@ class SequenceMetadata(DeveloperErrorViewMixin, APIView): ) if is_preview and staff_access else ( ModuleStoreEnum.Branch.published_only ) + _modulestore = modulestore() + with _modulestore.branch_setting(branch_type, usage_key.course_key): + with _modulestore.bulk_operations(usage_key.course_key): + sequence, _ = get_block_by_usage_id( + self.request, + str(usage_key.course_key), + str(usage_key), + disable_staff_debug_info=True, + will_recheck_access=True) - with modulestore().branch_setting(branch_type, usage_key.course_key): - sequence, _ = get_block_by_usage_id( - self.request, - str(usage_key.course_key), - str(usage_key), - disable_staff_debug_info=True, - will_recheck_access=True) + if not hasattr(sequence, 'get_metadata'): + # Looks like we were asked for metadata on something that is not a sequence (or section). + return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY) - if not hasattr(sequence, 'get_metadata'): - # Looks like we were asked for metadata on something that is not a sequence (or section). - return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY) + view = STUDENT_VIEW + if request.user.is_anonymous: + view = PUBLIC_VIEW - view = STUDENT_VIEW - if request.user.is_anonymous: - view = PUBLIC_VIEW - - context = {'specific_masquerade': is_masquerading_as_specific_student(request.user, usage_key.course_key)} - return Response(sequence.get_metadata(view=view, context=context)) + context = { + 'specific_masquerade': is_masquerading_as_specific_student(request.user, usage_key.course_key) + } + return Response(sequence.get_metadata(view=view, context=context)) class Resume(DeveloperErrorViewMixin, APIView): diff --git a/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py b/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py index cbfdd8e733..7d28ca5197 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 @@ rolling out for the first time. 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 @@ -39,11 +41,11 @@ class Command(BaseCommand): Example usage: # Process all certs/grades changes for a given course: - $ ./manage.py lms --settings=devstack_docker notify_credentials \ + $ ./manage.py lms --settings=devstack notify_credentials \ --courses course-v1:edX+DemoX+Demo_Course # Process all certs/grades changes in a given time range: - $ ./manage.py lms --settings=devstack_docker notify_credentials \ + $ ./manage.py lms --settings=devstack notify_credentials \ --start-date 2018-06-01 --end-date 2018-07-31 A Dry Run will produce output that looks like: @@ -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 @@ class Command(BaseCommand): 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 e50173001f..f7bed3446a 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 unittest import mock 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 @@ class TestNotifyCredentials(TestCase): @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 @@ class TestNotifyCredentials(TestCase): 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/models.py b/openedx/core/djangoapps/credit/models.py index 2a9fa20885..9c14a15104 100644 --- a/openedx/core/djangoapps/credit/models.py +++ b/openedx/core/djangoapps/credit/models.py @@ -514,7 +514,6 @@ class CreditRequirementStatus(TimeStampedModel): ) ) log.error(log_msg) - return @classmethod def retire_user(cls, retirement): diff --git a/openedx/core/djangoapps/credit/tasks.py b/openedx/core/djangoapps/credit/tasks.py index 312e278a98..79ef613e3d 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/discussions/config/waffle.py b/openedx/core/djangoapps/discussions/config/waffle.py index 1d4c67e9e1..eca6fc9708 100644 --- a/openedx/core/djangoapps/discussions/config/waffle.py +++ b/openedx/core/djangoapps/discussions/config/waffle.py @@ -2,6 +2,8 @@ This module contains various configuration settings via waffle switches for the discussions app. """ +from django.conf import settings + from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag WAFFLE_FLAG_NAMESPACE = "discussions" @@ -43,3 +45,31 @@ ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND = CourseWaffleFlag( ENABLE_NEW_STRUCTURE_DISCUSSIONS = CourseWaffleFlag( f"{WAFFLE_FLAG_NAMESPACE}.enable_new_structure_discussions", __name__ ) + +# .. toggle_name: discussions.enable_forum_v2 +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to use the forum v2 instead of v1(cs_comment_service) +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2024-9-26 +# .. toggle_target_removal_date: 2025-12-05 +ENABLE_FORUM_V2 = CourseWaffleFlag(f"{WAFFLE_FLAG_NAMESPACE}.enable_forum_v2", __name__) + + +def is_forum_v2_enabled(course_key): + """ + Returns whether forum V2 is enabled on the course. This is a 2-step check: + + 1. Check value of settings.DISABLE_FORUM_V2: if it exists and is true, this setting overrides any course flag. + 2. Else, check the value of the corresponding course waffle flag. + """ + if is_forum_v2_disabled_globally(): + return False + return ENABLE_FORUM_V2.is_enabled(course_key) + + +def is_forum_v2_disabled_globally() -> bool: + """ + Return True if DISABLE_FORUM_V2 is defined and true-ish. + """ + return getattr(settings, "DISABLE_FORUM_V2", False) diff --git a/openedx/core/djangoapps/discussions/serializers.py b/openedx/core/djangoapps/discussions/serializers.py index 88648a4995..5e042575a2 100644 --- a/openedx/core/djangoapps/discussions/serializers.py +++ b/openedx/core/djangoapps/discussions/serializers.py @@ -354,6 +354,14 @@ class DiscussionsConfigurationSerializer(serializers.ModelSerializer): key not in LegacySettingsSerializer.Meta.fields_cohorts ) } + # Toggle discussion tab is_hidden. Before Palm, we would mark the discussion tab with the is_hidden property. + # Redwood and later, we disable discussions entirely by toggling the discussion configuration enabled property. + # This ensures pre-Palm courses import with discussions tab appropriately shown/hidden. + for tab in course.tabs: + if tab.tab_id == 'discussion' and tab.is_hidden == validated_data.get('enabled'): + tab.is_hidden = not validated_data.get('enabled') + save = True + break if save: modulestore().update_item(course, self.context['user_id']) return instance diff --git a/openedx/core/djangoapps/discussions/tasks.py b/openedx/core/djangoapps/discussions/tasks.py index fea20dc59b..afb43b32cd 100644 --- a/openedx/core/djangoapps/discussions/tasks.py +++ b/openedx/core/djangoapps/discussions/tasks.py @@ -31,6 +31,8 @@ def update_discussions_settings_from_course_task(course_key_str: str, discussabl """ course_key = CourseKey.from_string(course_key_str) config_data = update_discussions_settings_from_course(course_key, discussable_units) + # .. event_implemented_name: COURSE_DISCUSSIONS_CHANGED + # .. event_type: org.openedx.learning.discussions.configuration.changed.v1 COURSE_DISCUSSIONS_CHANGED.send_event(configuration=config_data) @@ -181,7 +183,12 @@ def is_discussable_unit(unit, store, enable_graded_units, subsection): return True -def update_unit_discussion_state_from_discussion_blocks(course_key: CourseKey, user_id: int, force=False) -> None: +def update_unit_discussion_state_from_discussion_blocks( + course_key: CourseKey, + user_id: int, + force=False, + async_topics=True +) -> None: """ Migrate existing courses to the new mechanism for linking discussion to units. @@ -192,11 +199,18 @@ def update_unit_discussion_state_from_discussion_blocks(course_key: CourseKey, u course_key (CourseKey): CourseKey for course. user_id (int): User id for the user performing this operation. force (bool): Force migration of data even if not using legacy provider - + async_topics (bool): If True, run the task asynchronously. """ store = modulestore() course = store.get_course(course_key) - provider = course.discussions_settings.get('provider', None) + # The provider information has been written to both `provider_type` and `provider`. + # Both of these serve the same purpose and this is an accident of early development. + # The `provider_type` key is now treated as read-only to allow existing values + # to be respected while moving to the `provider` key in the future. + provider = course.discussions_settings.get( + 'provider_type', + course.discussions_settings.get('provider', None), + ) # Only migrate to the new discussion provider if the current provider is the legacy provider. log.info(f"Current provider for {course_key} is {provider}") if provider is not None and provider != Provider.LEGACY and not force: @@ -255,8 +269,15 @@ def update_unit_discussion_state_from_discussion_blocks(course_key: CourseKey, u discussion_config.enable_graded_units = enable_graded_subsections discussion_config.unit_level_visibility = True discussion_config.save() - # added delay of 30 minutes to allow for the course to be published - update_discussions_settings_from_course_task.apply_async( - args=[str(course_key), [str(unit) for unit in discussable_units]], - countdown=1800, - ) + + if async_topics: + # added delay of 30 minutes to allow for the course to be published + update_discussions_settings_from_course_task.apply_async( + args=[str(course_key), [str(unit) for unit in discussable_units]], + countdown=1800, + ) + else: + update_discussions_settings_from_course_task( + str(course_key), + [str(unit) for unit in discussable_units], + ) diff --git a/openedx/core/djangoapps/discussions/tests/test_views.py b/openedx/core/djangoapps/discussions/tests/test_views.py index cc17b56d22..64d342480a 100644 --- a/openedx/core/djangoapps/discussions/tests/test_views.py +++ b/openedx/core/djangoapps/discussions/tests/test_views.py @@ -5,6 +5,7 @@ Test app view logic import itertools from contextlib import contextmanager from datetime import datetime, timedelta, timezone +from unittest.mock import Mock, patch import ddt from django.core.exceptions import ValidationError @@ -13,20 +14,16 @@ from edx_toggles.toggles.testutils import override_waffle_flag from lti_consumer.models import CourseAllowPIISharingInLTIFlag from rest_framework import status from rest_framework.test import APITestCase + +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.django_utils import CourseUserType, ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from common.djangoapps.student.tests.factories import UserFactory -from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory from ..config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS - -from ..models import ( - AVAILABLE_PROVIDER_MAP, - DEFAULT_CONFIG_ENABLED, - Provider, - get_default_provider_type, -) +from ..models import AVAILABLE_PROVIDER_MAP, DEFAULT_CONFIG_ENABLED, Provider, get_default_provider_type +from ..permissions import IsStaffOrCourseTeam DATA_LEGACY_COHORTS = { 'divided_inline_discussions': [], @@ -387,6 +384,26 @@ class DataTest(AuthorizedApiTest, DataTestMixin): assert data['plugin_configuration'] == {'key': 'value'} assert data['lti_configuration'] == DEFAULT_LTI_CONFIGURATION + @ddt.data( + True, + False, + ) + def test_enabled_configuration(self, enabled): + """ + Test that setting the "enabled" property for Discussions shows the Discussions tab. + """ + payload = { + "provider_type": Provider.PIAZZA, + "enabled": enabled, + } + self._post(payload) + + data = self.get() + for tab in self.store.get_course(self.course.id).tabs: + if tab.tab_id == "discussion": + assert data["enabled"] == (not tab.is_hidden) + break + def test_change_plugin_configuration(self): """ Tests custom config values persist that when changing discussion @@ -830,3 +847,86 @@ class PIISettingsAPITests(DataTest): response_data = self.get() # the GET should pull back the same data as the POST assert response_data == data + + +class SyncDiscussionTopicsViewTests(ModuleStoreTestCase, APITestCase): + """ + Tests for SyncDiscussionTopicsView + """ + + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + self.course_key_string = str(self.course.id) + self.staff_user = UserFactory.create(is_staff=True) + self.instructor_user = UserFactory.create() + self.student_user = UserFactory.create() + self.url = reverse('sync-discussion-topics', kwargs={'course_key_string': self.course_key_string}) + + # Mock the permission class for course team checking + self.original_has_permission = IsStaffOrCourseTeam.has_permission + IsStaffOrCourseTeam.has_permission = Mock(return_value=True) + + def tearDown(self): + # Restore original permission method + IsStaffOrCourseTeam.has_permission = self.original_has_permission + super().tearDown() + + @patch('openedx.core.djangoapps.discussions.views.update_discussions_settings_from_course_task') + def test_sync_discussion_topics_staff_user(self, mock_update): + """ + Test that staff users can sync discussion topics + """ + self.client.force_authenticate(user=self.staff_user) + response = self.client.post(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'success') + mock_update.assert_called_once_with(self.course_key_string) + + @patch('openedx.core.djangoapps.discussions.views.update_discussions_settings_from_course_task') + def test_sync_discussion_topics_course_team(self, mock_update): + """ + Test that course team members can sync discussion topics + """ + self.client.force_authenticate(user=self.instructor_user) + + # Mock the course team permission check + IsStaffOrCourseTeam.has_permission = Mock(return_value=True) + + response = self.client.post(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'success') + mock_update.assert_called_once() + + def test_sync_discussion_topics_unauthorized(self): + """ + Test that unauthorized users cannot sync discussion topics + """ + # Don't authenticate the request + response = self.client.post(self.url) + + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_sync_discussion_topics_forbidden(self): + """ + Test that authenticated but unauthorized users cannot sync discussion topics + """ + self.client.force_authenticate(user=self.student_user) + + # Mock the course team permission check to return False + IsStaffOrCourseTeam.has_permission = Mock(return_value=False) + + response = self.client.post(self.url) + + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_invalid_http_method(self): + """ + Test that only POST method is allowed + """ + self.client.force_authenticate(user=self.staff_user) + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_405_METHOD_NOT_ALLOWED) diff --git a/openedx/core/djangoapps/discussions/urls.py b/openedx/core/djangoapps/discussions/urls.py index 7a68616ce0..6b70ca5f98 100644 --- a/openedx/core/djangoapps/discussions/urls.py +++ b/openedx/core/djangoapps/discussions/urls.py @@ -1,11 +1,15 @@ """ Configure URL endpoints for the djangoapp """ -from django.urls import re_path from django.conf import settings +from django.urls import re_path -from .views import CombinedDiscussionsConfigurationView, DiscussionsConfigurationSettingsView, DiscussionsProvidersView - +from .views import ( + CombinedDiscussionsConfigurationView, + DiscussionsConfigurationSettingsView, + DiscussionsProvidersView, + SyncDiscussionTopicsView +) urlpatterns = [ re_path( @@ -23,4 +27,9 @@ urlpatterns = [ DiscussionsProvidersView.as_view(), name='discussions-providers', ), + re_path( + fr'^v0/course/{settings.COURSE_KEY_PATTERN}/sync_discussion_topics$', + SyncDiscussionTopicsView.as_view(), + name='sync-discussion-topics', + ), ] diff --git a/openedx/core/djangoapps/discussions/views.py b/openedx/core/djangoapps/discussions/views.py index 9d27d8f5f8..b74d88ca03 100644 --- a/openedx/core/djangoapps/discussions/views.py +++ b/openedx/core/djangoapps/discussions/views.py @@ -7,6 +7,7 @@ import edx_api_doc_tools as apidocs from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from rest_framework.exceptions import ValidationError +from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response from rest_framework.views import APIView @@ -14,13 +15,12 @@ from rest_framework.views import APIView from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from openedx.core.lib.api.view_utils import validate_course_key + from .config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS from .models import AVAILABLE_PROVIDER_MAP, DiscussionsConfiguration, Features, Provider from .permissions import IsStaffOrCourseTeam, check_course_permissions -from .serializers import ( - DiscussionsConfigurationSerializer, - DiscussionsProvidersSerializer, -) +from .serializers import DiscussionsConfigurationSerializer, DiscussionsProvidersSerializer +from .tasks import update_discussions_settings_from_course_task class DiscussionsConfigurationSettingsView(APIView): @@ -251,3 +251,28 @@ class CombinedDiscussionsConfigurationView(DiscussionsConfigurationSettingsView) }, } ) + + +class SyncDiscussionTopicsView(APIView): + """ + View for syncing discussion topics for a course. + """ + authentication_classes = (BearerAuthenticationAllowInactiveUser, SessionAuthenticationAllowInactiveUser) + permission_classes = (IsAuthenticated, IsStaffOrCourseTeam) + + def post(self, request, course_key_string): + """ + Sync discussion topics for the course based on data in the request. + + Args: + request (Request): a DRF request + course_key_string (str): a course key string + + Returns: + Response: modified course configuration data + """ + update_discussions_settings_from_course_task(course_key_string) + return Response({ + "status": "success", + "message": "Discussion topics synced successfully." + }) diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py index c86f7eb405..a368d09830 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -1,10 +1,18 @@ # pylint: disable=missing-docstring,protected-access +import logging +import time + from bs4 import BeautifulSoup from openedx.core.djangoapps.django_comment_common.comment_client import models, settings -from .thread import Thread, _url_for_flag_abuse_thread, _url_for_unflag_abuse_thread -from .utils import CommentClientRequestError, perform_request +from .thread import Thread +from .utils import CommentClientRequestError, get_course_key +from forum import api as forum_api +from forum.backends.mongodb.comments import Comment as ForumComment + + +log = logging.getLogger(__name__) class Comment(models.Model): @@ -61,41 +69,30 @@ class Comment(models.Model): else: return super().url(action, params) - def flagAbuse(self, user, voteable): - if voteable.type == 'thread': - url = _url_for_flag_abuse_thread(voteable.id) - elif voteable.type == 'comment': - url = _url_for_flag_abuse_comment(voteable.id) - else: - raise CommentClientRequestError("Can only flag/unflag threads or comments") - params = {'user_id': user.id} - response = perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='comment.abuse.flagged' + def flagAbuse(self, user, voteable, course_id=None): + if voteable.type != 'comment': + raise CommentClientRequestError("Can only flag comments") + + course_key = get_course_key(self.attributes.get("course_id") or course_id) + response = forum_api.update_comment_flag( + comment_id=voteable.id, + action="flag", + user_id=str(user.id), + course_id=str(course_key), ) voteable._update_from_response(response) - def unFlagAbuse(self, user, voteable, removeAll): - if voteable.type == 'thread': - url = _url_for_unflag_abuse_thread(voteable.id) - elif voteable.type == 'comment': - url = _url_for_unflag_abuse_comment(voteable.id) - else: - raise CommentClientRequestError("Can flag/unflag for threads or comments") - params = {'user_id': user.id} + def unFlagAbuse(self, user, voteable, removeAll, course_id=None): + if voteable.type != 'comment': + raise CommentClientRequestError("Can only unflag comments") - if removeAll: - params['all'] = True - - response = perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='comment.abuse.unflagged' + course_key = get_course_key(self.attributes.get("course_id") or course_id) + response = forum_api.update_comment_flag( + comment_id=voteable.id, + action="unflag", + user_id=str(user.id), + update_all=bool(removeAll), + course_id=str(course_key), ) voteable._update_from_response(response) @@ -107,6 +104,44 @@ class Comment(models.Model): soup = BeautifulSoup(self.body, 'html.parser') return soup.get_text() + @classmethod + def get_user_comment_count(cls, user_id, course_ids): + """ + Returns comments and responses count of user in the given course_ids. + TODO: Add support for MySQL backend as well + """ + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "_type": "Comment" + } + return ForumComment()._collection.count_documents(query_params) # pylint: disable=protected-access + + @classmethod + def delete_user_comments(cls, user_id, course_ids): + """ + Deletes comments and responses of user in the given course_ids. + TODO: Add support for MySQL backend as well + """ + start_time = time.time() + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + } + comments_deleted = 0 + comments = ForumComment().get_list(**query_params) + log.info(f"<> Fetched comments for user {user_id} in {time.time() - start_time} seconds") + for comment in comments: + start_time = time.time() + comment_id = comment.get("_id") + course_id = comment.get("course_id") + if comment_id: + forum_api.delete_comment(comment_id, course_id=course_id) + comments_deleted += 1 + log.info(f"<> Deleted comment {comment_id} in {time.time() - start_time} seconds." + f" Comment Found: {comment_id is not None}") + return comments_deleted + def _url_for_thread_comments(thread_id): return f"{settings.PREFIX}/threads/{thread_id}/comments" diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/course.py b/openedx/core/djangoapps/django_comment_common/comment_client/course.py index 67d7efd228..8cbb580e78 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/course.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/course.py @@ -7,8 +7,10 @@ from typing import Dict, Optional from edx_django_utils.monitoring import function_trace from opaque_keys.edx.keys import CourseKey +from forum import api as forum_api from openedx.core.djangoapps.django_comment_common.comment_client import settings from openedx.core.djangoapps.django_comment_common.comment_client.utils import perform_request +from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled def get_course_commentable_counts(course_key: CourseKey) -> Dict[str, Dict[str, int]]: @@ -29,17 +31,20 @@ def get_course_commentable_counts(course_key: CourseKey) -> Dict[str, Dict[str, } """ - url = f"{settings.PREFIX}/commentables/{course_key}/counts" - response = perform_request( - 'get', - url, - metric_tags=[ - f"course_key:{course_key}", - "function:get_course_commentable_counts", - ], - metric_action='commentable_stats.retrieve', - ) - return response + if is_forum_v2_enabled(course_key): + commentable_stats = forum_api.get_commentables_stats(str(course_key)) + else: + url = f"{settings.PREFIX}/commentables/{course_key}/counts" + commentable_stats = perform_request( + 'get', + url, + metric_tags=[ + f"course_key:{course_key}", + "function:get_course_commentable_counts", + ], + metric_action='commentable_stats.retrieve', + ) + return commentable_stats @function_trace("get_course_user_stats") @@ -76,17 +81,21 @@ def get_course_user_stats(course_key: CourseKey, params: Optional[Dict] = None) """ if params is None: params = {} - url = f"{settings.PREFIX}/users/{course_key}/stats" - return perform_request( - 'get', - url, - params, - metric_action='user.course_stats', - metric_tags=[ - f"course_key:{course_key}", - "function:get_course_user_stats", - ], - ) + if is_forum_v2_enabled(course_key): + course_stats = forum_api.get_user_course_stats(str(course_key), **params) + else: + url = f"{settings.PREFIX}/users/{course_key}/stats" + course_stats = perform_request( + 'get', + url, + params, + metric_action='user.course_stats', + metric_tags=[ + f"course_key:{course_key}", + "function:get_course_user_stats", + ], + ) + return course_stats @function_trace("update_course_users_stats") @@ -100,13 +109,17 @@ def update_course_users_stats(course_key: CourseKey) -> Dict: Returns: dict: data returned by API. Contains count of users updated. """ - url = f"{settings.PREFIX}/users/{course_key}/update_stats" - return perform_request( - 'post', - url, - metric_action='user.update_course_stats', - metric_tags=[ - f"course_key:{course_key}", - "function:update_course_users_stats", - ], - ) + if is_forum_v2_enabled(course_key): + course_stats = forum_api.update_users_in_course(str(course_key)) + else: + url = f"{settings.PREFIX}/users/{course_key}/update_stats" + course_stats = perform_request( + 'post', + url, + metric_action='user.update_course_stats', + metric_tags=[ + f"course_key:{course_key}", + "function:update_course_users_stats", + ], + ) + return course_stats diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/models.py b/openedx/core/djangoapps/django_comment_common/comment_client/models.py index 4e602809c8..88606b999b 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/models.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/models.py @@ -2,8 +2,11 @@ import logging +import typing as t -from .utils import CommentClientRequestError, extract, perform_request +from .utils import CommentClientRequestError, extract, perform_request, get_course_key +from forum import api as forum_api +from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled, is_forum_v2_disabled_globally log = logging.getLogger(__name__) @@ -58,8 +61,8 @@ class Model: def get(self, *args, **kwargs): return self.attributes.get(*args, **kwargs) - def to_dict(self): - self.retrieve() + def to_dict(self, course_key=None): + self.retrieve(course_key=course_key) return self.attributes def retrieve(self, *args, **kwargs): @@ -69,14 +72,27 @@ class Model: return self def _retrieve(self, *args, **kwargs): - url = self.url(action='get', params=self.attributes) - response = perform_request( - 'get', - url, - self.default_retrieve_params, - metric_tags=self._metric_tags, - metric_action='model.retrieve' - ) + course_id = self.attributes.get("course_id") or kwargs.get("course_key") + if course_id: + course_key = get_course_key(course_id) + use_forumv2 = is_forum_v2_enabled(course_key) + else: + use_forumv2, course_id = is_forum_v2_enabled_for_comment(self.id) + response = None + if use_forumv2: + if self.type == "comment": + response = forum_api.get_parent_comment(comment_id=self.attributes["id"], course_id=course_id) + if response is None: + raise CommentClientRequestError("Forum v2 API call is missing") + else: + url = self.url(action='get', params=self.attributes) + response = perform_request( + 'get', + url, + self.default_retrieve_params, + metric_tags=self._metric_tags, + metric_action='model.retrieve' + ) self._update_from_response(response) @property @@ -151,33 +167,26 @@ class Model: """ self.before_save(self) if self.id: # if we have id already, treat this as an update - request_params = self.updatable_attributes() - if params: - request_params.update(params) - url = self.url(action='put', params=self.attributes) - response = perform_request( - 'put', - url, - request_params, - metric_tags=self._metric_tags, - metric_action='model.update' - ) - else: # otherwise, treat this as an insert - url = self.url(action='post', params=self.attributes) - response = perform_request( - 'post', - url, - self.initializable_attributes(), - metric_tags=self._metric_tags, - metric_action='model.insert' - ) + response = self.handle_update(params) + else: # otherwise, treat this as an insert + response = self.handle_create(params) self.retrieved = True self._update_from_response(response) self.after_save(self) - def delete(self): - url = self.url(action='delete', params=self.attributes) - response = perform_request('delete', url, metric_tags=self._metric_tags, metric_action='model.delete') + def delete(self, course_id=None): + course_key = get_course_key(self.attributes.get("course_id") or course_id) + if is_forum_v2_enabled(course_key): + response = None + if self.type == "comment": + response = forum_api.delete_comment(comment_id=self.attributes["id"], course_id=str(course_key)) + elif self.type == "thread": + response = forum_api.delete_thread(thread_id=self.attributes["id"], course_id=str(course_key)) + if response is None: + raise CommentClientRequestError("Forum v2 API call is missing") + else: + url = self.url(action='delete', params=self.attributes) + response = perform_request('delete', url, metric_tags=self._metric_tags, metric_action='model.delete') self.retrieved = True self._update_from_response(response) @@ -208,3 +217,175 @@ class Model: raise CommentClientRequestError(f"Cannot perform action {action} without id") # lint-amnesty, pylint: disable=raise-missing-from else: # action must be in DEFAULT_ACTIONS_WITHOUT_ID now return cls.url_without_id() + + def handle_update(self, params=None): + request_params = self.updatable_attributes() + if params: + request_params.update(params) + course_id = self.attributes.get("course_id") or request_params.get("course_id") + course_key = get_course_key(course_id) + if is_forum_v2_enabled(course_key): + response = None + if self.type == "comment": + response = self.handle_update_comment(request_params, str(course_key)) + elif self.type == "thread": + response = self.handle_update_thread(request_params, str(course_key)) + elif self.type == "user": + response = self.handle_update_user(request_params, str(course_key)) + if response is None: + raise CommentClientRequestError("Forum v2 API call is missing") + else: + response = self.perform_http_put_request(request_params) + return response + + def handle_update_user(self, request_params, course_id): + try: + username = request_params["username"] + external_id = str(request_params["external_id"]) + except KeyError as e: + raise e + response = forum_api.update_user( + external_id, + username=username, + course_id=course_id, + ) + return response + + def handle_update_comment(self, request_params, course_id): + request_data = { + "comment_id": self.attributes["id"], + "body": request_params.get("body"), + "course_id": request_params.get("course_id") or course_id, + "user_id": request_params.get("user_id"), + "anonymous": request_params.get("anonymous"), + "anonymous_to_peers": request_params.get("anonymous_to_peers"), + "endorsed": request_params.get("endorsed"), + "closed": request_params.get("closed"), + "editing_user_id": request_params.get("editing_user_id"), + "edit_reason_code": request_params.get("edit_reason_code"), + "endorsement_user_id": request_params.get("endorsement_user_id"), + } + request_data = {k: v for k, v in request_data.items() if v is not None} + response = forum_api.update_comment(**request_data) + return response + + def handle_update_thread(self, request_params, course_id): + request_data = { + "thread_id": self.attributes["id"], + "title": request_params.get("title"), + "body": request_params.get("body"), + "course_id": request_params.get("course_id") or course_id, + "anonymous": request_params.get("anonymous"), + "anonymous_to_peers": request_params.get("anonymous_to_peers"), + "closed": request_params.get("closed"), + "commentable_id": request_params.get("commentable_id"), + "user_id": request_params.get("user_id"), + "editing_user_id": request_params.get("editing_user_id"), + "pinned": request_params.get("pinned"), + "thread_type": request_params.get("thread_type"), + "edit_reason_code": request_params.get("edit_reason_code"), + "close_reason_code": request_params.get("close_reason_code"), + "closing_user_id": request_params.get("closing_user_id"), + "endorsed": request_params.get("endorsed"), + "read": request_params.get("read"), + } + request_data = {k: v for k, v in request_data.items() if v is not None} + response = forum_api.update_thread(**request_data) + return response + + def perform_http_put_request(self, request_params): + url = self.url(action="put", params=self.attributes) + response = perform_request( + "put", + url, + request_params, + metric_tags=self._metric_tags, + metric_action="model.update", + ) + return response + + def perform_http_post_request(self): + url = self.url(action="post", params=self.attributes) + response = perform_request( + "post", + url, + self.initializable_attributes(), + metric_tags=self._metric_tags, + metric_action="model.insert", + ) + return response + + def handle_create(self, params=None): + course_id = self.attributes.get("course_id") or params.get("course_id") + course_key = get_course_key(course_id) + if is_forum_v2_enabled(course_key): + response = None + if self.type == "comment": + response = self.handle_create_comment(str(course_key)) + elif self.type == "thread": + response = self.handle_create_thread(str(course_key)) + if response is None: + raise CommentClientRequestError("Forum v2 API call is missing") + else: + response = self.perform_http_post_request() + return response + + def handle_create_comment(self, course_id): + request_data = self.initializable_attributes() + body = request_data["body"] + user_id = request_data["user_id"] + course_id = course_id or str(request_data["course_id"]) + if parent_id := self.attributes.get("parent_id"): + response = forum_api.create_child_comment( + parent_id, + body, + user_id, + course_id, + request_data.get("anonymous", False), + request_data.get("anonymous_to_peers", False), + ) + else: + response = forum_api.create_parent_comment( + self.attributes["thread_id"], + body, + user_id, + course_id, + request_data.get("anonymous", False), + request_data.get("anonymous_to_peers", False), + ) + return response + + def handle_create_thread(self, course_id): + request_data = self.initializable_attributes() + response = forum_api.create_thread( + title=request_data["title"], + body=request_data["body"], + course_id=course_id or str(request_data["course_id"]), + user_id=str(request_data["user_id"]), + anonymous=request_data.get("anonymous", False), + anonymous_to_peers=request_data.get("anonymous_to_peers", False), + commentable_id=request_data.get("commentable_id", "course"), + thread_type=request_data.get("thread_type", "discussion"), + group_id=request_data.get("group_id", None), + context=request_data.get("context", None), + ) + return response + + +def is_forum_v2_enabled_for_comment(comment_id: str) -> tuple[bool, t.Optional[str]]: + """ + Figure out whether we use forum v2 for a given comment. + + See is_forum_v2_enabled_for_thread. + + Return: + + enabled (bool) + course_id (str or None) + """ + if is_forum_v2_disabled_globally(): + return False, None + + course_id = forum_api.get_course_id_by_comment(comment_id) + course_key = get_course_key(course_id) + return is_forum_v2_enabled(course_key), course_id diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py b/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py index 545948a092..2130dfc56b 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py @@ -4,6 +4,8 @@ Subscription model is used to get users who are subscribed to the main thread/po import logging from . import models, settings, utils +from forum import api as forum_api +from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled log = logging.getLogger(__name__) @@ -21,7 +23,7 @@ class Subscription(models.Model): base_url = f"{settings.PREFIX}/threads" @classmethod - def fetch(cls, thread_id, query_params): + def fetch(cls, thread_id, course_id, query_params): """ Fetches the subscriptions for a given thread_id """ @@ -33,14 +35,23 @@ class Subscription(models.Model): params.update( utils.strip_blank(utils.strip_none(query_params)) ) - response = utils.perform_request( - 'get', - cls.url(action='get', params=params) + "/subscriptions", - params, - metric_tags=[], - metric_action='subscription.get', - paged_results=True - ) + course_key = utils.get_course_key(course_id) + if is_forum_v2_enabled(course_key): + response = forum_api.get_thread_subscriptions( + thread_id=thread_id, + page=params["page"], + per_page=params["per_page"], + course_id=str(course_key) + ) + else: + response = utils.perform_request( + 'get', + cls.url(action='get', params=params) + "/subscriptions", + params, + metric_tags=[], + metric_action='subscription.get', + paged_results=True + ) return utils.SubscriptionsPaginatedResult( collection=response.get('collection', []), page=response.get('page', 1), diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py index ef5accbad2..af2424a5a0 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -2,10 +2,16 @@ import logging +import time +import typing as t from eventtracking import tracker from . import models, settings, utils +from forum import api as forum_api +from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled, is_forum_v2_disabled_globally +from forum.backends.mongodb.threads import CommentThread + log = logging.getLogger(__name__) @@ -53,20 +59,29 @@ class Thread(models.Model): utils.strip_blank(utils.strip_none(query_params)) ) - if query_params.get('text'): - url = cls.url(action='search') + # Convert user_id and author_id to strings if present + for field in ['user_id', 'author_id']: + if value := params.get(field): + params[field] = str(value) + + # Handle commentable_ids/commentable_id conversion + if commentable_ids := params.get('commentable_ids'): + params['commentable_ids'] = commentable_ids.split(',') + elif commentable_id := params.get('commentable_id'): + params['commentable_ids'] = [commentable_id] + params.pop('commentable_id', None) + + params = utils.clean_forum_params(params) + if query_params.get('text'): # Handle group_ids/group_id conversion + if group_ids := params.get('group_ids'): + params['group_ids'] = [int(group_id) for group_id in group_ids.split(',')] + elif group_id := params.get('group_id'): + params['group_ids'] = [int(group_id)] + params.pop('group_id', None) + response = forum_api.search_threads(**params) else: - url = cls.url(action='get_all', params=utils.extract(params, 'commentable_id')) - if params.get('commentable_id'): - del params['commentable_id'] - response = utils.perform_request( - 'get', - url, - params, - metric_tags=['course_id:{}'.format(query_params['course_id'])], - metric_action='thread.search', - paged_results=True - ) + response = forum_api.get_user_threads(**params) + if query_params.get('text'): search_query = query_params['text'] course_id = query_params['course_id'] @@ -98,7 +113,6 @@ class Thread(models.Model): total_results=total_results ) ) - return utils.CommentClientPaginatedResult( collection=response.get('collection', []), page=response.get('page', 1), @@ -148,74 +162,114 @@ class Thread(models.Model): 'merge_question_type_responses': kwargs.get('merge_question_type_responses', False) } request_params = utils.strip_none(request_params) - - response = utils.perform_request( - 'get', - url, - request_params, - metric_action='model.retrieve', - metric_tags=self._metric_tags - ) + course_id = kwargs.get("course_id") + if course_id: + course_key = utils.get_course_key(course_id) + use_forumv2 = is_forum_v2_enabled(course_key) + else: + use_forumv2, course_id = is_forum_v2_enabled_for_thread(self.id) + if use_forumv2: + if user_id := request_params.get('user_id'): + request_params['user_id'] = str(user_id) + response = forum_api.get_thread( + thread_id=self.id, + params=request_params, + course_id=course_id, + ) + else: + response = utils.perform_request( + 'get', + url, + request_params, + metric_action='model.retrieve', + metric_tags=self._metric_tags + ) self._update_from_response(response) - def flagAbuse(self, user, voteable): - if voteable.type == 'thread': - url = _url_for_flag_abuse_thread(voteable.id) - else: - raise utils.CommentClientRequestError("Can only flag/unflag threads or comments") - params = {'user_id': user.id} - response = utils.perform_request( - 'put', - url, - params, - metric_action='thread.abuse.flagged', - metric_tags=self._metric_tags + def flagAbuse(self, user, voteable, course_id=None): + if voteable.type != 'thread': + raise utils.CommentClientRequestError("Can only flag threads") + + course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) + response = forum_api.update_thread_flag( + thread_id=voteable.id, + action="flag", + user_id=str(user.id), + course_id=str(course_key) ) voteable._update_from_response(response) - def unFlagAbuse(self, user, voteable, removeAll): - if voteable.type == 'thread': - url = _url_for_unflag_abuse_thread(voteable.id) - else: - raise utils.CommentClientRequestError("Can only flag/unflag for threads or comments") - params = {'user_id': user.id} - #if you're an admin, when you unflag, remove ALL flags - if removeAll: - params['all'] = True + def unFlagAbuse(self, user, voteable, removeAll, course_id=None): + if voteable.type != 'thread': + raise utils.CommentClientRequestError("Can only unflag threads") - response = utils.perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='thread.abuse.unflagged' + course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) + response = forum_api.update_thread_flag( + thread_id=voteable.id, + action="unflag", + user_id=user.id, + update_all=bool(removeAll), + course_id=str(course_key) ) + voteable._update_from_response(response) - def pin(self, user, thread_id): - url = _url_for_pin_thread(thread_id) - params = {'user_id': user.id} - response = utils.perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='thread.pin' + def pin(self, user, thread_id, course_id=None): + course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) + response = forum_api.pin_thread( + user_id=user.id, + thread_id=thread_id, + course_id=str(course_key) ) self._update_from_response(response) - def un_pin(self, user, thread_id): - url = _url_for_un_pin_thread(thread_id) - params = {'user_id': user.id} - response = utils.perform_request( - 'put', - url, - params, - metric_tags=self._metric_tags, - metric_action='thread.unpin' + def un_pin(self, user, thread_id, course_id=None): + course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) + response = forum_api.unpin_thread( + user_id=user.id, + thread_id=thread_id, + course_id=str(course_key) ) self._update_from_response(response) + @classmethod + def get_user_threads_count(cls, user_id, course_ids): + """ + Returns threads and responses count of user in the given course_ids. + TODO: Add support for MySQL backend as well + """ + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + "_type": "CommentThread" + } + return CommentThread()._collection.count_documents(query_params) # pylint: disable=protected-access + + @classmethod + def delete_user_threads(cls, user_id, course_ids): + """ + Deletes threads of user in the given course_ids. + TODO: Add support for MySQL backend as well + """ + start_time = time.time() + query_params = { + "course_id": {"$in": course_ids}, + "author_id": str(user_id), + } + threads_deleted = 0 + threads = CommentThread().get_list(**query_params) + log.info(f"<> Fetched threads for user {user_id} in {time.time() - start_time} seconds") + for thread in threads: + start_time = time.time() + thread_id = thread.get("_id") + course_id = thread.get("course_id") + if thread_id: + forum_api.delete_thread(thread_id, course_id=course_id) + threads_deleted += 1 + log.info(f"<> Deleted thread {thread_id} in {time.time() - start_time} seconds." + f" Thread Found: {thread_id is not None}") + return threads_deleted + def _url_for_flag_abuse_thread(thread_id): return f"{settings.PREFIX}/threads/{thread_id}/abuse_flag" @@ -231,3 +285,28 @@ def _url_for_pin_thread(thread_id): def _url_for_un_pin_thread(thread_id): return f"{settings.PREFIX}/threads/{thread_id}/unpin" + + +def is_forum_v2_enabled_for_thread(thread_id: str) -> tuple[bool, t.Optional[str]]: + """ + Figure out whether we use forum v2 for a given thread. + + This is a complex affair... First, we check the value of the DISABLE_FORUM_V2 + setting, which overrides everything. If this setting does not exist, then we need to + find the course ID that corresponds to the thread ID. Then, we return the value of + the course waffle flag for this course ID. + + Note that to fetch the course ID associated to a thread ID, we need to connect both + to mongodb and mysql. As a consequence, when forum v2 needs adequate connection + strings for both backends. + + Return: + + enabled (bool) + course_id (str or None) + """ + if is_forum_v2_disabled_globally(): + return False, None + course_id = forum_api.get_course_id_by_thread(thread_id) + course_key = utils.get_course_key(course_id) + return is_forum_v2_enabled(course_key), course_id diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/user.py b/openedx/core/djangoapps/django_comment_common/comment_client/user.py index 684469c9e7..ee9591e51d 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/user.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/user.py @@ -1,8 +1,10 @@ # pylint: disable=missing-docstring,protected-access """ User model wrapper for comment service""" - from . import models, settings, utils +from forum import api as forum_api +from forum.utils import ForumV2RequestError, str_to_bool +from openedx.core.djangoapps.discussions.config.waffle import is_forum_v2_enabled class User(models.Model): @@ -34,67 +36,93 @@ class User(models.Model): """ Calls cs_comments_service to mark thread as read for the user """ - params = {'source_type': source.type, 'source_id': source.id} - utils.perform_request( - 'post', - _url_for_read(self.id), - params, - metric_action='user.read', - metric_tags=self._metric_tags + [f'target.type:{source.type}'], - ) + course_id = self.attributes.get("course_id") + course_key = utils.get_course_key(course_id) + if is_forum_v2_enabled(course_key): + forum_api.mark_thread_as_read(self.id, source.id, course_id=str(course_id)) + else: + params = {'source_type': source.type, 'source_id': source.id} + utils.perform_request( + 'post', + _url_for_read(self.id), + params, + metric_action='user.read', + metric_tags=self._metric_tags + [f'target.type:{source.type}'], + ) - def follow(self, source): - params = {'source_type': source.type, 'source_id': source.id} - utils.perform_request( - 'post', - _url_for_subscription(self.id), - params, - metric_action='user.follow', - metric_tags=self._metric_tags + [f'target.type:{source.type}'], - ) + def follow(self, source, course_id=None): + course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) + if is_forum_v2_enabled(course_key): + forum_api.create_subscription( + user_id=self.id, + source_id=source.id, + course_id=str(course_key) + ) + else: + params = {'source_type': source.type, 'source_id': source.id} + utils.perform_request( + 'post', + _url_for_subscription(self.id), + params, + metric_action='user.follow', + metric_tags=self._metric_tags + [f'target.type:{source.type}'], + ) - def unfollow(self, source): - params = {'source_type': source.type, 'source_id': source.id} - utils.perform_request( - 'delete', - _url_for_subscription(self.id), - params, - metric_action='user.unfollow', - metric_tags=self._metric_tags + [f'target.type:{source.type}'], - ) + def unfollow(self, source, course_id=None): + course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) + if is_forum_v2_enabled(course_key): + forum_api.delete_subscription( + user_id=self.id, + source_id=source.id, + course_id=str(course_key) + ) + else: + params = {'source_type': source.type, 'source_id': source.id} + utils.perform_request( + 'delete', + _url_for_subscription(self.id), + params, + metric_action='user.unfollow', + metric_tags=self._metric_tags + [f'target.type:{source.type}'], + ) - def vote(self, voteable, value): + def vote(self, voteable, value, course_id=None): + course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) if voteable.type == 'thread': - url = _url_for_vote_thread(voteable.id) + response = forum_api.update_thread_votes( + thread_id=voteable.id, + user_id=self.id, + value=value, + course_id=str(course_key) + ) elif voteable.type == 'comment': - url = _url_for_vote_comment(voteable.id) + response = forum_api.update_comment_votes( + comment_id=voteable.id, + user_id=self.id, + value=value, + course_id=str(course_key) + ) else: raise utils.CommentClientRequestError("Can only vote / unvote for threads or comments") - params = {'user_id': self.id, 'value': value} - response = utils.perform_request( - 'put', - url, - params, - metric_action='user.vote', - metric_tags=self._metric_tags + [f'target.type:{voteable.type}'], - ) voteable._update_from_response(response) - def unvote(self, voteable): + def unvote(self, voteable, course_id=None): + course_key = utils.get_course_key(self.attributes.get("course_id") or course_id) if voteable.type == 'thread': - url = _url_for_vote_thread(voteable.id) + response = forum_api.delete_thread_vote( + thread_id=voteable.id, + user_id=self.id, + course_id=str(course_key) + ) elif voteable.type == 'comment': - url = _url_for_vote_comment(voteable.id) + response = forum_api.delete_comment_vote( + comment_id=voteable.id, + user_id=self.id, + course_id=str(course_key) + ) else: raise utils.CommentClientRequestError("Can only vote / unvote for threads or comments") - params = {'user_id': self.id} - response = utils.perform_request( - 'delete', - url, - params, - metric_action='user.unvote', - metric_tags=self._metric_tags + [f'target.type:{voteable.type}'], - ) + voteable._update_from_response(response) def active_threads(self, query_params=None): @@ -105,14 +133,28 @@ class User(models.Model): url = _url_for_user_active_threads(self.id) params = {'course_id': str(self.course_id)} params.update(query_params) - response = utils.perform_request( - 'get', - url, - params, - metric_action='user.active_threads', - metric_tags=self._metric_tags, - paged_results=True, - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + if user_id := params.get("user_id"): + params["user_id"] = str(user_id) + if page := params.get("page"): + params["page"] = int(page) + if per_page := params.get("per_page"): + params["per_page"] = int(per_page) + if count_flagged := params.get("count_flagged", False): + params["count_flagged"] = str_to_bool(count_flagged) + if not params.get("course_id"): + params["course_id"] = str(course_key) + response = forum_api.get_user_active_threads(**params) + else: + response = utils.perform_request( + 'get', + url, + params, + metric_action='user.active_threads', + metric_tags=self._metric_tags, + paged_results=True, + ) return response.get('collection', []), response.get('page', 1), response.get('num_pages', 1) def subscribed_threads(self, query_params=None): @@ -125,14 +167,30 @@ class User(models.Model): url = _url_for_user_subscribed_threads(self.id) params = {'course_id': str(self.course_id)} params.update(query_params) - response = utils.perform_request( - 'get', - url, - params, - metric_action='user.subscribed_threads', - metric_tags=self._metric_tags, - paged_results=True - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + if page := params.get("page"): + params["page"] = int(page) + if per_page := params.get("per_page"): + params["per_page"] = int(per_page) + if count_flagged := params.get("count_flagged", False): + params["count_flagged"] = str_to_bool(count_flagged) + if not params.get("course_id"): + params["course_id"] = str(course_key) + + user_id = params.pop("user_id", None) + if "text" in params: + params.pop("text") + response = forum_api.get_user_subscriptions(user_id, str(course_key), utils.clean_forum_params(params)) + else: + response = utils.perform_request( + 'get', + url, + params, + metric_action='user.subscribed_threads', + metric_tags=self._metric_tags, + paged_results=True + ) return utils.CommentClientPaginatedResult( collection=response.get('collection', []), page=response.get('page', 1), @@ -144,23 +202,35 @@ class User(models.Model): url = self.url(action='get', params=self.attributes) retrieve_params = self.default_retrieve_params.copy() retrieve_params.update(kwargs) + if self.attributes.get('course_id'): retrieve_params['course_id'] = str(self.course_id) if self.attributes.get('group_id'): retrieve_params['group_id'] = self.group_id - try: - response = utils.perform_request( - 'get', - url, - retrieve_params, - metric_action='model.retrieve', - metric_tags=self._metric_tags, - ) - except utils.CommentClientRequestError as e: - if e.status_code == 404: - # attempt to gracefully recover from a previous failure - # to sync this user to the comments service. - self.save() + + # course key -> id conversation + course_id = retrieve_params.get('course_id') + if course_id: + course_id = str(course_id) + retrieve_params['course_id'] = course_id + course_key = utils.get_course_key(course_id) + + if is_forum_v2_enabled(course_key): + group_ids = [retrieve_params['group_id']] if 'group_id' in retrieve_params else [] + is_complete = retrieve_params['complete'] + params = utils.clean_forum_params({ + "user_id": self.attributes["id"], + "group_ids": group_ids, + "course_id": course_id, + "complete": is_complete + }) + try: + response = forum_api.get_user(**params) + except ForumV2RequestError as e: + self.save({"course_id": course_id}) + response = forum_api.get_user(**params) + else: + try: response = utils.perform_request( 'get', url, @@ -168,33 +238,52 @@ class User(models.Model): metric_action='model.retrieve', metric_tags=self._metric_tags, ) - else: - raise + except utils.CommentClientRequestError as e: + if e.status_code == 404: + # attempt to gracefully recover from a previous failure + # to sync this user to the comments service. + self.save() + response = utils.perform_request( + 'get', + url, + retrieve_params, + metric_action='model.retrieve', + metric_tags=self._metric_tags, + ) + else: + raise self._update_from_response(response) def retire(self, retired_username): - url = _url_for_retire(self.id) - params = {'retired_username': retired_username} - - utils.perform_request( - 'post', - url, - params, - raw=True, - metric_action='user.retire', - metric_tags=self._metric_tags - ) + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + forum_api.retire_user(user_id=self.id, retired_username=retired_username, course_id=str(course_key)) + else: + url = _url_for_retire(self.id) + params = {'retired_username': retired_username} + utils.perform_request( + 'post', + url, + params, + raw=True, + metric_action='user.retire', + metric_tags=self._metric_tags + ) def replace_username(self, new_username): - url = _url_for_username_replacement(self.id) - params = {"new_username": new_username} + course_key = utils.get_course_key(self.attributes.get("course_id")) + if is_forum_v2_enabled(course_key): + forum_api.update_username(user_id=self.id, new_username=new_username, course_id=str(course_key)) + else: + url = _url_for_username_replacement(self.id) + params = {"new_username": new_username} - utils.perform_request( - 'post', - url, - params, - raw=True, - ) + utils.perform_request( + 'post', + url, + params, + raw=True, + ) def _url_for_vote_comment(comment_id): diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py index a67cdbdbc4..26625ed3a7 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py @@ -7,6 +7,7 @@ from uuid import uuid4 import requests from django.utils.translation import get_language +from opaque_keys.edx.keys import CourseKey from .settings import SERVICE_HOST as COMMENTS_SERVICE @@ -102,6 +103,23 @@ def perform_request(method, url, data_or_params=None, raw=False, return data +def clean_forum_params(params): + """Convert string booleans to actual booleans and remove None values and empty lists from forum parameters.""" + result = {} + for k, v in params.items(): + if v is not None and v != []: + if isinstance(v, str): + if v.lower() == 'true': + result[k] = True + elif v.lower() == 'false': + result[k] = False + else: + result[k] = v + else: + result[k] = v + return result + + class CommentClientError(Exception): pass @@ -167,3 +185,19 @@ def check_forum_heartbeat(): return 'forum', False, res.get('check', 'Forum heartbeat failed') except Exception as fail: return 'forum', False, str(fail) + + +def get_course_key(course_id: CourseKey | str | None) -> CourseKey | None: + """ + Returns a CourseKey if the provided course_id is a valid string representation of a CourseKey. + If course_id is None or already a CourseKey object, it returns the course_id as is. + Args: + course_id (CourseKey | str | None): The course ID to be converted. + Returns: + CourseKey | None: The corresponding CourseKey object or None if the input is None. + Raises: + KeyError: If course_id is not a valid string representation of a CourseKey. + """ + if course_id and isinstance(course_id, str): + course_id = CourseKey.from_string(course_id) + return course_id diff --git a/openedx/core/djangoapps/embargo/admin.py b/openedx/core/djangoapps/embargo/admin.py index b86409b079..fa96317ac5 100644 --- a/openedx/core/djangoapps/embargo/admin.py +++ b/openedx/core/djangoapps/embargo/admin.py @@ -8,7 +8,7 @@ from config_models.admin import ConfigurationModelAdmin from django.contrib import admin from .forms import IPFilterForm, RestrictedCourseForm -from .models import CountryAccessRule, IPFilter, RestrictedCourse +from .models import CountryAccessRule, GlobalRestrictedCountry, IPFilter, RestrictedCourse class IPFilterAdmin(ConfigurationModelAdmin): @@ -41,5 +41,20 @@ class RestrictedCourseAdmin(admin.ModelAdmin): search_fields = ('course_key',) +class GlobalRestrictedCountryAdmin(admin.ModelAdmin): + """ + Admin configuration for the Global Country Restriction model. + """ + list_display = ("country",) + + def delete_queryset(self, request, queryset): + """ + Override the delete_queryset method to clear the cache when objects are deleted in bulk. + """ + super().delete_queryset(request, queryset) + GlobalRestrictedCountry.update_cache() + + admin.site.register(IPFilter, IPFilterAdmin) admin.site.register(RestrictedCourse, RestrictedCourseAdmin) +admin.site.register(GlobalRestrictedCountry, GlobalRestrictedCountryAdmin) diff --git a/openedx/core/djangoapps/embargo/migrations/0003_add_global_restricted_country_model.py b/openedx/core/djangoapps/embargo/migrations/0003_add_global_restricted_country_model.py new file mode 100644 index 0000000000..d9a422202c --- /dev/null +++ b/openedx/core/djangoapps/embargo/migrations/0003_add_global_restricted_country_model.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.18 on 2025-01-29 08:19 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('embargo', '0002_data__add_countries'), + ] + + operations = [ + migrations.CreateModel( + name='GlobalRestrictedCountry', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('country', models.ForeignKey(help_text='The country to be restricted.', on_delete=django.db.models.deletion.CASCADE, to='embargo.country', unique=True)), + ], + options={ + 'verbose_name': 'Global Restricted Country', + 'verbose_name_plural': 'Global Restricted Countries', + }, + ), + ] diff --git a/openedx/core/djangoapps/embargo/models.py b/openedx/core/djangoapps/embargo/models.py index 3e72699404..b3a452079c 100644 --- a/openedx/core/djangoapps/embargo/models.py +++ b/openedx/core/djangoapps/embargo/models.py @@ -662,6 +662,81 @@ class CourseAccessRuleHistory(models.Model): get_latest_by = 'timestamp' +class GlobalRestrictedCountry(models.Model): + """ + Model to restrict access to specific countries globally. + """ + country = models.ForeignKey( + "Country", + help_text="The country to be restricted.", + on_delete=models.CASCADE, + unique=True + ) + + CACHE_KEY = "embargo.global.restricted_countries" + + @classmethod + def get_countries(cls): + """ + Retrieve the set of restricted country codes from the cache or refresh it if not available. + + Returns: + set: A set of restricted country codes. + """ + return cache.get_or_set(cls.CACHE_KEY, cls._fetch_restricted_countries) + + @classmethod + def is_country_restricted(cls, country_code): + """ + Check if the given country code is restricted. + + Args: + country_code (str): The country code to check. + + Returns: + bool: True if the country is restricted, False otherwise. + """ + return country_code in cls.get_countries() + + @classmethod + def _fetch_restricted_countries(cls): + """ + Fetch the set of restricted country codes from the database. + + Returns: + set: A set of restricted country codes. + """ + return set(cls.objects.values_list("country__country", flat=True)) + + @classmethod + def update_cache(cls): + """ + Update the cache with the latest restricted country codes. + """ + cache.set(cls.CACHE_KEY, cls._fetch_restricted_countries()) + + def save(self, *args, **kwargs): + """ + Override save method to update cache on insert/update. + """ + super().save(*args, **kwargs) + self.update_cache() + + def delete(self, *args, **kwargs): + """ + Override delete method to update cache on deletion. + """ + super().delete(*args, **kwargs) + self.update_cache() + + def __str__(self): + return f"{self.country.country.name} ({self.country.country})" + + class Meta: + verbose_name = "Global Restricted Country" + verbose_name_plural = "Global Restricted Countries" + + # Connect the signals to the receivers so we record a history # of changes to the course access rules. post_save.connect(CourseAccessRuleHistory.snapshot_post_save_receiver, sender=RestrictedCourse) diff --git a/openedx/core/djangoapps/enrollments/data.py b/openedx/core/djangoapps/enrollments/data.py index 9986830a34..b76042f72c 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/enrollments/enrollments_notifications.py b/openedx/core/djangoapps/enrollments/enrollments_notifications.py new file mode 100644 index 0000000000..90f8a42d97 --- /dev/null +++ b/openedx/core/djangoapps/enrollments/enrollments_notifications.py @@ -0,0 +1,36 @@ +""" +Enrollment notifications sender util. +""" +from django.conf import settings + +from openedx_events.learning.data import UserNotificationData +from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED + + +class EnrollmentNotificationSender: + """ + Class to send notifications to user about their enrollments. + """ + + def __init__(self, course, user_id, audit_access_expiry): + self.course = course + self.user_id = user_id + self.audit_access_expiry = audit_access_expiry + + def send_audit_access_expiring_soon_notification(self): + """ + Send audit access expiring soon notification to user + """ + + notification_data = UserNotificationData( + user_ids=[int(self.user_id)], + context={ + 'course': self.course.name, + 'audit_access_expiry': self.audit_access_expiry, + }, + notification_type='audit_access_expiring_soon', + content_url=f"{settings.LEARNING_MICROFRONTEND_URL}/course/{str(self.course.id)}/home", + app_name="enrollments", + course_key=self.course.id, + ) + USER_NOTIFICATION_REQUESTED.send_event(notification_data=notification_data) diff --git a/openedx/core/djangoapps/enrollments/forms.py b/openedx/core/djangoapps/enrollments/forms.py index 4c691ae704..e87210b3fe 100644 --- a/openedx/core/djangoapps/enrollments/forms.py +++ b/openedx/core/djangoapps/enrollments/forms.py @@ -18,6 +18,7 @@ class CourseEnrollmentsApiListForm(Form): MAX_INPUT_COUNT = 100 username = CharField(required=False) course_id = CharField(required=False) + course_ids = CharField(required=False) email = CharField(required=False) def clean_course_id(self): @@ -51,6 +52,24 @@ class CourseEnrollmentsApiListForm(Form): return usernames return usernames_csv_string + def clean_course_ids(self): + """ + Validate a string of comma-separated course IDs and return a list of course IDs. + """ + course_ids_csv_string = self.cleaned_data.get('course_ids') + if course_ids_csv_string: + course_ids = course_ids_csv_string.split(',') + if len(course_ids) > self.MAX_INPUT_COUNT: + raise ValidationError( + "Too many course_ids in a single request - {}. A maximum of {} is allowed".format( + len(course_ids), + self.MAX_INPUT_COUNT, + ) + ) + return course_ids + + return course_ids_csv_string + def clean_email(self): """ Validate a string of comma-separated emails and return a list of emails. diff --git a/openedx/core/djangoapps/enrollments/serializers.py b/openedx/core/djangoapps/enrollments/serializers.py index 44a1a56194..9b64cc95ca 100644 --- a/openedx/core/djangoapps/enrollments/serializers.py +++ b/openedx/core/djangoapps/enrollments/serializers.py @@ -2,14 +2,12 @@ Serializers for all Course Enrollment related return objects. """ - import logging from rest_framework import serializers from common.djangoapps.course_modes.models import CourseMode -from common.djangoapps.student.models import (CourseEnrollment, - CourseEnrollmentAllowed) +from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed log = logging.getLogger(__name__) @@ -21,6 +19,7 @@ class StringListField(serializers.CharField): [1,2,3] """ + def field_to_native(self, obj, field_name): # pylint: disable=unused-argument """ Serialize the object's class name. @@ -28,7 +27,7 @@ class StringListField(serializers.CharField): if not obj.suggested_prices: return [] - items = obj.suggested_prices.split(',') + items = obj.suggested_prices.split(",") return [int(item) for item in items] @@ -49,7 +48,7 @@ class CourseSerializer(serializers.Serializer): # pylint: disable=abstract-meth class Meta: # For disambiguating within the drf-yasg swagger schema - ref_name = 'enrollment.Course' + ref_name = "enrollment.Course" def __init__(self, *args, **kwargs): self.include_expired = kwargs.pop("include_expired", False) @@ -59,15 +58,8 @@ class CourseSerializer(serializers.Serializer): # pylint: disable=abstract-meth """ Retrieve course modes associated with the course. """ - course_modes = CourseMode.modes_for_course( - obj.id, - include_expired=self.include_expired, - only_selectable=False - ) - return [ - ModeSerializer(mode).data - for mode in course_modes - ] + course_modes = CourseMode.modes_for_course(obj.id, include_expired=self.include_expired, only_selectable=False) + return [ModeSerializer(mode).data for mode in course_modes] def get_pacing_type(self, obj): """ @@ -83,8 +75,9 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer): the Course block and course modes, to give a complete representation of course enrollment. """ + course_details = CourseSerializer(source="course_overview") - user = serializers.SerializerMethodField('get_username') + user = serializers.SerializerMethodField("get_username") def get_username(self, model): """Retrieves the username from the associated model.""" @@ -92,8 +85,8 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer): class Meta: model = CourseEnrollment - fields = ('created', 'mode', 'is_active', 'course_details', 'user') - lookup_field = 'username' + fields = ("created", "mode", "is_active", "course_details", "user") + lookup_field = "username" class CourseEnrollmentsApiListSerializer(CourseEnrollmentSerializer): @@ -101,14 +94,15 @@ class CourseEnrollmentsApiListSerializer(CourseEnrollmentSerializer): Serializes CourseEnrollment model and returns a subset of fields returned by the CourseEnrollmentSerializer. """ - course_id = serializers.CharField(source='course_overview.id') + + course_id = serializers.CharField(source="course_overview.id") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.fields.pop('course_details') + self.fields.pop("course_details") class Meta(CourseEnrollmentSerializer.Meta): - fields = CourseEnrollmentSerializer.Meta.fields + ('course_id', ) + fields = CourseEnrollmentSerializer.Meta.fields + ("course_id",) class ModeSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -119,6 +113,7 @@ class ModeSerializer(serializers.Serializer): # pylint: disable=abstract-method does not handle the model object itself, but the tuple. """ + slug = serializers.CharField(max_length=100) name = serializers.CharField(max_length=255) min_price = serializers.IntegerField() @@ -137,7 +132,8 @@ class CourseEnrollmentAllowedSerializer(serializers.ModelSerializer): Aggregates all data from the CourseEnrollmentAllowed table, and pulls in the serialization to give a complete representation of course enrollment allowed. """ + class Meta: model = CourseEnrollmentAllowed - exclude = ['id'] - lookup_field = 'user' + exclude = ["id"] + lookup_field = "user" diff --git a/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json b/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json index 6108d92efd..65b7dfdfd0 100644 --- a/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json +++ b/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json @@ -210,5 +210,69 @@ "created": "2018-01-01T00:00:01Z" } ] + ], + [ + { + "course_ids": "course-v1:e+d+X,x+y+Z" + }, + [ + { + "course_id": "course-v1:e+d+X", + "is_active": true, + "mode": "honor", + "user": "student1", + "created": "2018-01-01T00:00:01Z" + }, + { + "course_id": "course-v1:e+d+X", + "is_active": true, + "mode": "honor", + "user": "student2", + "created": "2018-01-01T00:00:01Z" + }, + { + "course_id": "course-v1:x+y+Z", + "is_active": true, + "mode": "verified", + "user": "staff", + "created": "2018-01-01T00:00:01Z" + }, + { + "course_id": "course-v1:x+y+Z", + "is_active": true, + "mode": "honor", + "user": "student2", + "created": "2018-01-01T00:00:01Z" + }, + { + "course_id": "course-v1:x+y+Z", + "is_active": true, + "mode": "verified", + "user": "student3", + "created": "2018-01-01T00:00:01Z" + } + ] + ], + [ + { + "course_ids": "course-v1:e+d+X,x+y+Z", + "username": "student2" + }, + [ + { + "course_id": "course-v1:e+d+X", + "is_active": true, + "mode": "honor", + "user": "student2", + "created": "2018-01-01T00:00:01Z" + }, + { + "course_id": "course-v1:x+y+Z", + "is_active": true, + "mode": "honor", + "user": "student2", + "created": "2018-01-01T00:00:01Z" + } + ] ] ] diff --git a/openedx/core/djangoapps/enrollments/tests/test_enrollments_notifications.py b/openedx/core/djangoapps/enrollments/tests/test_enrollments_notifications.py new file mode 100644 index 0000000000..5420733107 --- /dev/null +++ b/openedx/core/djangoapps/enrollments/tests/test_enrollments_notifications.py @@ -0,0 +1,49 @@ +""" +Unit tests for the EnrollmentsNotificationSender class +""" +import unittest +import datetime +from unittest.mock import MagicMock, patch + +from django.conf import settings +import pytest + +from openedx.core.djangoapps.enrollments.enrollments_notifications import EnrollmentNotificationSender +from openedx_events.learning.data import UserNotificationData + + +@pytest.mark.django_db +class TestEnrollmentsNotificationSender(unittest.TestCase): + """ + Tests for the EnrollmentsNotificationSender class + """ + + def setUp(self): + self.course = MagicMock() + self.course.name = "test course" + self.course.id = 1 + self.expiry_date = datetime.date.today() + datetime.timedelta(days=1) + self.user_id = '123' + self.notification_sender = EnrollmentNotificationSender(self.course, self.user_id, self.expiry_date) + + @patch('openedx.core.djangoapps.enrollments.enrollments_notifications.USER_NOTIFICATION_REQUESTED.send_event') + def test_send_audit_access_expiring_soon_notification(self, mock_send_notification): + """ + Test that audit access expiring soon notification event is sent with correct parameters. + """ + + self.notification_sender.send_audit_access_expiring_soon_notification() + + mock_send_notification.assert_called_once() + notification_data = UserNotificationData( + user_ids=[int(self.user_id)], + context={ + 'course': self.course.name, + 'audit_access_expiry': self.expiry_date, + }, + notification_type='audit_access_expiring_soon', + content_url=f"{settings.LEARNING_MICROFRONTEND_URL}/course/{str(self.course.id)}/home", + app_name="enrollments", + course_key=self.course.id, + ) + mock_send_notification.assert_called_with(notification_data=notification_data) diff --git a/openedx/core/djangoapps/enrollments/urls.py b/openedx/core/djangoapps/enrollments/urls.py index 39a11a9893..828d4b6179 100644 --- a/openedx/core/djangoapps/enrollments/urls.py +++ b/openedx/core/djangoapps/enrollments/urls.py @@ -3,7 +3,6 @@ URLs for the Enrollment API """ - from django.conf import settings from django.urls import path, re_path @@ -14,21 +13,24 @@ from .views import ( EnrollmentListView, EnrollmentUserRolesView, EnrollmentView, - UnenrollmentView + UnenrollmentView, ) urlpatterns = [ - re_path(r'^enrollment/{username},{course_key}$'.format( - username=settings.USERNAME_PATTERN, - course_key=settings.COURSE_ID_PATTERN), - EnrollmentView.as_view(), name='courseenrollment'), - re_path(fr'^enrollment/{settings.COURSE_ID_PATTERN}$', - EnrollmentView.as_view(), name='courseenrollment'), - path('enrollment', EnrollmentListView.as_view(), name='courseenrollments'), - re_path(r'^enrollments/?$', CourseEnrollmentsApiListView.as_view(), name='courseenrollmentsapilist'), - re_path(fr'^course/{settings.COURSE_ID_PATTERN}$', - EnrollmentCourseDetailView.as_view(), name='courseenrollmentdetails'), - path('unenroll/', UnenrollmentView.as_view(), name='unenrollment'), - path('roles/', EnrollmentUserRolesView.as_view(), name='roles'), - path('enrollment_allowed/', EnrollmentAllowedView.as_view(), name='courseenrollmentallowed'), + re_path( + r"^enrollment/{username},{course_key}$".format( + username=settings.USERNAME_PATTERN, course_key=settings.COURSE_ID_PATTERN + ), + EnrollmentView.as_view(), + name="courseenrollment", + ), + re_path(rf"^enrollment/{settings.COURSE_ID_PATTERN}$", EnrollmentView.as_view(), name="courseenrollment"), + path("enrollment", EnrollmentListView.as_view(), name="courseenrollments"), + re_path(r"^enrollments/?$", CourseEnrollmentsApiListView.as_view(), name="courseenrollmentsapilist"), + re_path( + rf"^course/{settings.COURSE_ID_PATTERN}$", EnrollmentCourseDetailView.as_view(), name="courseenrollmentdetails" + ), + path("unenroll/", UnenrollmentView.as_view(), name="unenrollment"), + path("roles/", EnrollmentUserRolesView.as_view(), name="roles"), + path("enrollment_allowed/", EnrollmentAllowedView.as_view(), name="courseenrollmentallowed"), ] diff --git a/openedx/core/djangoapps/enrollments/views.py b/openedx/core/djangoapps/enrollments/views.py index 52ec4e3b31..dc3423245e 100644 --- a/openedx/core/djangoapps/enrollments/views.py +++ b/openedx/core/djangoapps/enrollments/views.py @@ -4,19 +4,21 @@ consist primarily of authentication, request validation, and serialization. """ - import logging from django.core.exceptions import ( # lint-amnesty, pylint: disable=wrong-import-order ObjectDoesNotExist, - ValidationError + ValidationError, ) -from django.db import IntegrityError # lint-amnesty, pylint: disable=wrong-import-order +from django.db import IntegrityError # lint-amnesty, pylint: disable=wrong-import-order +from django.db.models import Q # lint-amnesty, pylint: disable=wrong-import-order from django.utils.decorators import method_decorator # lint-amnesty, pylint: disable=wrong-import-order -from edx_rest_framework_extensions.auth.jwt.authentication import \ - JwtAuthentication # lint-amnesty, pylint: disable=wrong-import-order -from edx_rest_framework_extensions.auth.session.authentication import \ - SessionAuthenticationAllowInactiveUser # lint-amnesty, pylint: disable=wrong-import-order +from edx_rest_framework_extensions.auth.jwt.authentication import ( + JwtAuthentication, +) # lint-amnesty, pylint: disable=wrong-import-order +from edx_rest_framework_extensions.auth.session.authentication import ( + SessionAuthenticationAllowInactiveUser, +) # lint-amnesty, pylint: disable=wrong-import-order from opaque_keys import InvalidKeyError # lint-amnesty, pylint: disable=wrong-import-order from opaque_keys.edx.keys import CourseKey # lint-amnesty, pylint: disable=wrong-import-order from rest_framework import permissions, status # lint-amnesty, pylint: disable=wrong-import-order @@ -27,7 +29,7 @@ from rest_framework.views import APIView # lint-amnesty, pylint: disable=wrong- from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.auth import user_has_role -from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed, User +from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed, EnrollmentNotAllowed, User from common.djangoapps.student.roles import CourseStaffRole, GlobalStaff from common.djangoapps.util.disable_rate_limit import can_disable_rate_limit from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf @@ -38,13 +40,14 @@ from openedx.core.djangoapps.enrollments import api from openedx.core.djangoapps.enrollments.errors import ( CourseEnrollmentError, CourseEnrollmentExistsError, - CourseModeNotFoundError + CourseModeNotFoundError, + InvalidEnrollmentAttribute, ) from openedx.core.djangoapps.enrollments.forms import CourseEnrollmentsApiListForm from openedx.core.djangoapps.enrollments.paginators import CourseEnrollmentsApiListPagination from openedx.core.djangoapps.enrollments.serializers import ( CourseEnrollmentAllowedSerializer, - CourseEnrollmentsApiListSerializer + CourseEnrollmentsApiListSerializer, ) from openedx.core.djangoapps.user_api.accounts.permissions import CanRetireUser from openedx.core.djangoapps.user_api.models import UserRetirementStatus @@ -58,7 +61,7 @@ from openedx.features.enterprise_support.api import ( ConsentApiServiceClient, EnterpriseApiException, EnterpriseApiServiceClient, - enterprise_enabled + enterprise_enabled, ) log = logging.getLogger(__name__) @@ -68,7 +71,8 @@ REQUIRED_ATTRIBUTES = { class EnrollmentCrossDomainSessionAuth(SessionAuthenticationAllowInactiveUser, SessionAuthenticationCrossDomainCsrf): - """Session authentication that allows inactive users and cross-domain requests. """ + """Session authentication that allows inactive users and cross-domain requests.""" + pass # lint-amnesty, pylint: disable=unnecessary-pass @@ -97,15 +101,15 @@ class EnrollmentUserThrottle(UserRateThrottle, ApiKeyPermissionMixIn): # To see how the staff rate limit was selected, see https://github.com/openedx/edx-platform/pull/18360 THROTTLE_RATES = { - 'user': '40/minute', - 'staff': '120/minute', + "user": "40/minute", + "staff": "120/minute", } def allow_request(self, request, view): # Use a special scope for staff to allow for a separate throttle rate user = request.user if user.is_authenticated and (user.is_staff or user.is_superuser): - self.scope = 'staff' + self.scope = "staff" self.rate = self.get_rate() self.num_requests, self.duration = self.parse_rate(self.rate) @@ -115,66 +119,66 @@ class EnrollmentUserThrottle(UserRateThrottle, ApiKeyPermissionMixIn): @can_disable_rate_limit class EnrollmentView(APIView, ApiKeyPermissionMixIn): """ - **Use Case** + **Use Case** - Get the user's enrollment status for a course. + Get the user's enrollment status for a course. - **Example Request** + **Example Request** - GET /api/enrollment/v1/enrollment/{username},{course_id} + GET /api/enrollment/v1/enrollment/{username},{course_id} - **Response Values** + **Response Values** - If the request for information about the user is successful, an HTTP 200 "OK" response - is returned. + If the request for information about the user is successful, an HTTP 200 "OK" response + is returned. - The HTTP 200 response has the following values. + The HTTP 200 response has the following values. - * course_details: A collection that includes the following + * course_details: A collection that includes the following + values. + + * course_end: The date and time when the course closes. If + null, the course never ends. + * course_id: The unique identifier for the course. + * course_name: The name of the course. + * course_modes: An array of data about the enrollment modes + supported for the course. If the request uses the parameter + include_expired=1, the array also includes expired + enrollment modes. + + Each enrollment mode collection includes the following values. - * course_end: The date and time when the course closes. If - null, the course never ends. - * course_id: The unique identifier for the course. - * course_name: The name of the course. - * course_modes: An array of data about the enrollment modes - supported for the course. If the request uses the parameter - include_expired=1, the array also includes expired - enrollment modes. + * currency: The currency of the listed prices. + * description: A description of this mode. + * expiration_datetime: The date and time after which + users cannot enroll in the course in this mode. + * min_price: The minimum price for which a user can + enroll in this mode. + * name: The full name of the enrollment mode. + * slug: The short name for the enrollment mode. + * suggested_prices: A list of suggested prices for + this enrollment mode. - Each enrollment mode collection includes the following - values. + * course_end: The date and time at which the course closes. If + null, the course never ends. + * course_start: The date and time when the course opens. If + null, the course opens immediately when it is created. + * enrollment_end: The date and time after which users cannot + enroll for the course. If null, the enrollment period never + ends. + * enrollment_start: The date and time when users can begin + enrolling in the course. If null, enrollment opens + immediately when the course is created. + * invite_only: A value indicating whether students must be + invited to enroll in the course. Possible values are true or + false. - * currency: The currency of the listed prices. - * description: A description of this mode. - * expiration_datetime: The date and time after which - users cannot enroll in the course in this mode. - * min_price: The minimum price for which a user can - enroll in this mode. - * name: The full name of the enrollment mode. - * slug: The short name for the enrollment mode. - * suggested_prices: A list of suggested prices for - this enrollment mode. - - * course_end: The date and time at which the course closes. If - null, the course never ends. - * course_start: The date and time when the course opens. If - null, the course opens immediately when it is created. - * enrollment_end: The date and time after which users cannot - enroll for the course. If null, the enrollment period never - ends. - * enrollment_start: The date and time when users can begin - enrolling in the course. If null, enrollment opens - immediately when the course is created. - * invite_only: A value indicating whether students must be - invited to enroll in the course. Possible values are true or - false. - - * created: The date the user account was created. - * is_active: Whether the enrollment is currently active. - * mode: The enrollment mode of the user in this course. - * user: The ID of the user. - """ + * created: The date the user account was created. + * is_active: Whether the enrollment is currently active. + * mode: The enrollment mode of the user in this course. + * user: The ID of the user. + """ authentication_classes = ( JwtAuthentication, @@ -207,8 +211,11 @@ class EnrollmentView(APIView, ApiKeyPermissionMixIn): username = username or request.user.username # TODO Implement proper permissions - if request.user.username != username and not self.has_api_key_permissions(request) \ - and not request.user.is_staff: + if ( + request.user.username != username + and not self.has_api_key_permissions(request) + and not request.user.is_staff + ): # Return a 404 instead of a 403 (Unauthorized). If one user is looking up # other users, do not let them deduce the existence of an enrollment. return Response(status=status.HTTP_404_NOT_FOUND) @@ -223,7 +230,7 @@ class EnrollmentView(APIView, ApiKeyPermissionMixIn): "An error occurred while retrieving enrollments for user " "'{username}' in course '{course_id}'" ).format(username=username, course_id=course_id) - } + }, ) @@ -251,6 +258,7 @@ class EnrollmentUserRolesView(APIView): logged-in user, filtered by course_id if given, along with whether or not the user is global staff """ + authentication_classes = ( JwtAuthentication, BearerAuthenticationAllowInactiveUser, @@ -265,7 +273,7 @@ class EnrollmentUserRolesView(APIView): Gets a list of all roles for the currently logged-in user, filtered by course_id if supplied """ try: - course_id = request.GET.get('course_id') + course_id = request.GET.get("course_id") roles_data = api.get_user_roles(request.user.username) if course_id: roles_data = [role for role in roles_data if str(role.course_id) == course_id] @@ -273,85 +281,83 @@ class EnrollmentUserRolesView(APIView): return Response( status=status.HTTP_400_BAD_REQUEST, data={ - "message": ( - "An error occurred while retrieving roles for user '{username}" - ).format(username=request.user.username) - } + "message": ("An error occurred while retrieving roles for user '{username}").format( + username=request.user.username + ) + }, ) - return Response({ - 'roles': [ - { - "org": role.org, - "course_id": str(role.course_id), - "role": role.role - } - for role in roles_data], - 'is_staff': request.user.is_staff, - }) + return Response( + { + "roles": [ + {"org": role.org, "course_id": str(role.course_id), "role": role.role} for role in roles_data + ], + "is_staff": request.user.is_staff, + } + ) @can_disable_rate_limit class EnrollmentCourseDetailView(APIView): """ - **Use Case** + **Use Case** - Get enrollment details for a course. + Get enrollment details for a course. - Response values include the course schedule and enrollment modes - supported by the course. Use the parameter include_expired=1 to - include expired enrollment modes in the response. + Response values include the course schedule and enrollment modes + supported by the course. Use the parameter include_expired=1 to + include expired enrollment modes in the response. - **Note:** Getting enrollment details for a course does not require - authentication. + **Note:** Getting enrollment details for a course does not require + authentication. - **Example Requests** + **Example Requests** - GET /api/enrollment/v1/course/{course_id} + GET /api/enrollment/v1/course/{course_id} - GET /api/enrollment/v1/course/{course_id}?include_expired=1 + GET /api/enrollment/v1/course/{course_id}?include_expired=1 - **Response Values** + **Response Values** - If the request is successful, an HTTP 200 "OK" response is - returned along with a collection of course enrollments for the - user or for the newly created enrollment. + If the request is successful, an HTTP 200 "OK" response is + returned along with a collection of course enrollments for the + user or for the newly created enrollment. - Each course enrollment contains the following values. + Each course enrollment contains the following values. - * course_end: The date and time when the course closes. If - null, the course never ends. - * course_id: The unique identifier for the course. - * course_name: The name of the course. - * course_modes: An array of data about the enrollment modes - supported for the course. If the request uses the parameter - include_expired=1, the array also includes expired - enrollment modes. + * course_end: The date and time when the course closes. If + null, the course never ends. + * course_id: The unique identifier for the course. + * course_name: The name of the course. + * course_modes: An array of data about the enrollment modes + supported for the course. If the request uses the parameter + include_expired=1, the array also includes expired + enrollment modes. - Each enrollment mode collection includes the following - values. + Each enrollment mode collection includes the following + values. - * currency: The currency of the listed prices. - * description: A description of this mode. - * expiration_datetime: The date and time after which - users cannot enroll in the course in this mode. - * min_price: The minimum price for which a user can - enroll in this mode. - * name: The full name of the enrollment mode. - * slug: The short name for the enrollment mode. - * suggested_prices: A list of suggested prices for - this enrollment mode. + * currency: The currency of the listed prices. + * description: A description of this mode. + * expiration_datetime: The date and time after which + users cannot enroll in the course in this mode. + * min_price: The minimum price for which a user can + enroll in this mode. + * name: The full name of the enrollment mode. + * slug: The short name for the enrollment mode. + * suggested_prices: A list of suggested prices for + this enrollment mode. - * course_start: The date and time when the course opens. If - null, the course opens immediately when it is created. - * enrollment_end: The date and time after which users cannot - enroll for the course. If null, the enrollment period never - ends. - * enrollment_start: The date and time when users can begin - enrolling in the course. If null, enrollment opens - immediately when the course is created. - * invite_only: A value indicating whether students must be - invited to enroll in the course. Possible values are true or - false. + * course_start: The date and time when the course opens. If + null, the course opens immediately when it is created. + * enrollment_end: The date and time after which users cannot + enroll for the course. If null, the enrollment period never + ends. + * enrollment_start: The date and time when users can begin + enrolling in the course. If null, enrollment opens + immediately when the course is created. + * invite_only: A value indicating whether students must be + invited to enroll in the course. Possible values are true or + false. """ authentication_classes = [] @@ -374,54 +380,54 @@ class EnrollmentCourseDetailView(APIView): """ try: - return Response(api.get_course_enrollment_details(course_id, bool(request.GET.get('include_expired', '')))) + return Response(api.get_course_enrollment_details(course_id, bool(request.GET.get("include_expired", "")))) except CourseNotFoundError: return Response( status=status.HTTP_400_BAD_REQUEST, - data={ - "message": ( - "No course found for course ID '{course_id}'" - ).format(course_id=course_id) - } + data={"message": ("No course found for course ID '{course_id}'").format(course_id=course_id)}, ) class UnenrollmentView(APIView): """ - **Use Cases** + **Use Cases** - * Unenroll a single user from all courses. + * Unenroll a single user from all courses. - This command can only be issued by a privileged service user. + This command can only be issued by a privileged service user. - **Example Requests** + **Example Requests** - POST /api/enrollment/v1/enrollment { - "username": "username12345" - } + POST /api/enrollment/v1/enrollment { + "username": "username12345" + } - **POST Parameters** + **POST Parameters** - A POST request must include the following parameter. + A POST request must include the following parameter. - * username: The username of the user being unenrolled. - This will never match the username from the request, - since the request is issued as a privileged service user. + * username: The username of the user being unenrolled. + This will never match the username from the request, + since the request is issued as a privileged service user. - **POST Response Values** + **POST Response Values** - If the user has not requested retirement and does not have a retirement - request status, the request returns an HTTP 404 "Does Not Exist" response. + If the user has not requested retirement and does not have a retirement + request status, the request returns an HTTP 404 "Does Not Exist" response. - If the user is already unenrolled from all courses, the request returns - an HTTP 204 "No Content" response. + If the user is already unenrolled from all courses, the request returns + an HTTP 204 "No Content" response. - If an unexpected error occurs, the request returns an HTTP 500 response. + If an unexpected error occurs, the request returns an HTTP 500 response. - If the request is successful, an HTTP 200 "OK" response is - returned along with a list of all courses from which the user was unenrolled. - """ - permission_classes = (permissions.IsAuthenticated, CanRetireUser,) + If the request is successful, an HTTP 200 "OK" response is + returned along with a list of all courses from which the user was unenrolled. + """ + + permission_classes = ( + permissions.IsAuthenticated, + CanRetireUser, + ) def post(self, request): """ @@ -429,18 +435,18 @@ class UnenrollmentView(APIView): """ try: # Get the username from the request. - username = request.data['username'] + username = request.data["username"] # Ensure that a retirement request status row exists for this username. UserRetirementStatus.get_retirement_for_retirement_action(username) enrollments = api.get_enrollments(username) - active_enrollments = [enrollment for enrollment in enrollments if enrollment['is_active']] + active_enrollments = [enrollment for enrollment in enrollments if enrollment["is_active"]] if len(active_enrollments) < 1: return Response(status=status.HTTP_204_NO_CONTENT) return Response(api.unenroll_user_from_all_courses(username)) except KeyError: - return Response('Username not specified.', status=status.HTTP_404_NOT_FOUND) + return Response("Username not specified.", status=status.HTTP_404_NOT_FOUND) except UserRetirementStatus.DoesNotExist: - return Response('No retirement request status for username.', status=status.HTTP_404_NOT_FOUND) + return Response("No retirement request status for username.", status=status.HTTP_404_NOT_FOUND) except Exception as exc: # pylint: disable=broad-except return Response(str(exc), status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -448,177 +454,178 @@ class UnenrollmentView(APIView): @can_disable_rate_limit class EnrollmentListView(APIView, ApiKeyPermissionMixIn): """ - **Use Cases** + **Use Cases** - * Get a list of all course enrollments for the currently signed in user. + * Get a list of all course enrollments for the currently signed in user. - * Enroll the currently signed in user in a course. + * Enroll the currently signed in user in a course. - Currently a user can use this command only to enroll the - user in the default course mode. If this is not - supported for the course, the request fails and returns - the available modes. + Currently a user can use this command only to enroll the + user in the default course mode. If this is not + supported for the course, the request fails and returns + the available modes. - This command can use a server-to-server call to enroll a user in - other modes, such as "verified", "professional", or "credit". If - the mode is not supported for the course, the request will fail - and return the available modes. + This command can use a server-to-server call to enroll a user in + other modes, such as "verified", "professional", or "credit". If + the mode is not supported for the course, the request will fail + and return the available modes. - You can include other parameters as enrollment attributes for a - specific course mode. For example, for credit mode, you can - include the following parameters to specify the credit provider - attribute. + You can include other parameters as enrollment attributes for a + specific course mode. For example, for credit mode, you can + include the following parameters to specify the credit provider + attribute. - * namespace: credit - * name: provider_id - * value: institution_name + * namespace: credit + * name: provider_id + * value: institution_name - **Example Requests** + **Example Requests** - GET /api/enrollment/v1/enrollment + GET /api/enrollment/v1/enrollment - POST /api/enrollment/v1/enrollment { + POST /api/enrollment/v1/enrollment { - "mode": "credit", - "course_details":{"course_id": "edX/DemoX/Demo_Course"}, - "enrollment_attributes":[{"namespace": "credit","name": "provider_id","value": "hogwarts",},] + "mode": "credit", + "course_details":{"course_id": "edX/DemoX/Demo_Course"}, + "enrollment_attributes":[{"namespace": "credit","name": "provider_id","value": "hogwarts",},] - } + } - **POST Parameters** + **POST Parameters** - A POST request can include the following parameters. + A POST request can include the following parameters. - * user: Optional. The username of the currently logged in user. - You cannot use the command to enroll a different user. + * user: Optional. The username of the currently logged in user. + You cannot use the command to enroll a different user. - * mode: Optional. The course mode for the enrollment. Individual - users cannot upgrade their enrollment mode from the default. Only - server-to-server requests can enroll with other modes. + * mode: Optional. The course mode for the enrollment. Individual + users cannot upgrade their enrollment mode from the default. Only + server-to-server requests can enroll with other modes. - * is_active: Optional. A Boolean value indicating whether the - enrollment is active. Only server-to-server requests are - allowed to deactivate an enrollment. + * is_active: Optional. A Boolean value indicating whether the + enrollment is active. Only server-to-server requests are + allowed to deactivate an enrollment. - * course details: A collection that includes the following - information. + * course details: A collection that includes the following + information. - * course_id: The unique identifier for the course. + * course_id: The unique identifier for the course. - * email_opt_in: Optional. A Boolean value that indicates whether - the user wants to receive email from the organization that runs - this course. + * email_opt_in: Optional. A Boolean value that indicates whether + the user wants to receive email from the organization that runs + this course. - * enrollment_attributes: A dictionary that contains the following - values. + * enrollment_attributes: A dictionary that contains the following + values. - * namespace: Namespace of the attribute - * name: Name of the attribute - * value: Value of the attribute + * namespace: Namespace of the attribute + * name: Name of the attribute + * value: Value of the attribute - * is_active: Optional. A Boolean value that indicates whether the - enrollment is active. Only server-to-server requests can - deactivate an enrollment. + * is_active: Optional. A Boolean value that indicates whether the + enrollment is active. Only server-to-server requests can + deactivate an enrollment. - * mode: Optional. The course mode for the enrollment. Individual - users cannot upgrade their enrollment mode from the default. Only - server-to-server requests can enroll with other modes. + * mode: Optional. The course mode for the enrollment. Individual + users cannot upgrade their enrollment mode from the default. Only + server-to-server requests can enroll with other modes. - * user: Optional. The user ID of the currently logged in user. You - cannot use the command to enroll a different user. + * user: Optional. The user ID of the currently logged in user. You + cannot use the command to enroll a different user. - * enterprise_course_consent: Optional. A Boolean value that - indicates the consent status for an EnterpriseCourseEnrollment - to be posted to the Enterprise service. + * enterprise_course_consent: Optional. A Boolean value that + indicates the consent status for an EnterpriseCourseEnrollment + to be posted to the Enterprise service. - **GET Response Values** + **GET Response Values** - If an unspecified error occurs when the user tries to obtain a - learner's enrollments, the request returns an HTTP 400 "Bad - Request" response. + If an unspecified error occurs when the user tries to obtain a + learner's enrollments, the request returns an HTTP 400 "Bad + Request" response. - If the user does not have permission to view enrollment data for - the requested learner, the request returns an HTTP 404 "Not Found" - response. + If the user does not have permission to view enrollment data for + the requested learner, the request returns an HTTP 404 "Not Found" + response. - **POST Response Values** + **POST Response Values** - If the user does not specify a course ID, the specified course - does not exist, or the is_active status is invalid, the request - returns an HTTP 400 "Bad Request" response. + If the user does not specify a course ID, the specified course + does not exist, or the is_active status is invalid, the request + returns an HTTP 400 "Bad Request" response. - If a user who is not an admin tries to upgrade a learner's course - mode, the request returns an HTTP 403 "Forbidden" response. + If a user who is not an admin tries to upgrade a learner's course + mode, the request returns an HTTP 403 "Forbidden" response. - If the specified user does not exist, the request returns an HTTP - 406 "Not Acceptable" response. + If the specified user does not exist, the request returns an HTTP + 406 "Not Acceptable" response. - **GET and POST Response Values** + **GET and POST Response Values** - If the request is successful, an HTTP 200 "OK" response is - returned along with a collection of course enrollments for the - user or for the newly created enrollment. + If the request is successful, an HTTP 200 "OK" response is + returned along with a collection of course enrollments for the + user or for the newly created enrollment. - Each course enrollment contains the following values. + Each course enrollment contains the following values. - * course_details: A collection that includes the following + * course_details: A collection that includes the following + values. + + * course_end: The date and time when the course closes. If + null, the course never ends. + + * course_id: The unique identifier for the course. + + * course_name: The name of the course. + + * course_modes: An array of data about the enrollment modes + supported for the course. If the request uses the parameter + include_expired=1, the array also includes expired + enrollment modes. + + Each enrollment mode collection includes the following values. - * course_end: The date and time when the course closes. If - null, the course never ends. + * currency: The currency of the listed prices. - * course_id: The unique identifier for the course. + * description: A description of this mode. - * course_name: The name of the course. + * expiration_datetime: The date and time after which users + cannot enroll in the course in this mode. - * course_modes: An array of data about the enrollment modes - supported for the course. If the request uses the parameter - include_expired=1, the array also includes expired - enrollment modes. + * min_price: The minimum price for which a user can enroll in + this mode. - Each enrollment mode collection includes the following - values. + * name: The full name of the enrollment mode. - * currency: The currency of the listed prices. + * slug: The short name for the enrollment mode. - * description: A description of this mode. + * suggested_prices: A list of suggested prices for this + enrollment mode. - * expiration_datetime: The date and time after which users - cannot enroll in the course in this mode. + * course_start: The date and time when the course opens. If + null, the course opens immediately when it is created. - * min_price: The minimum price for which a user can enroll in - this mode. + * enrollment_end: The date and time after which users cannot + enroll for the course. If null, the enrollment period never + ends. - * name: The full name of the enrollment mode. + * enrollment_start: The date and time when users can begin + enrolling in the course. If null, enrollment opens + immediately when the course is created. - * slug: The short name for the enrollment mode. + * invite_only: A value indicating whether students must be + invited to enroll in the course. Possible values are true or + false. - * suggested_prices: A list of suggested prices for this - enrollment mode. + * created: The date the user account was created. - * course_start: The date and time when the course opens. If - null, the course opens immediately when it is created. + * is_active: Whether the enrollment is currently active. - * enrollment_end: The date and time after which users cannot - enroll for the course. If null, the enrollment period never - ends. + * mode: The enrollment mode of the user in this course. - * enrollment_start: The date and time when users can begin - enrolling in the course. If null, enrollment opens - immediately when the course is created. - - * invite_only: A value indicating whether students must be - invited to enroll in the course. Possible values are true or - false. - - * created: The date the user account was created. - - * is_active: Whether the enrollment is currently active. - - * mode: The enrollment mode of the user in this course. - - * user: The username of the user. + * user: The username of the user. """ + authentication_classes = ( JwtAuthentication, BearerAuthenticationAllowInactiveUser, @@ -648,20 +655,23 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): Users who have the global staff permission can access all enrollment data for all courses. """ - username = request.GET.get('user', request.user.username) + username = request.GET.get("user", request.user.username) try: enrollment_data = api.get_enrollments(username) except CourseEnrollmentError: return Response( status=status.HTTP_400_BAD_REQUEST, data={ - "message": ( - "An error occurred while retrieving enrollments for user '{username}'" - ).format(username=username) - } + "message": ("An error occurred while retrieving enrollments for user '{username}'").format( + username=username + ) + }, ) - if username == request.user.username or GlobalStaff().has_user(request.user) or \ - self.has_api_key_permissions(request): + if ( + username == request.user.username + or GlobalStaff().has_user(request.user) + or self.has_api_key_permissions(request) + ): return Response(enrollment_data) filtered_data = [] for enrollment in enrollment_data: @@ -679,32 +689,33 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): """ # Get the User, Course ID, and Mode from the request. - username = request.data.get('user') - course_id = request.data.get('course_details', {}).get('course_id') + username = request.data.get("user") + course_id = request.data.get("course_details", {}).get("course_id") if not course_id: return Response( status=status.HTTP_400_BAD_REQUEST, - data={"message": "Course ID must be specified to create a new enrollment."} + data={"message": "Course ID must be specified to create a new enrollment."}, ) try: course_id = CourseKey.from_string(course_id) except InvalidKeyError: return Response( - status=status.HTTP_400_BAD_REQUEST, - data={ - "message": f"No course '{course_id}' found for enrollment" - } + status=status.HTTP_400_BAD_REQUEST, data={"message": f"No course '{course_id}' found for enrollment"} ) - mode = request.data.get('mode') + mode = request.data.get("mode") has_api_key_permissions = self.has_api_key_permissions(request) # Check that the user specified is either the same user, or this is a server-to-server request. - if username and username != request.user.username and not has_api_key_permissions \ - and not GlobalStaff().has_user(request.user): + if ( + username + and username != request.user.username + and not has_api_key_permissions + and not GlobalStaff().has_user(request.user) + ): # Return a 404 instead of a 403 (Unauthorized). If one user is looking up # other users, do not let them deduce the existence of an enrollment. return Response(status=status.HTTP_404_NOT_FOUND) @@ -712,7 +723,7 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): # A provided user has priority over a provided email. # Fallback on request user if neither is provided. if not username: - email = request.data.get('email') + email = request.data.get("email") if email: # Only server-to-server or staff users can use the email for the request. if not has_api_key_permissions and not GlobalStaff().has_user(request.user): @@ -722,22 +733,23 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): except ObjectDoesNotExist: return Response( status=status.HTTP_406_NOT_ACCEPTABLE, - data={ - 'message': f'The user with the email address {email} does not exist.' - } + data={"message": f"The user with the email address {email} does not exist."}, ) else: username = request.user.username - if mode not in (CourseMode.AUDIT, CourseMode.HONOR, None) and not has_api_key_permissions \ - and not GlobalStaff().has_user(request.user): + if ( + mode not in (CourseMode.AUDIT, CourseMode.HONOR, None) + and not has_api_key_permissions + and not GlobalStaff().has_user(request.user) + ): return Response( status=status.HTTP_403_FORBIDDEN, data={ "message": "User does not have permission to create enrollment with mode [{mode}].".format( mode=mode ) - } + }, ) try: @@ -745,10 +757,7 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): user = User.objects.get(username=username) except ObjectDoesNotExist: return Response( - status=status.HTTP_406_NOT_ACCEPTABLE, - data={ - 'message': f'The user {username} does not exist.' - } + status=status.HTTP_406_NOT_ACCEPTABLE, data={"message": f"The user {username} does not exist."} ) embargo_response = embargo_api.get_embargo_response(request, course_id, user) @@ -757,54 +766,53 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): return embargo_response try: - is_active = request.data.get('is_active') + is_active = request.data.get("is_active") # Check if the requested activation status is None or a Boolean if is_active is not None and not isinstance(is_active, bool): return Response( status=status.HTTP_400_BAD_REQUEST, - data={ - 'message': ("'{value}' is an invalid enrollment activation status.").format(value=is_active) - } + data={"message": ("'{value}' is an invalid enrollment activation status.").format(value=is_active)}, ) - explicit_linked_enterprise = request.data.get('linked_enterprise_customer') + explicit_linked_enterprise = request.data.get("linked_enterprise_customer") if explicit_linked_enterprise and has_api_key_permissions and enterprise_enabled(): enterprise_api_client = EnterpriseApiServiceClient() consent_client = ConsentApiServiceClient() try: enterprise_api_client.post_enterprise_course_enrollment(username, str(course_id)) except EnterpriseApiException as error: - log.exception("An unexpected error occurred while creating the new EnterpriseCourseEnrollment " - "for user [%s] in course run [%s]", username, course_id) + log.exception( + "An unexpected error occurred while creating the new EnterpriseCourseEnrollment " + "for user [%s] in course run [%s]", + username, + course_id, + ) raise CourseEnrollmentError(str(error)) # lint-amnesty, pylint: disable=raise-missing-from kwargs = { - 'username': username, - 'course_id': str(course_id), - 'enterprise_customer_uuid': explicit_linked_enterprise, + "username": username, + "course_id": str(course_id), + "enterprise_customer_uuid": explicit_linked_enterprise, } consent_client.provide_consent(**kwargs) - enrollment_attributes = request.data.get('enrollment_attributes') - force_enrollment = request.data.get('force_enrollment') + enrollment_attributes = request.data.get("enrollment_attributes") + force_enrollment = request.data.get("force_enrollment") # Check if the force enrollment status is None or a Boolean if force_enrollment is not None and not isinstance(force_enrollment, bool): return Response( status=status.HTTP_400_BAD_REQUEST, data={ - 'message': ("'{value}' is an invalid force enrollment status.").format(value=force_enrollment) - } + "message": ("'{value}' is an invalid force enrollment status.").format(value=force_enrollment) + }, ) # Only a staff user role can enroll a user forcefully force_enrollment = force_enrollment and GlobalStaff().has_user(request.user) enrollment = api.get_enrollment(username, str(course_id)) - mode_changed = enrollment and mode is not None and enrollment['mode'] != mode - active_changed = enrollment and is_active is not None and enrollment['is_active'] != is_active + mode_changed = enrollment and mode is not None and enrollment["mode"] != mode + active_changed = enrollment and is_active is not None and enrollment["is_active"] != is_active missing_attrs = [] if enrollment_attributes: - actual_attrs = [ - "{namespace}:{name}".format(**attr) - for attr in enrollment_attributes - ] + actual_attrs = ["{namespace}:{name}".format(**attr) for attr in enrollment_attributes] missing_attrs = set(REQUIRED_ATTRIBUTES.get(mode, [])) - set(actual_attrs) if (GlobalStaff().has_user(request.user) or has_api_key_permissions) and (mode_changed or active_changed): if mode_changed and active_changed and not is_active: @@ -831,7 +839,7 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): is_active=is_active, enrollment_attributes=enrollment_attributes, # If we are updating enrollment by authorized api caller, we should allow expired modes - include_expired=has_api_key_permissions + include_expired=has_api_key_permissions, ) else: # Will reactivate inactive enrollments. @@ -841,27 +849,44 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): mode=mode, is_active=is_active, enrollment_attributes=enrollment_attributes, - enterprise_uuid=request.data.get('enterprise_uuid'), + enterprise_uuid=request.data.get("enterprise_uuid"), force_enrollment=force_enrollment, # If we are creating enrollment by staff user with force_enrollment, we should allow expired modes - include_expired=force_enrollment + include_expired=force_enrollment, ) - cohort_name = request.data.get('cohort') + cohort_name = request.data.get("cohort") if cohort_name is not None: cohort = get_cohort_by_name(course_id, cohort_name) try: add_user_to_cohort(cohort, user) except ValueError: # user already in cohort, probably because they were un-enrolled and re-enrolled - log.exception('Cohort re-addition') - email_opt_in = request.data.get('email_opt_in', None) + log.exception("Cohort re-addition") + email_opt_in = request.data.get("email_opt_in", None) if email_opt_in is not None: org = course_id.org update_email_opt_in(request.user, org, email_opt_in) - log.info('The user [%s] has already been enrolled in course run [%s].', username, course_id) + log.info("The user [%s] has already been enrolled in course run [%s].", username, course_id) return Response(response) + + except InvalidEnrollmentAttribute as error: + return Response( + status=status.HTTP_400_BAD_REQUEST, + data={ + "message": str(error), + "localizedMessage": str(error), + } + ) + except EnrollmentNotAllowed as error: + return Response( + status=status.HTTP_403_FORBIDDEN, + data={ + "message": str(error), + "localizedMessage": str(error), + } + ) except CourseModeNotFoundError as error: return Response( status=status.HTTP_400_BAD_REQUEST, @@ -869,21 +894,22 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): "message": ( "The [{mode}] course mode is expired or otherwise unavailable for course run [{course_id}]." ).format(mode=mode, course_id=course_id), - "course_details": error.data - }) + "course_details": error.data, + }, + ) except CourseNotFoundError: return Response( - status=status.HTTP_400_BAD_REQUEST, - data={ - "message": f"No course '{course_id}' found for enrollment" - } + status=status.HTTP_400_BAD_REQUEST, data={"message": f"No course '{course_id}' found for enrollment"} ) except CourseEnrollmentExistsError as error: - log.warning('An enrollment already exists for user [%s] in course run [%s].', username, course_id) + log.warning("An enrollment already exists for user [%s] in course run [%s].", username, course_id) return Response(data=error.enrollment) except CourseEnrollmentError: - log.exception("An error occurred while creating the new course enrollment for user " - "[%s] in course run [%s]", username, course_id) + log.exception( + "An error occurred while creating the new course enrollment for user [%s] in course run [%s]", + username, + course_id, + ) return Response( status=status.HTTP_400_BAD_REQUEST, data={ @@ -891,100 +917,107 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): "An error occurred while creating the new course enrollment for user " "'{username}' in course '{course_id}'" ).format(username=username, course_id=course_id) - } + }, ) + except CourseUserGroup.DoesNotExist: - log.exception('Missing cohort [%s] in course run [%s]', cohort_name, course_id) + log.exception("Missing cohort [%s] in course run [%s]", cohort_name, course_id) return Response( status=status.HTTP_400_BAD_REQUEST, - data={ - "message": "An error occured while adding to cohort [%s]" % cohort_name - }) + data={"message": "An error occured while adding to cohort [%s]" % cohort_name}, + ) finally: # Assumes that the ecommerce service uses an API key to authenticate. if has_api_key_permissions: current_enrollment = api.get_enrollment(username, str(course_id)) audit_log( - 'enrollment_change_requested', + "enrollment_change_requested", course_id=str(course_id), requested_mode=mode, - actual_mode=current_enrollment['mode'] if current_enrollment else None, + actual_mode=current_enrollment["mode"] if current_enrollment else None, requested_activation=is_active, - actual_activation=current_enrollment['is_active'] if current_enrollment else None, - user_id=user.id + actual_activation=current_enrollment["is_active"] if current_enrollment else None, + user_id=user.id, ) @can_disable_rate_limit class CourseEnrollmentsApiListView(DeveloperErrorViewMixin, ListAPIView): """ - **Use Cases** + **Use Cases** - Get a list of all course enrollments, optionally filtered by a course ID or list of usernames. + Get a list of all course enrollments, optionally filtered by a course ID or list of usernames. - **Example Requests** + **Example Requests** - GET /api/enrollment/v1/enrollments + GET /api/enrollment/v1/enrollments - GET /api/enrollment/v1/enrollments?course_id={course_id} + GET /api/enrollment/v1/enrollments?course_id={course_id} - GET /api/enrollment/v1/enrollments?username={username},{username},{username} + GET /api/enrollment/v1/enrollments?course_ids={course_id},{course_id},{course_id} - GET /api/enrollment/v1/enrollments?course_id={course_id}&username={username} + GET /api/enrollment/v1/enrollments?username={username},{username},{username} - GET /api/enrollment/v1/enrollments?email={email},{email} + GET /api/enrollment/v1/enrollments?course_id={course_id}&username={username} - **Query Parameters for GET** + GET /api/enrollment/v1/enrollments?email={email},{email} - * course_id: Filters the result to course enrollments for the course corresponding to the - given course ID. The value must be URL encoded. Optional. + **Query Parameters for GET** - * username: List of comma-separated usernames. Filters the result to the course enrollments - of the given users. Optional. + * course_id: Filters the result to course enrollments for the course corresponding to the + given course ID. The value must be URL encoded. Optional. - * email: List of comma-separated emails. Filters the result to the course enrollments - of the given users. Optional. + * course_ids: List of comma-separated course IDs. Filters the result to course enrollments + for the courses corresponding to the given course IDs. Course IDs could be course run IDs + or course IDs. The value must be URL encoded. Optional. - * page_size: Number of results to return per page. Optional. + * username: List of comma-separated usernames. Filters the result to the course enrollments + of the given users. Optional. - * page: Page number to retrieve. Optional. + * email: List of comma-separated emails. Filters the result to the course enrollments + of the given users. Optional. - **Response Values** + * page_size: Number of results to return per page. Optional. - If the request for information about the course enrollments is successful, an HTTP 200 "OK" response - is returned. + * page: Page number to retrieve. Optional. - The HTTP 200 response has the following values. + **Response Values** - * results: A list of the course enrollments matching the request. + If the request for information about the course enrollments is successful, an HTTP 200 "OK" response + is returned. - * created: Date and time when the course enrollment was created. + The HTTP 200 response has the following values. - * mode: Mode for the course enrollment. + * results: A list of the course enrollments matching the request. - * is_active: Whether the course enrollment is active or not. + * created: Date and time when the course enrollment was created. - * user: Username of the user in the course enrollment. + * mode: Mode for the course enrollment. - * course_id: Course ID of the course in the course enrollment. + * is_active: Whether the course enrollment is active or not. - * next: The URL to the next page of results, or null if this is the - last page. + * user: Username of the user in the course enrollment. - * previous: The URL to the next page of results, or null if this - is the first page. + * course_id: Course ID of the course in the course enrollment. - If the user is not logged in, a 401 error is returned. + * next: The URL to the next page of results, or null if this is the + last page. - If the user is not global staff, a 403 error is returned. + * previous: The URL to the next page of results, or null if this + is the first page. - If the specified course_id is not valid or any of the specified usernames - are not valid, a 400 error is returned. + If the user is not logged in, a 401 error is returned. - If the specified course_id does not correspond to a valid course or if all the specified - usernames do not correspond to valid users, an HTTP 200 "OK" response is returned with an - empty 'results' field. + If the user is not global staff, a 403 error is returned. + + If the specified course_id is not valid or any of the specified usernames + are not valid, a 400 error is returned. + + If the specified course_id does not correspond to a valid course or if all the specified + usernames do not correspond to valid users, an HTTP 200 "OK" response is returned with an + empty 'results' field. """ + authentication_classes = ( JwtAuthentication, BearerAuthenticationAllowInactiveUser, @@ -1004,13 +1037,20 @@ class CourseEnrollmentsApiListView(DeveloperErrorViewMixin, ListAPIView): if not form.is_valid(): raise ValidationError(form.errors) - queryset = CourseEnrollment.objects.all() - course_id = form.cleaned_data.get('course_id') - usernames = form.cleaned_data.get('username') - emails = form.cleaned_data.get('email') + queryset = CourseEnrollment.objects.all().select_related("user", "course") + course_id = form.cleaned_data.get("course_id") + course_ids = form.cleaned_data.get("course_ids") + usernames = form.cleaned_data.get("username") + emails = form.cleaned_data.get("email") if course_id: - queryset = queryset.filter(course_id=course_id) + queryset = queryset.filter(course__id=course_id) + if course_ids: + # Handles the case if parent course ID is sent rather than course run ID + query = Q() + for cid in course_ids: + query |= Q(course__id__icontains=cid) + queryset = queryset.filter(query) if usernames: queryset = queryset.filter(user__username__in=usernames) if emails: @@ -1022,6 +1062,7 @@ class EnrollmentAllowedView(APIView): """ A view that allows the retrieval and creation of enrollment allowed for a given user email and course id. """ + permission_classes = (permissions.IsAdminUser,) throttle_classes = (EnrollmentUserThrottle,) serializer_class = CourseEnrollmentAllowedSerializer @@ -1042,7 +1083,7 @@ class EnrollmentAllowedView(APIView): - 200: Success. - 403: Forbidden, you need to be staff. """ - user_email = request.query_params.get('email') + user_email = request.query_params.get("email") if not user_email: user_email = request.user.email @@ -1051,10 +1092,7 @@ class EnrollmentAllowedView(APIView): CourseEnrollmentAllowedSerializer(enrollment).data for enrollment in enrollments_allowed ] - return Response( - status=status.HTTP_200_OK, - data=serialized_enrollments_allowed - ) + return Response(status=status.HTTP_200_OK, data=serialized_enrollments_allowed) def post(self, request): """ @@ -1089,29 +1127,22 @@ class EnrollmentAllowedView(APIView): - 409: Conflict, enrollment allowed already exists. """ is_bad_request_response, email, course_id = self.check_required_data(request) - auto_enroll = request.data.get('auto_enroll', False) + auto_enroll = request.data.get("auto_enroll", False) if is_bad_request_response: return is_bad_request_response try: enrollment_allowed = CourseEnrollmentAllowed.objects.create( - email=email, - course_id=course_id, - auto_enroll=auto_enroll + email=email, course_id=course_id, auto_enroll=auto_enroll ) except IntegrityError: return Response( status=status.HTTP_409_CONFLICT, - data={ - 'message': f'An enrollment allowed with email {email} and course {course_id} already exists.' - } + data={"message": f"An enrollment allowed with email {email} and course {course_id} already exists."}, ) serializer = CourseEnrollmentAllowedSerializer(enrollment_allowed) - return Response( - status=status.HTTP_201_CREATED, - data=serializer.data - ) + return Response(status=status.HTTP_201_CREATED, data=serializer.data) def delete(self, request): """ @@ -1148,33 +1179,27 @@ class EnrollmentAllowedView(APIView): return is_bad_request_response try: - CourseEnrollmentAllowed.objects.get( - email=email, - course_id=course_id - ).delete() + CourseEnrollmentAllowed.objects.get(email=email, course_id=course_id).delete() return Response( status=status.HTTP_204_NO_CONTENT, ) except ObjectDoesNotExist: return Response( status=status.HTTP_404_NOT_FOUND, - data={ - 'message': f"An enrollment allowed with email {email} and course {course_id} doesn't exists." - } + data={"message": f"An enrollment allowed with email {email} and course {course_id} doesn't exists."}, ) def check_required_data(self, request): """ Check if the request has email and course_id. """ - email = request.data.get('email') - course_id = request.data.get('course_id') + email = request.data.get("email") + course_id = request.data.get("course_id") if not email or not course_id: is_bad_request = Response( status=status.HTTP_400_BAD_REQUEST, - data={ - "message": "Please provide a value for 'email' and 'course_id' in the request data." - }) + data={"message": "Please provide a value for 'email' and 'course_id' in the request data."}, + ) else: is_bad_request = None return (is_bad_request, email, course_id) diff --git a/openedx/core/djangoapps/monkey_patch/__init__.py b/openedx/core/djangoapps/monkey_patch/__init__.py index ed4b9fc261..1a28af9257 100644 --- a/openedx/core/djangoapps/monkey_patch/__init__.py +++ b/openedx/core/djangoapps/monkey_patch/__init__.py @@ -6,9 +6,8 @@ Here be dragons (and simians!) * USE WITH CAUTION * No, but seriously, you probably never really want to make changes here. This module contains methods to monkey-patch [0] the edx-platform. -Patches are to be applied as early as possible in the callstack -(currently lms/startup.py and cms/startup.py). Consequently, changes -made here will affect the entire platform. +Patches are to be applied as early as possible in the callstack). Consequently, +changes made here will affect the entire platform. That said, if you've decided you really need to monkey-patch the platform (and you've convinced enough people that this is best @@ -25,8 +24,7 @@ solution), kindly follow these guidelines: - is_patched - patch - unpatch - - Add the following code where needed (typically cms/startup.py and - lms/startup.py): + - Add the following code where needed: ``` from openedx.core.djangoapps.monkey_patch import your_module your_module.patch() diff --git a/openedx/core/djangoapps/notifications/admin.py b/openedx/core/djangoapps/notifications/admin.py index 82524e4b08..a57d3458e9 100644 --- a/openedx/core/djangoapps/notifications/admin.py +++ b/openedx/core/djangoapps/notifications/admin.py @@ -82,6 +82,10 @@ class CourseNotificationPreferenceAdmin(admin.ModelAdmin): def get_username(self, obj): return obj.user.username + def get_queryset(self, request): + queryset = super().get_queryset(request) + return queryset.select_related("user").only("id", "user__username", "course_id") + def get_search_results(self, request, queryset, search_term): """ Custom search for CourseNotificationPreference model diff --git a/openedx/core/djangoapps/notifications/base_notification.py b/openedx/core/djangoapps/notifications/base_notification.py index 91a1635aec..0592e4aff5 100644 --- a/openedx/core/djangoapps/notifications/base_notification.py +++ b/openedx/core/djangoapps/notifications/base_notification.py @@ -31,8 +31,6 @@ COURSE_NOTIFICATION_TYPES = { 'is_core': True, 'content_template': _('<{p}><{strong}>{replier_name} commented on <{strong}>{author_name}' ' response to your post <{strong}>{post_title}'), - 'grouped_content_template': _('<{p}><{strong}>{replier_name} commented on <{strong}>{author_name}' - ' response to your post <{strong}>{post_title}'), 'content_context': { 'post_title': 'Post title', 'author_name': 'author name', @@ -63,7 +61,7 @@ COURSE_NOTIFICATION_TYPES = { 'email': False, 'email_cadence': EmailCadence.DAILY, 'push': False, - 'non_editable': [], + 'non_editable': ['push'], 'content_template': _('<{p}><{strong}>{username} posted <{strong}>{post_title}'), 'grouped_content_template': _('<{p}><{strong}>{replier_name} and others started new discussions' ''), @@ -83,7 +81,7 @@ COURSE_NOTIFICATION_TYPES = { 'email': False, 'email_cadence': EmailCadence.DAILY, 'push': False, - 'non_editable': [], + 'non_editable': ['push'], 'content_template': _('<{p}><{strong}>{username} asked <{strong}>{post_title}'), 'content_context': { 'post_title': 'Post title', @@ -132,8 +130,8 @@ COURSE_NOTIFICATION_TYPES = { 'web': True, 'email': True, 'email_cadence': EmailCadence.DAILY, - 'push': True, - 'non_editable': [], + 'push': False, + 'non_editable': ['push'], 'content_template': _('

    {username}’s {content_type} has been reported {' 'content}

    '), @@ -181,9 +179,9 @@ COURSE_NOTIFICATION_TYPES = { 'info': '', 'web': True, 'email': False, - 'push': True, + 'push': False, 'email_cadence': EmailCadence.DAILY, - 'non_editable': [], + 'non_editable': ['push'], 'content_template': _('<{p}><{strong}>{course_update_content}'), 'content_context': { 'course_update_content': 'Course update', @@ -191,18 +189,20 @@ COURSE_NOTIFICATION_TYPES = { 'email_template': '', 'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE] }, - 'ora_staff_notification': { + 'ora_staff_notifications': { 'notification_app': 'grading', - 'name': 'ora_staff_notification', + 'name': 'ora_staff_notifications', 'is_core': False, - 'info': '', - 'web': False, + 'info': 'Notifications for when a submission is made for ORA that includes staff grading step.', + 'web': True, 'email': False, 'push': False, 'email_cadence': EmailCadence.DAILY, - 'non_editable': [], - 'content_template': _('<{p}>You have a new open response submission awaiting for review for ' + 'non_editable': ['push'], + 'content_template': _('<{p}>You have a new open response submission awaiting review for ' '<{strong}>{ora_name}'), + 'grouped_content_template': _('<{p}>You have multiple submissions awaiting review for ' + '<{strong}>{ora_name}'), 'content_context': { 'ora_name': 'Name of ORA in course', }, @@ -219,7 +219,7 @@ COURSE_NOTIFICATION_TYPES = { 'email': True, 'push': False, 'email_cadence': EmailCadence.DAILY, - 'non_editable': [], + 'non_editable': ['push'], 'content_template': _('<{p}>You have received {points_earned} out of {points_possible} on your assessment: ' '<{strong}>{ora_name}'), 'content_context': { @@ -230,6 +230,44 @@ COURSE_NOTIFICATION_TYPES = { 'email_template': '', 'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE], }, + 'new_instructor_all_learners_post': { + 'notification_app': 'discussion', + 'name': 'new_instructor_all_learners_post', + 'is_core': False, + 'info': '', + 'web': True, + 'email': False, + 'email_cadence': EmailCadence.DAILY, + 'push': False, + 'non_editable': ['push'], + 'content_template': _('<{p}>Your instructor posted <{strong}>{post_title}'), + 'grouped_content_template': '', + 'content_context': { + 'post_title': 'Post title', + }, + 'email_template': '', + 'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE] + }, + 'audit_access_expiring_soon': { + 'notification_app': 'enrollments', + 'name': 'audit_access_expiring_soon', + 'is_core': False, + 'info': '', + 'web': True, + 'email': False, + 'email_cadence': EmailCadence.DAILY, + 'push': False, + 'non_editable': [], + 'content_template': _('<{p}>Your audit access for <{strong}>{course_name} is expiring on ' + '<{strong}>{audit_access_expiry}. ' + 'Upgrade now to extend access and get a certificate!.'), + 'content_context': { + 'course_name': 'Course name', + 'audit_access_expiry': 'Audit access expiry date', + }, + 'email_template': '', + 'filters': [FILTER_AUDIT_EXPIRED_USERS_WITH_NO_ROLE], + }, } COURSE_NOTIFICATION_APPS = { @@ -241,7 +279,7 @@ COURSE_NOTIFICATION_APPS = { 'core_email': True, 'core_push': True, 'core_email_cadence': EmailCadence.DAILY, - 'non_editable': ['web'] + 'non_editable': [] }, 'updates': { 'enabled': True, @@ -261,6 +299,15 @@ COURSE_NOTIFICATION_APPS = { 'core_email_cadence': EmailCadence.DAILY, 'non_editable': [] }, + 'enrollments': { + 'enabled': True, + 'core_info': _('Notifications for enrollments.'), + 'core_web': True, + 'core_email': True, + 'core_push': True, + 'core_email_cadence': EmailCadence.DAILY, + 'non_editable': [] + } } @@ -330,7 +377,7 @@ class NotificationPreferenceSyncManager: return denormalized_preferences @staticmethod - def update_preferences(preferences): + def update_preferences(preferences, email_opt_out=False): """ Creates a new preference version from old preferences. New preference is created instead of updating old preference @@ -347,7 +394,7 @@ class NotificationPreferenceSyncManager: 5) Denormalize new preference """ old_preferences = NotificationPreferenceSyncManager.normalize_preferences(preferences) - default_prefs = NotificationAppManager().get_notification_app_preferences() + default_prefs = NotificationAppManager().get_notification_app_preferences(email_opt_out) new_prefs = NotificationPreferenceSyncManager.normalize_preferences(default_prefs) for app in new_prefs.get('apps'): @@ -409,7 +456,7 @@ class NotificationTypeManager: return non_editable_notification_channels @staticmethod - def get_non_core_notification_type_preferences(non_core_notification_types): + def get_non_core_notification_type_preferences(non_core_notification_types, email_opt_out=False): """ Returns non-core notification type preferences for the given notification types. """ @@ -417,13 +464,13 @@ class NotificationTypeManager: for notification_type in non_core_notification_types: non_core_notification_type_preferences[notification_type.get('name')] = { 'web': notification_type.get('web', False), - 'email': notification_type.get('email', False), + 'email': False if email_opt_out else notification_type.get('email', False), 'push': notification_type.get('push', False), 'email_cadence': notification_type.get('email_cadence', 'Daily'), } return non_core_notification_type_preferences - def get_notification_app_preference(self, notification_app): + def get_notification_app_preference(self, notification_app, email_opt_out=False): """ Returns notification app preferences for the given notification app. """ @@ -431,11 +478,10 @@ class NotificationTypeManager: notification_app, ) non_core_notification_types_preferences = self.get_non_core_notification_type_preferences( - non_core_notification_types, + non_core_notification_types, email_opt_out ) - non_editable_notification_channels = self.get_non_editable_notification_channels(non_core_notification_types) core_notification_types_name = [notification_type.get('name') for notification_type in core_notification_types] - return non_core_notification_types_preferences, core_notification_types_name, non_editable_notification_channels + return non_core_notification_types_preferences, core_notification_types_name class NotificationAppManager: @@ -443,13 +489,13 @@ class NotificationAppManager: Notification app manager """ - def add_core_notification_preference(self, notification_app_attrs, notification_types): + def add_core_notification_preference(self, notification_app_attrs, notification_types, email_opt_out=False): """ Adds core notification preference for the given notification app. """ notification_types['core'] = { 'web': notification_app_attrs.get('core_web', False), - 'email': notification_app_attrs.get('core_email', False), + 'email': False if email_opt_out else notification_app_attrs.get('core_email', False), 'push': notification_app_attrs.get('core_push', False), 'email_cadence': notification_app_attrs.get('core_email_cadence', 'Daily'), } @@ -461,22 +507,22 @@ class NotificationAppManager: if notification_app_attrs.get('non_editable', None): non_editable_channels['core'] = notification_app_attrs.get('non_editable') - def get_notification_app_preferences(self): + def get_notification_app_preferences(self, email_opt_out=False): """ Returns notification app preferences for the given name. """ course_notification_preference_config = {} for notification_app_key, notification_app_attrs in COURSE_NOTIFICATION_APPS.items(): notification_app_preferences = {} - notification_types, core_notifications, \ - non_editable_channels = NotificationTypeManager().get_notification_app_preference(notification_app_key) - self.add_core_notification_preference(notification_app_attrs, notification_types) - self.add_core_notification_non_editable(notification_app_attrs, non_editable_channels) + notification_types, core_notifications = NotificationTypeManager().get_notification_app_preference( + notification_app_key, + email_opt_out + ) + self.add_core_notification_preference(notification_app_attrs, notification_types, email_opt_out) notification_app_preferences['enabled'] = notification_app_attrs.get('enabled', False) notification_app_preferences['core_notification_types'] = core_notifications notification_app_preferences['notification_types'] = notification_types - notification_app_preferences['non_editable'] = non_editable_channels course_notification_preference_config[notification_app_key] = notification_app_preferences return course_notification_preference_config diff --git a/openedx/core/djangoapps/notifications/config/waffle.py b/openedx/core/djangoapps/notifications/config/waffle.py index b74cd84dca..84ef7c723f 100644 --- a/openedx/core/djangoapps/notifications/config/waffle.py +++ b/openedx/core/djangoapps/notifications/config/waffle.py @@ -50,12 +50,33 @@ ENABLE_ORA_GRADE_NOTIFICATION = CourseWaffleFlag(f"{WAFFLE_NAMESPACE}.enable_ora # .. toggle_tickets: INF-1472 ENABLE_NOTIFICATION_GROUPING = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_notification_grouping', __name__) -# .. toggle_name: notifications.enable_new_notification_view -# .. toggle_implementation: WaffleFlag +# .. toggle_name: notifications.post_enable_notify_all_learners +# .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False -# .. toggle_description: Waffle flag to enable new notification view +# .. toggle_description: Waffle flag to enable the notify all learners on discussion post +# .. toggle_use_cases: open_edx +# .. toggle_creation_date: 2025-06-11 +# .. toggle_warning: When the flag is ON, notification to all learners feature is enabled on discussion post. +# .. toggle_tickets: INF-1917 +ENABLE_NOTIFY_ALL_LEARNERS = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_post_notify_all_learners', __name__) + +# .. toggle_name: notifications.enable_push_notifications +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable push Notifications feature on mobile devices +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2025-05-27 +# .. toggle_target_removal_date: 2026-05-27 +# .. toggle_warning: When the flag is ON, Notifications will go through ace push channels. +ENABLE_PUSH_NOTIFICATIONS = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_push_notifications', __name__) + +# .. toggle_name: notifications.enable_account_level_preferences +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable account level preferences for notifications # .. toggle_use_cases: temporary, open_edx -# .. toggle_creation_date: 2024-09-30 -# .. toggle_target_removal_date: 2025-10-10 -# .. toggle_tickets: INF-1603 -ENABLE_NEW_NOTIFICATION_VIEW = WaffleFlag(f"{WAFFLE_NAMESPACE}.enable_new_notification_view", __name__) +# .. toggle_creation_date: 2025-04-29 +# .. toggle_target_removal_date: 2025-07-29 +# .. toggle_warning: When the flag is ON, account level preferences for notifications are enabled. +# .. toggle_tickets: INF-1472 +ENABLE_ACCOUNT_LEVEL_PREFERENCES = WaffleFlag(f'{WAFFLE_NAMESPACE}.enable_account_level_preferences', __name__) diff --git a/openedx/core/djangoapps/notifications/email/__init__.py b/openedx/core/djangoapps/notifications/email/__init__.py index e69de29bb2..221a7d3426 100644 --- a/openedx/core/djangoapps/notifications/email/__init__.py +++ b/openedx/core/djangoapps/notifications/email/__init__.py @@ -0,0 +1,2 @@ +# lint-amnesty, pylint: disable=missing-module-docstring +ONE_CLICK_EMAIL_UNSUB_KEY = "one_click_email_unsubscribe" diff --git a/openedx/core/djangoapps/notifications/email/events.py b/openedx/core/djangoapps/notifications/email/events.py index 165539a018..d05c829df2 100644 --- a/openedx/core/djangoapps/notifications/email/events.py +++ b/openedx/core/djangoapps/notifications/email/events.py @@ -12,19 +12,29 @@ from openedx.core.djangoapps.notifications.base_notification import COURSE_NOTIF EMAIL_DIGEST_SENT = "edx.notifications.email_digest" -def send_user_email_digest_sent_event(user, cadence_type, notifications): +def send_user_email_digest_sent_event(user, cadence_type, notifications, message_context): """ Sends tracker and segment email for user email digest """ notification_breakdown = {key: 0 for key in COURSE_NOTIFICATION_APPS.keys()} for notification in notifications: notification_breakdown[notification.app_name] += 1 + + truncated_count = {} + email_content = message_context.get("email_content", []) + for app in email_content: + truncated_count[app.get("title", "")] = { + "total": app.get("total", -1), + "remaining_count": app.get("remaining_count", -1), + } + event_data = { "username": user.username, "email": user.email, "cadence_type": cadence_type, "total_notifications_count": len(notifications), "count_breakdown": notification_breakdown, + "truncated_count": truncated_count, "notification_ids": [notification.id for notification in notifications], "send_at": str(datetime.datetime.now()) } @@ -38,3 +48,30 @@ def send_user_email_digest_sent_event(user, cadence_type, notifications): EMAIL_DIGEST_SENT, event_data, ) + + +def send_immediate_email_digest_sent_event(user, cadence_type, notification): + """ + Sends tracker and segment event for immediate notification email + """ + event_data = { + "username": user.username, + "email": user.email, + "cadence_type": cadence_type, + "course_id": str(notification.course_id), + "app_name": notification.app_name, + "notification_type": notification.notification_type, + "content_url": notification.content_url, + "content": notification.content, + "send_at": str(datetime.datetime.now()) + } + with tracker.get_tracker().context(EMAIL_DIGEST_SENT, event_data): + tracker.emit( + EMAIL_DIGEST_SENT, + event_data, + ) + segment.track( + user.id, + EMAIL_DIGEST_SENT, + event_data, + ) diff --git a/openedx/core/djangoapps/notifications/email/message_type.py b/openedx/core/djangoapps/notifications/email/message_type.py index 655363248b..e1c0c8ba19 100644 --- a/openedx/core/djangoapps/notifications/email/message_type.py +++ b/openedx/core/djangoapps/notifications/email/message_type.py @@ -16,3 +16,4 @@ class EmailNotificationMessageType(MessageType): super().__init__(*args, **kwargs) self.options['transactional'] = True self.options['from_address'] = settings.NOTIFICATIONS_DEFAULT_FROM_EMAIL + self.options['skip_disable_user_policy'] = True diff --git a/openedx/core/djangoapps/notifications/email/notification_icons.py b/openedx/core/djangoapps/notifications/email/notification_icons.py index 0336a43a81..2b420d7109 100644 --- a/openedx/core/djangoapps/notifications/email/notification_icons.py +++ b/openedx/core/djangoapps/notifications/email/notification_icons.py @@ -11,6 +11,7 @@ class NotificationTypeIcons: CHECK_CIRCLE_GREEN = "CHECK_CIRCLE_GREEN" HELP_OUTLINE = "HELP_OUTLINE" NEWSPAPER = "NEWSPAPER" + OPEN_RESPONSE_OUTLINE = "OPEN_RESPONSE_OUTLINE" POST_OUTLINE = "POST_OUTLINE" QUESTION_ANSWER_OUTLINE = "QUESTION_ANSWER_OUTLINE" REPORT_RED = "REPORT_RED" @@ -32,7 +33,9 @@ class NotificationTypeIcons: "content_reported": cls.REPORT_RED, "response_endorsed_on_thread": cls.VERIFIED, "response_endorsed": cls.CHECK_CIRCLE_GREEN, - "course_update": cls.NEWSPAPER, + "course_updates": cls.NEWSPAPER, + "ora_staff_notifications": cls.OPEN_RESPONSE_OUTLINE, + "ora_grade_assigned": cls.OPEN_RESPONSE_OUTLINE, } return notification_type_dict.get(notification_type, default) diff --git a/openedx/core/djangoapps/notifications/email/tasks.py b/openedx/core/djangoapps/notifications/email/tasks.py index 0d450fe9a9..ebc54a7661 100644 --- a/openedx/core/djangoapps/notifications/email/tasks.py +++ b/openedx/core/djangoapps/notifications/email/tasks.py @@ -1,29 +1,38 @@ """ Celery tasks for sending email notifications """ +from bs4 import BeautifulSoup from celery import shared_task from celery.utils.log import get_task_logger from django.contrib.auth import get_user_model +from django.utils.translation import gettext as _, override as translation_override from edx_ace import ace from edx_ace.recipient import Recipient from edx_django_utils.monitoring import set_code_owner_attribute +from openedx.core.djangoapps.notifications.config.waffle import ENABLE_ACCOUNT_LEVEL_PREFERENCES from openedx.core.djangoapps.notifications.email_notifications import EmailCadence from openedx.core.djangoapps.notifications.models import ( CourseNotificationPreference, Notification, + NotificationPreference, get_course_notification_preference_config_version ) -from .events import send_user_email_digest_sent_event +from .events import send_immediate_email_digest_sent_event, send_user_email_digest_sent_event from .message_type import EmailNotificationMessageType from .utils import ( add_headers_to_email_message, create_app_notifications_dict, create_email_digest_context, + create_email_template_context, + filter_email_enabled_notifications, filter_notification_with_email_enabled_preferences, + get_course_info, + get_language_preference_for_users, get_start_end_date, + get_text_for_notification_type, get_unique_course_ids, - is_email_notification_flag_enabled + is_email_notification_flag_enabled, ) @@ -70,7 +79,7 @@ def get_user_preferences_for_courses(course_ids, user): return new_preferences -def send_digest_email_to_user(user, cadence_type, start_date, end_date, course_language='en', courses_data=None): +def send_digest_email_to_user(user, cadence_type, start_date, end_date, user_language='en', courses_data=None): """ Send [cadence_type] email to user. Cadence Type can be EmailCadence.DAILY or EmailCadence.WEEKLY @@ -80,6 +89,9 @@ def send_digest_email_to_user(user, cadence_type, start_date, end_date, course_l if cadence_type not in [EmailCadence.DAILY, EmailCadence.WEEKLY]: raise ValueError('Invalid cadence_type') logger.info(f' Sending email to user {user.username} ==Temp Log==') + if not user.has_usable_password(): + logger.info(f' User is disabled {user.username} ==Temp Log==') + return if not is_email_notification_flag_enabled(user): logger.info(f' Flag disabled for {user.username} ==Temp Log==') return @@ -88,23 +100,32 @@ def send_digest_email_to_user(user, cadence_type, start_date, end_date, course_l if not notifications: logger.info(f' No notification for {user.username} ==Temp Log==') return - course_ids = get_unique_course_ids(notifications) - preferences = get_user_preferences_for_courses(course_ids, user) - notifications = filter_notification_with_email_enabled_preferences(notifications, preferences, cadence_type) - if not notifications: - logger.info(f' No filtered notification for {user.username} ==Temp Log==') - return - apps_dict = create_app_notifications_dict(notifications) - message_context = create_email_digest_context(apps_dict, user.username, start_date, end_date, - cadence_type, courses_data=courses_data) - recipient = Recipient(user.id, user.email) - message = EmailNotificationMessageType( - app_label="notifications", name="email_digest" - ).personalize(recipient, course_language, message_context) - message = add_headers_to_email_message(message, message_context) - ace.send(message) - send_user_email_digest_sent_event(user, cadence_type, notifications) - logger.info(f' Email sent to {user.username} ==Temp Log==') + + with translation_override(user_language): + if ENABLE_ACCOUNT_LEVEL_PREFERENCES.is_enabled(): + preferences = NotificationPreference.objects.filter(user=user) + notifications = filter_email_enabled_notifications(notifications, preferences, user, + cadence_type=cadence_type) + else: + course_ids = get_unique_course_ids(notifications) + preferences = get_user_preferences_for_courses(course_ids, user) + notifications = filter_notification_with_email_enabled_preferences(notifications, preferences, cadence_type) + + if not notifications: + logger.info(f' No filtered notification for {user.username} ==Temp Log==') + return + apps_dict = create_app_notifications_dict(notifications) + message_context = create_email_digest_context(apps_dict, user.username, start_date, end_date, + cadence_type, courses_data=courses_data) + recipient = Recipient(user.id, user.email) + message = EmailNotificationMessageType( + app_label="notifications", name="email_digest" + ).personalize(recipient, user_language, message_context) + message = add_headers_to_email_message(message, message_context) + message.options['skip_disable_user_policy'] = True + ace.send(message) + send_user_email_digest_sent_event(user, cadence_type, notifications, message_context) + logger.info(f' Email sent to {user.username} ==Temp Log==') @shared_task(ignore_result=True) @@ -115,8 +136,60 @@ def send_digest_email_to_all_users(cadence_type): """ logger.info(f' Sending cadence email of type {cadence_type}') users = get_audience_for_cadence_email(cadence_type) + language_prefs = get_language_preference_for_users([user.id for user in users]) courses_data = {} start_date, end_date = get_start_end_date(cadence_type) logger.info(f' Email Cadence Audience {len(users)}') for user in users: - send_digest_email_to_user(user, cadence_type, start_date, end_date, courses_data=courses_data) + user_language = language_prefs.get(user.id, 'en') + send_digest_email_to_user(user, cadence_type, start_date, end_date, user_language=user_language, + courses_data=courses_data) + + +def send_immediate_cadence_email(email_notification_mapping, course_key): + """ + Send immediate cadence email to users + Parameters: + email_notification_mapping: Dictionary of user_id and Notification object + course_key: Course key for which the email is sent + """ + if not email_notification_mapping: + return + user_list = email_notification_mapping.keys() + users = User.objects.filter(id__in=user_list) + language_prefs = get_language_preference_for_users(user_list) + course_name = get_course_info(course_key).get("name", course_key) + for user in users.iterator(chunk_size=100): + if not user.has_usable_password(): + logger.info(f' User is disabled {user.username}') + continue + if not is_email_notification_flag_enabled(user): + logger.info(f' Flag disabled for {user.username}') + continue + notification = email_notification_mapping.get(user.id, None) + if not notification: + logger.info(f' No notification for {user.username}') + continue + + language = language_prefs.get(user.id, 'en') + with translation_override(language): + soup = BeautifulSoup(notification.content, "html.parser") + title = _("New Course Update") if notification.notification_type == "course_updates" else soup.get_text() + message_context = create_email_template_context(user.username) + message_context.update({ + "course_id": course_key, + "course_name": course_name, + "content_url": notification.content_url, + "content_title": title, + "footer_email_reason": _( + "You are receiving this email because you are enrolled in the edX course " + ) + str(course_name), + "content": notification.content_context.get("email_content", notification.content), + "view_text": get_text_for_notification_type(notification.notification_type), + }) + message = EmailNotificationMessageType( + app_label="notifications", name="immediate_email" + ).personalize(Recipient(user.id, user.email), language, message_context) + message = add_headers_to_email_message(message, message_context) + ace.send(message) + send_immediate_email_digest_sent_event(user, EmailCadence.IMMEDIATELY, notification) diff --git a/openedx/core/djangoapps/notifications/email/tests/test_tasks.py b/openedx/core/djangoapps/notifications/email/tests/test_tasks.py index 785dcf2a1b..005ab13a04 100644 --- a/openedx/core/djangoapps/notifications/email/tests/test_tasks.py +++ b/openedx/core/djangoapps/notifications/email/tests/test_tasks.py @@ -9,7 +9,10 @@ from unittest.mock import patch from edx_toggles.toggles.testutils import override_waffle_flag from common.djangoapps.student.tests.factories import UserFactory -from openedx.core.djangoapps.notifications.config.waffle import ENABLE_EMAIL_NOTIFICATIONS +from openedx.core.djangoapps.notifications.config.waffle import ( + ENABLE_ACCOUNT_LEVEL_PREFERENCES, ENABLE_NOTIFICATIONS, ENABLE_EMAIL_NOTIFICATIONS +) +from openedx.core.djangoapps.notifications.tasks import send_notifications from openedx.core.djangoapps.notifications.email_notifications import EmailCadence from openedx.core.djangoapps.notifications.email.tasks import ( get_audience_for_cadence_email, @@ -17,7 +20,7 @@ from openedx.core.djangoapps.notifications.email.tasks import ( send_digest_email_to_user ) from openedx.core.djangoapps.notifications.email.utils import get_start_end_date -from openedx.core.djangoapps.notifications.models import CourseNotificationPreference +from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, NotificationPreference from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -72,6 +75,124 @@ class TestEmailDigestForUser(ModuleStoreTestCase): send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date) assert not mock_func.called + @ddt.data(True, False) + @patch('edx_ace.ace.send') + def test_email_not_send_to_disable_user(self, value, mock_func): + """ + Tests email is not sent to disabled user + """ + created_date = datetime.datetime.now() - datetime.timedelta(days=1) + create_notification(self.user, self.course.id, created=created_date) + start_date, end_date = get_start_end_date(EmailCadence.DAILY) + if value: + self.user.set_password("12345678") + else: + self.user.set_unusable_password() + with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True): + send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date) + assert mock_func.called is value + + @patch('edx_ace.ace.send') + def test_notification_not_send_if_created_day_before_yesterday(self, mock_func): + """ + Tests email is not sent if notification is created day before yesterday + """ + start_date, end_date = get_start_end_date(EmailCadence.DAILY) + created_date = datetime.datetime.now() - datetime.timedelta(days=1, minutes=18) + create_notification(self.user, self.course.id, created=created_date) + with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True): + send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date) + assert not mock_func.called + + @ddt.data( + (EmailCadence.DAILY, datetime.datetime.now() - datetime.timedelta(days=1, minutes=30), False), + (EmailCadence.DAILY, datetime.datetime.now() - datetime.timedelta(minutes=10), True), + (EmailCadence.DAILY, datetime.datetime.now() - datetime.timedelta(days=1), True), + (EmailCadence.DAILY, datetime.datetime.now() + datetime.timedelta(minutes=20), False), + (EmailCadence.WEEKLY, datetime.datetime.now() - datetime.timedelta(days=7, minutes=30), False), + (EmailCadence.WEEKLY, datetime.datetime.now() - datetime.timedelta(days=7), True), + (EmailCadence.WEEKLY, datetime.datetime.now() - datetime.timedelta(minutes=20), True), + (EmailCadence.WEEKLY, datetime.datetime.now() + datetime.timedelta(minutes=20), False), + ) + @ddt.unpack + @patch('edx_ace.ace.send') + def test_notification_content(self, cadence_type, created_time, notification_created, mock_func): + """ + Tests email only contains notification created within date + """ + start_date, end_date = get_start_end_date(cadence_type) + create_notification(self.user, self.course.id, created=created_time) + with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True): + send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date) + assert mock_func.called is notification_created + + +@override_waffle_flag(ENABLE_ACCOUNT_LEVEL_PREFERENCES, True) +@ddt.ddt +class TestEmailDigestForUserWithAccountPreferences(ModuleStoreTestCase): + """ + Tests email notification for a specific user + """ + + def setUp(self): + """ + Setup + """ + super().setUp() + self.user = UserFactory() + self.course = CourseFactory.create(display_name='test course', run="Testing_course") + + @patch('edx_ace.ace.send') + def test_email_is_not_sent_if_no_notifications(self, mock_func): + """ + Tests email is sent iff waffle flag is enabled + """ + start_date, end_date = get_start_end_date(EmailCadence.DAILY) + with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True): + send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date) + assert not mock_func.called + + @ddt.data(True, False) + @patch('edx_ace.ace.send') + def test_email_is_sent_iff_flag_enabled(self, flag_value, mock_func): + """ + Tests email is sent iff waffle flag is enabled + """ + created_date = datetime.datetime.now() - datetime.timedelta(days=1) + create_notification(self.user, self.course.id, created=created_date) + start_date, end_date = get_start_end_date(EmailCadence.DAILY) + with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, flag_value): + send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date) + assert mock_func.called is flag_value + + @patch('edx_ace.ace.send') + def test_notification_not_send_if_created_on_next_day(self, mock_func): + """ + Tests email is not sent if notification is created on next day + """ + start_date, end_date = get_start_end_date(EmailCadence.DAILY) + create_notification(self.user, self.course.id, created=end_date + datetime.timedelta(minutes=2)) + with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True): + send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date) + assert not mock_func.called + + @ddt.data(True, False) + @patch('edx_ace.ace.send') + def test_email_not_send_to_disable_user(self, value, mock_func): + """ + Tests email is not sent to disabled user + """ + created_date = datetime.datetime.now() - datetime.timedelta(days=1) + create_notification(self.user, self.course.id, created=created_date) + start_date, end_date = get_start_end_date(EmailCadence.DAILY) + if value: + self.user.set_password("12345678") + else: + self.user.set_unusable_password() + with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True): + send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date) + assert mock_func.called is value + @patch('edx_ace.ace.send') def test_notification_not_send_if_created_day_before_yesterday(self, mock_func): """ @@ -241,3 +362,112 @@ class TestPreferences(ModuleStoreTestCase): with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True): send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date) assert not mock_func.called + + +@override_waffle_flag(ENABLE_ACCOUNT_LEVEL_PREFERENCES, True) +@ddt.ddt +class TestAccountPreferences(ModuleStoreTestCase): + """ + Tests preferences + """ + def setUp(self): + """ + Setup + """ + super().setUp() + self.user = UserFactory() + self.course = CourseFactory.create(display_name='test course', run="Testing_course") + self.preference, _ = NotificationPreference.objects.get_or_create(user=self.user, app="discussion", + type="new_discussion_post") + created_date = datetime.datetime.now() - datetime.timedelta(hours=23) + create_notification(self.user, self.course.id, notification_type='new_discussion_post', created=created_date) + + @patch('edx_ace.ace.send') + def test_email_send_for_digest_preference(self, mock_func): + """ + Tests email is send for digest notification preference + """ + start_date, end_date = get_start_end_date(EmailCadence.DAILY) + self.preference.email = True + self.preference.email_cadence = EmailCadence.DAILY + self.preference.save() + with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True): + send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date) + assert mock_func.called + + @ddt.data(True, False) + @patch('edx_ace.ace.send') + def test_email_send_for_email_preference_value(self, pref_value, mock_func): + """ + Tests email is sent iff preference value is True + """ + start_date, end_date = get_start_end_date(EmailCadence.DAILY) + self.preference.email = pref_value + self.preference.email_cadence = EmailCadence.DAILY + self.preference.save() + with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True): + send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date) + assert mock_func.called is pref_value + + @patch('edx_ace.ace.send') + def test_email_not_send_if_different_digest_preference(self, mock_func): + """ + Tests email is not send if digest notification preference doesnot match + """ + start_date, end_date = get_start_end_date(EmailCadence.DAILY) + self.preference.email = True + self.preference.email_cadence = EmailCadence.WEEKLY + self.preference.save() + with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True): + send_digest_email_to_user(self.user, EmailCadence.DAILY, start_date, end_date) + assert not mock_func.called + + +class TestImmediateEmail(ModuleStoreTestCase): + """ + Tests immediate email + """ + + def setUp(self): + """ + Setup + """ + super().setUp() + self.user = UserFactory() + self.course = CourseFactory.create(display_name='test course', run="Testing_course") + + @patch('edx_ace.ace.send') + def test_email_sent_when_cadence_is_immediate(self, mock_func): + """ + Tests email is sent when cadence is immediate + """ + preference = CourseNotificationPreference.objects.create(user=self.user, course_id=self.course.id) + app_prefs = preference.notification_preference_config['discussion']['notification_types'] + app_prefs['new_discussion_post']['email'] = True + app_prefs['new_discussion_post']['email_cadence'] = EmailCadence.IMMEDIATELY + preference.save() + context = { + 'username': 'User', + 'post_title': 'title' + } + with override_waffle_flag(ENABLE_NOTIFICATIONS, True): + with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True): + send_notifications([self.user.id], str(self.course.id), 'discussion', + 'new_discussion_post', context, 'http://test.url') + assert mock_func.call_count == 1 + + @patch('edx_ace.ace.send') + def test_email_not_sent_when_cadence_is_not_immediate(self, mock_func): + """ + Tests email is not sent when cadence is not immediate + """ + CourseNotificationPreference.objects.create(user=self.user, course_id=self.course.id) + context = { + 'replier_name': 'User', + 'post_title': 'title' + } + with override_waffle_flag(ENABLE_NOTIFICATIONS, True): + with override_waffle_flag(ENABLE_EMAIL_NOTIFICATIONS, True): + send_notifications([self.user.id], str(self.course.id), 'discussion', + 'new_response', context, 'http://test.url') + assert mock_func.call_count == 0 diff --git a/openedx/core/djangoapps/notifications/email/tests/test_utils.py b/openedx/core/djangoapps/notifications/email/tests/test_utils.py index 1f3da983a0..e7b5db54e8 100644 --- a/openedx/core/djangoapps/notifications/email/tests/test_utils.py +++ b/openedx/core/djangoapps/notifications/email/tests/test_utils.py @@ -8,7 +8,7 @@ import pytest from django.http.response import Http404 from itertools import product from pytz import utc -from waffle import get_waffle_flag_model # pylint: disable=invalid-django-waffle-import +from waffle import get_waffle_flag_model # pylint: disable=invalid-django-waffle-import from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.notifications.base_notification import ( @@ -16,6 +16,7 @@ from openedx.core.djangoapps.notifications.base_notification import ( COURSE_NOTIFICATION_TYPES, ) from openedx.core.djangoapps.notifications.config.waffle import ENABLE_EMAIL_NOTIFICATIONS +from openedx.core.djangoapps.notifications.email import ONE_CLICK_EMAIL_UNSUB_KEY from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, Notification from openedx.core.djangoapps.notifications.email.utils import ( add_additional_attributes_to_notifications, @@ -32,6 +33,7 @@ from openedx.core.djangoapps.notifications.email.utils import ( is_email_notification_flag_enabled, update_user_preferences_from_patch, ) +from openedx.core.djangoapps.user_api.models import UserPreference from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -42,6 +44,7 @@ class TestUtilFunctions(ModuleStoreTestCase): """ Test utils functions """ + def setUp(self): """ Setup @@ -102,6 +105,7 @@ class TestContextFunctions(ModuleStoreTestCase): """ Test template context functions in utils.py """ + def setUp(self): """ Setup @@ -143,13 +147,25 @@ class TestContextFunctions(ModuleStoreTestCase): context = create_email_digest_context(**params) expected_start_date = 'Sunday, Mar 24' if digest_frequency == 'Daily' else 'Monday, Mar 18' expected_digest_updates = [ - {'title': 'Total Notifications', 'count': 2}, - {'title': 'Discussion', 'count': 1}, - {'title': 'Updates', 'count': 1}, + {'title': 'Total Notifications', 'translated_title': 'Total Notifications', 'count': 2}, + {'title': 'Discussion', 'translated_title': 'Discussion', 'count': 1}, + {'title': 'Updates', 'translated_title': 'Updates', 'count': 1}, ] expected_email_content = [ - {'title': 'Discussion', 'help_text': '', 'help_text_url': '', 'notifications': [discussion_notification]}, - {'title': 'Updates', 'help_text': '', 'help_text_url': '', 'notifications': [update_notification]} + { + 'title': 'Discussion', 'help_text': '', 'help_text_url': '', + 'translated_title': 'Discussion', + 'notifications': [discussion_notification], + 'total': 1, 'show_remaining_count': False, 'remaining_count': 0, + 'url': 'http://learner-home-mfe/?showNotifications=true&app=discussion' + }, + { + 'title': 'Updates', 'help_text': '', 'help_text_url': '', + 'translated_title': 'Updates', + 'notifications': [update_notification], + 'total': 1, 'show_remaining_count': False, 'remaining_count': 0, + 'url': 'http://learner-home-mfe/?showNotifications=true&app=updates' + } ] assert context['start_date'] == expected_start_date assert context['end_date'] == 'Sunday, Mar 24' @@ -162,6 +178,7 @@ class TestWaffleFlag(ModuleStoreTestCase): """ Test user level email notifications waffle flag """ + def setUp(self): """ Setup @@ -214,6 +231,7 @@ class TestEncryption(ModuleStoreTestCase): """ Tests all encryption methods """ + def test_string_encryption(self): """ Tests if decrypted string is equal original string @@ -240,6 +258,7 @@ class TestUpdatePreferenceFromPatch(ModuleStoreTestCase): """ Tests if preferences are update according to patch data """ + def setUp(self): """ Setup test cases @@ -426,3 +445,17 @@ class TestUpdatePreferenceFromPatch(ModuleStoreTestCase): enc_patch = encrypt_object({"value": True}) with pytest.raises(Http404): update_user_preferences_from_patch(enc_username, enc_patch) + + def test_user_preference_created_on_email_unsubscribe(self): + """ + Test that the user's email unsubscribe preference is correctly created after unsubscribing digest email. + """ + encrypted_username = encrypt_string(self.user.username) + encrypted_patch = encrypt_object({ + 'channel': 'email', + 'value': False + }) + update_user_preferences_from_patch(encrypted_username, encrypted_patch) + self.assertTrue( + UserPreference.objects.filter(user=self.user, key=ONE_CLICK_EMAIL_UNSUB_KEY).exists() + ) diff --git a/openedx/core/djangoapps/notifications/email/utils.py b/openedx/core/djangoapps/notifications/email/utils.py index d855494012..8ff985d993 100644 --- a/openedx/core/djangoapps/notifications/email/utils.py +++ b/openedx/core/djangoapps/notifications/email/utils.py @@ -8,23 +8,25 @@ from bs4 import BeautifulSoup from django.conf import settings from django.contrib.auth import get_user_model from django.shortcuts import get_object_or_404 +from django.utils.translation import gettext as _ from pytz import utc -from waffle import get_waffle_flag_model # pylint: disable=invalid-django-waffle-import +from waffle import get_waffle_flag_model # pylint: disable=invalid-django-waffle-import from common.djangoapps.student.models import CourseEnrollment from lms.djangoapps.branding.api import get_logo_url_for_email from lms.djangoapps.discussion.notification_prefs.views import UsernameCipher -from openedx.core.djangoapps.notifications.base_notification import ( - COURSE_NOTIFICATION_APPS, - COURSE_NOTIFICATION_TYPES, -) +from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY +from openedx.core.djangoapps.notifications.base_notification import COURSE_NOTIFICATION_APPS, COURSE_NOTIFICATION_TYPES from openedx.core.djangoapps.notifications.config.waffle import ENABLE_EMAIL_NOTIFICATIONS +from openedx.core.djangoapps.notifications.email import ONE_CLICK_EMAIL_UNSUB_KEY from openedx.core.djangoapps.notifications.email_notifications import EmailCadence from openedx.core.djangoapps.notifications.events import notification_preference_unsubscribe_event from openedx.core.djangoapps.notifications.models import ( CourseNotificationPreference, + NotificationPreference, get_course_notification_preference_config_version ) +from openedx.core.djangoapps.user_api.models import UserPreference from xmodule.modulestore.django import modulestore from .notification_icons import NotificationTypeIcons @@ -96,12 +98,14 @@ def create_email_template_context(username): 'channel': 'email', 'value': False } + account_base_url = (settings.ACCOUNT_MICROFRONTEND_URL or "").rstrip('/') return { "platform_name": settings.PLATFORM_NAME, "mailing_address": settings.CONTACT_MAILING_ADDRESS, "logo_url": get_logo_url_for_email(), + "logo_notification_cadence_url": settings.NOTIFICATION_DIGEST_LOGO, "social_media": social_media_info, - "notification_settings_url": f"{settings.ACCOUNT_MICROFRONTEND_URL}/notifications", + "notification_settings_url": f"{account_base_url}/#notifications", "unsubscribe_url": get_unsubscribe_link(username, patch) } @@ -119,28 +123,49 @@ def create_email_digest_context(app_notifications_dict, username, start_date, en context = create_email_template_context(username) start_date_str = create_datetime_string(start_date) end_date_str = create_datetime_string(end_date if end_date else start_date) - email_digest_updates = [{ - 'title': 'Total Notifications', - 'count': sum(value['count'] for value in app_notifications_dict.values()) - }] - email_digest_updates.extend([ + email_digest_updates = [ { 'title': value['title'], 'count': value['count'], + 'translated_title': value.get('translated_title', value['title']), } for key, value in app_notifications_dict.items() - ]) - email_content = [ - { + ] + lookup = { + 'Updates': 1, + 'Grading': 2, + 'Discussion': 3, + } + email_digest_updates.sort(key=lambda x: lookup.get(x['title'], 4), reverse=False) + email_digest_updates.append({ + 'title': 'Total Notifications', + 'translated_title': _('Total Notifications'), + 'count': sum(value['count'] for value in app_notifications_dict.values()) + }) + + email_content = [] + notifications_in_app = 5 + for key, value in app_notifications_dict.items(): + total = value['count'] + app_content = { 'title': value['title'], + 'translated_title': value.get('translated_title', value['title']), 'help_text': value.get('help_text', ''), 'help_text_url': value.get('help_text_url', ''), 'notifications': add_additional_attributes_to_notifications( value.get('notifications', []), courses_data=courses_data - ) + ), + 'total': total, + 'show_remaining_count': False, + 'remaining_count': 0, + 'url': f'{settings.LEARNER_HOME_MICROFRONTEND_URL}/?showNotifications=true&app={key}' } - for key, value in app_notifications_dict.items() - ] + if total > notifications_in_app: + app_content['notifications'] = app_content['notifications'][:notifications_in_app] + app_content['show_remaining_count'] = True + app_content['remaining_count'] = total - notifications_in_app + email_content.append(app_content) + context.update({ "start_date": start_date_str, "end_date": end_date_str, @@ -190,7 +215,7 @@ def get_time_ago(datetime_obj): current_date = utc.localize(datetime.datetime.today()) days_diff = (current_date - datetime_obj).days if days_diff == 0: - return "Today" + return _("Today") if days_diff >= 7: return f"{int(days_diff / 7)}w" return f"{days_diff}d" @@ -229,6 +254,7 @@ def add_additional_attributes_to_notifications(notifications, courses_data=None) notification.time_ago = get_time_ago(notification.created) notification.email_content = add_zero_margin_to_root(notification.content) notification.details = add_zero_margin_to_root(notification.content_context.get('email_content', '')) + notification.view_text = get_text_for_notification_type(notification_type) return notifications @@ -242,6 +268,7 @@ def create_app_notifications_dict(notifications): name: { 'count': 0, 'title': name.title(), + 'translated_title': get_translated_app_title(name), 'notifications': [] } for name in app_names @@ -295,6 +322,58 @@ def filter_notification_with_email_enabled_preferences(notifications, preference for notification in notifications: if notification.notification_type in enabled_course_prefs[notification.course_id]: filtered_notifications.append(notification) + filtered_notifications.sort(key=lambda elem: elem.created, reverse=True) + return filtered_notifications + + +def create_missing_account_level_preferences(notifications, preferences, user): + """ + Creates missing account level preferences for notifications + """ + preferences = list(preferences) + notification_types = list(set(notification.notification_type for notification in notifications)) + missing_prefs = [] + for notification_type in notification_types: + if not any(preference.type == notification_type for preference in preferences): + type_pref = COURSE_NOTIFICATION_TYPES.get(notification_type, {}) + app_name = type_pref["notification_app"] + if type_pref.get('is_core', False): + app_pref = COURSE_NOTIFICATION_APPS.get(app_name, {}) + default_pref = { + "web": app_pref["core_web"], + "push": app_pref["core_push"], + "email": app_pref["core_email"], + "email_cadence": app_pref["core_email_cadence"] + } + else: + default_pref = COURSE_NOTIFICATION_TYPES.get(notification_type, {}) + missing_prefs.append( + NotificationPreference( + user=user, type=notification_type, app=app_name, web=default_pref['web'], + push=default_pref['push'], email=default_pref['email'], email_cadence=default_pref['email_cadence'], + ) + ) + if missing_prefs: + created_prefs = NotificationPreference.objects.bulk_create(missing_prefs, ignore_conflicts=True) + preferences = preferences + list(created_prefs) + return preferences + + +def filter_email_enabled_notifications(notifications, preferences, user, cadence_type=EmailCadence.DAILY): + """ + Filter notifications with email enabled in account level preferences + """ + preferences = create_missing_account_level_preferences(notifications, preferences, user) + enabled_course_prefs = [ + preference.type + for preference in preferences + if preference.email and preference.email_cadence == cadence_type + ] + filtered_notifications = [] + for notification in notifications: + if notification.notification_type in enabled_course_prefs: + filtered_notifications.append(notification) + filtered_notifications.sort(key=lambda elem: elem.created, reverse=True) return filtered_notifications @@ -357,14 +436,6 @@ def update_user_preferences_from_patch(encrypted_username, encrypted_patch): """ return True if param_name is None else name == param_name - def is_editable(app_name, notification_type, channel): - """ - Returns if notification type channel is editable - """ - if notification_type == 'core': - return channel not in COURSE_NOTIFICATION_APPS[app_name]['non_editable'] - return channel not in COURSE_NOTIFICATION_TYPES[notification_type]['non_editable'] - def get_default_cadence_value(app_name, notification_type): """ Returns default email cadence value @@ -381,7 +452,7 @@ def update_user_preferences_from_patch(encrypted_username, encrypted_patch): pref = pref.get_user_course_preference(pref.user_id, pref.course_id) return pref - course_ids = CourseEnrollment.objects.filter(user=user).values_list('course_id', flat=True) + course_ids = CourseEnrollment.objects.filter(user=user, is_active=True).values_list('course_id', flat=True) CourseNotificationPreference.objects.bulk_create( [ CourseNotificationPreference(user=user, course_id=course_id) @@ -390,6 +461,7 @@ def update_user_preferences_from_patch(encrypted_username, encrypted_patch): ignore_conflicts=True ) preferences = CourseNotificationPreference.objects.filter(**kwargs) + is_preference_updated = False # pylint: disable=too-many-nested-blocks for preference in preferences: @@ -404,9 +476,64 @@ def update_user_preferences_from_patch(encrypted_username, encrypted_patch): for channel in ['web', 'email', 'push']: if not is_name_match(channel, channel_value): continue - if is_editable(app_name, noti_type, channel): - type_prefs[channel] = pref_value + if is_notification_type_channel_editable(app_name, noti_type, channel): + if type_prefs[channel] != pref_value: + type_prefs[channel] = pref_value + is_preference_updated = True + if channel == 'email' and pref_value and type_prefs.get('email_cadence') == EmailCadence.NEVER: - type_prefs['email_cadence'] = get_default_cadence_value(app_name, noti_type) + default_cadence = get_default_cadence_value(app_name, noti_type) + if type_prefs['email_cadence'] != default_cadence: + type_prefs['email_cadence'] = default_cadence + is_preference_updated = True preference.save() - notification_preference_unsubscribe_event(user) + notification_preference_unsubscribe_event(user, is_preference_updated) + if app_value is None and type_value is None and channel_value == 'email' and not pref_value: + UserPreference.objects.get_or_create(user_id=user.id, key=ONE_CLICK_EMAIL_UNSUB_KEY) + + +def is_notification_type_channel_editable(app_name, notification_type, channel): + """ + Returns if notification type channel is editable + """ + notification_type = 'core'\ + if COURSE_NOTIFICATION_TYPES.get(notification_type, {}).get("is_core", False)\ + else notification_type + if notification_type == 'core': + return channel not in COURSE_NOTIFICATION_APPS[app_name]['non_editable'] + return channel not in COURSE_NOTIFICATION_TYPES[notification_type]['non_editable'] + + +def get_translated_app_title(name): + """ + Returns translated string from notification app_name key + """ + mapping = { + 'discussion': _('Discussion'), + 'updates': _('Updates'), + 'grading': _('Grades'), + } + return mapping.get(name, '') + + +def get_language_preference_for_users(user_ids): + """ + Returns mapping of user_id and language preference for users + """ + prefs = UserPreference.get_preference_for_users(user_ids, LANGUAGE_KEY) + return {pref.user_id: pref.value for pref in prefs} + + +def get_text_for_notification_type(notification_type): + """ + Returns text for notification type + """ + app_name = COURSE_NOTIFICATION_TYPES.get(notification_type, {}).get('notification_app') + if not app_name: + return "" + mapping = { + 'discussion': _('discussion'), + 'updates': _('update'), + 'grading': _('assessment'), + } + return mapping.get(app_name, "") diff --git a/openedx/core/djangoapps/notifications/events.py b/openedx/core/djangoapps/notifications/events.py index 74e6e56e41..8dd1f51868 100644 --- a/openedx/core/djangoapps/notifications/events.py +++ b/openedx/core/djangoapps/notifications/events.py @@ -46,20 +46,32 @@ def notification_event_context(user, course_id, notification): } -def notification_preferences_viewed_event(request, course_id): +def notification_preferences_viewed_event(request, course_id=None): """ Emit an event when a user views their notification preferences. """ - context = contexts.course_context_from_course_id(course_id) - with tracker.get_tracker().context(NOTIFICATION_PREFERENCES_VIEWED, context): + event_data = { + 'user_id': str(request.user.id), + 'course_id': None, + 'user_forum_roles': [], + 'user_course_roles': [], + 'type': 'account' + } + if not course_id: tracker.emit( NOTIFICATION_PREFERENCES_VIEWED, - { - 'user_id': str(request.user.id), - 'course_id': str(course_id), - 'user_forum_roles': get_user_forums_roles(request.user, course_id), - 'user_course_roles': get_user_course_roles(request.user, course_id), - } + event_data + ) + return + context = contexts.course_context_from_course_id(course_id) + with tracker.get_tracker().context(NOTIFICATION_PREFERENCES_VIEWED, context): + event_data['course_id']: str(course_id) + event_data['user_forum_roles'] = get_user_forums_roles(request.user, course_id) + event_data['user_course_roles'] = get_user_course_roles(request.user, course_id) + event_data['type'] = 'course' + tracker.emit( + NOTIFICATION_PREFERENCES_VIEWED, + event_data ) @@ -125,23 +137,36 @@ def notification_preference_update_event(user, course_id, updated_preference): """ Emit an event when a notification preference is updated. """ - context = contexts.course_context_from_course_id(course_id) - with tracker.get_tracker().context(NOTIFICATION_PREFERENCES_UPDATED, context): - value = updated_preference.get('value', '') - if updated_preference.get('notification_channel', '') == 'email_cadence': - value = updated_preference.get('email_cadence', '') + value = updated_preference.get('value', '') + if updated_preference.get('notification_channel', '') == 'email_cadence': + value = updated_preference.get('email_cadence', '') + event_data = { + 'user_id': str(user.id), + 'notification_app': updated_preference.get('notification_app', ''), + 'notification_type': updated_preference.get('notification_type', ''), + 'notification_channel': updated_preference.get('notification_channel', ''), + 'value': value, + 'course_id': None, + 'user_forum_roles': [], + 'user_course_roles': [], + 'type': 'course', + } + if not isinstance(course_id, list): + context = contexts.course_context_from_course_id(course_id) + with tracker.get_tracker().context(NOTIFICATION_PREFERENCES_UPDATED, context): + event_data['course_id'] = str(course_id) + event_data['user_forum_roles'] = get_user_forums_roles(user, course_id) + event_data['user_course_roles'] = get_user_course_roles(user, course_id) + tracker.emit( + NOTIFICATION_PREFERENCES_UPDATED, + event_data + ) + else: + event_data['course_ids'] = course_id + event_data['type'] = 'account' tracker.emit( NOTIFICATION_PREFERENCES_UPDATED, - { - 'user_id': str(user.id), - 'course_id': str(course_id), - 'user_forum_roles': get_user_forums_roles(user, course_id), - 'user_course_roles': get_user_course_roles(user, course_id), - 'notification_app': updated_preference.get('notification_app', ''), - 'notification_type': updated_preference.get('notification_type', ''), - 'notification_channel': updated_preference.get('notification_channel', ''), - 'value': value - } + event_data ) @@ -158,14 +183,18 @@ def notification_tray_opened_event(user, unseen_notifications_count): ) -def notification_preference_unsubscribe_event(user): +def notification_preference_unsubscribe_event(user, is_preference_updated=False): """ Emits an event when user clicks on one-click-unsubscribe url """ - event_data = { + context_data = { 'user_id': user.id, - 'username': user.username, - 'event_type': 'email_digest_unsubscribe' + 'username': user.username } - tracker.emit(NOTIFICATION_PREFERENCE_UNSUBSCRIBE, event_data) + event_data = context_data.copy() + event_data['event_type'] = 'email_digest_unsubscribe' + event_data['is_preference_updated'] = is_preference_updated + + with tracker.get_tracker().context(NOTIFICATION_PREFERENCE_UNSUBSCRIBE, context_data): + tracker.emit(NOTIFICATION_PREFERENCE_UNSUBSCRIBE, event_data) segment.track(user.id, NOTIFICATION_PREFERENCE_UNSUBSCRIBE, event_data) diff --git a/openedx/core/djangoapps/notifications/exceptions.py b/openedx/core/djangoapps/notifications/exceptions.py new file mode 100644 index 0000000000..2eb8bb7942 --- /dev/null +++ b/openedx/core/djangoapps/notifications/exceptions.py @@ -0,0 +1,6 @@ +""" Notification-related exceptions. """ + + +class InvalidNotificationTypeError(Exception): + """ Exception raised when an invalid notification type is passed. """ + pass # lint-amnesty, pylint: disable=unnecessary-pass diff --git a/openedx/core/djangoapps/notifications/grouping_notifications.py b/openedx/core/djangoapps/notifications/grouping_notifications.py index 3c4688b5ed..c855ca3d23 100644 --- a/openedx/core/djangoapps/notifications/grouping_notifications.py +++ b/openedx/core/djangoapps/notifications/grouping_notifications.py @@ -2,14 +2,16 @@ Notification grouping utilities for notifications """ import datetime +from abc import ABC, abstractmethod from typing import Dict, Type, Union from pytz import utc -from abc import ABC, abstractmethod - +from openedx.core.djangoapps.notifications.base_notification import COURSE_NOTIFICATION_TYPES from openedx.core.djangoapps.notifications.models import Notification +from .exceptions import InvalidNotificationTypeError + class BaseNotificationGrouper(ABC): """ @@ -42,6 +44,10 @@ class NotificationRegistry: """ Registers the grouper class for the given notification type. """ + if notification_type not in COURSE_NOTIFICATION_TYPES: + raise InvalidNotificationTypeError( + f"'{notification_type}' is not a valid notification type." + ) cls._groupers[notification_type] = grouper_class return grouper_class @@ -63,27 +69,6 @@ class NotificationRegistry: return grouper_class() -@NotificationRegistry.register('new_comment') -class NewCommentGrouper(BaseNotificationGrouper): - """ - Groups new comment notifications based on the replier name. - """ - - def group(self, new_notification, old_notification): - """ - Groups new comment notifications based on the replier name. - """ - context = old_notification.content_context.copy() - if not context.get('grouped'): - context['replier_name_list'] = [context['replier_name']] - context['grouped_count'] = 1 - context['grouped'] = True - context['replier_name_list'].append(new_notification.content_context['replier_name']) - context['grouped_count'] += 1 - context['email_content'] = new_notification.content_context.get('email_content', '') - return context - - @NotificationRegistry.register('new_discussion_post') class NewPostGrouper(BaseNotificationGrouper): """ @@ -106,6 +91,21 @@ class NewPostGrouper(BaseNotificationGrouper): } +@NotificationRegistry.register('ora_staff_notifications') +class OraStaffGrouper(BaseNotificationGrouper): + """ + Grouper for new ora staff notifications. + """ + + def group(self, new_notification, old_notification): + """ + Groups new ora staff notifications based on the xblock ID. + """ + content_context = old_notification.content_context + content_context.setdefault("grouped", True) + return content_context + + def group_user_notifications(new_notification: Notification, old_notification: Notification): """ Groups user notification based on notification type and group_id @@ -132,13 +132,14 @@ def get_user_existing_notifications(user_ids, notification_type, group_by_id, co user__in=user_ids, notification_type=notification_type, group_by_id=group_by_id, - course_id=course_id + course_id=course_id, + last_seen__isnull=True, ) notifications_mapping = {user_id: [] for user_id in user_ids} for notification in notifications: notifications_mapping[notification.user_id].append(notification) for user_id, notifications in notifications_mapping.items(): - notifications.sort(key=lambda elem: elem.created) + notifications.sort(key=lambda elem: elem.created, reverse=True) notifications_mapping[user_id] = notifications[0] if notifications else None return notifications_mapping diff --git a/openedx/core/djangoapps/notifications/handlers.py b/openedx/core/djangoapps/notifications/handlers.py index f28cb594ea..451b827f9b 100644 --- a/openedx/core/djangoapps/notifications/handlers.py +++ b/openedx/core/djangoapps/notifications/handlers.py @@ -3,8 +3,10 @@ Handlers for notifications """ import logging +from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist -from django.db import IntegrityError, transaction +from django.db import IntegrityError, transaction, ProgrammingError +from django.db.models.signals import post_save from django.dispatch import receiver from openedx_events.learning.signals import ( COURSE_ENROLLMENT_CREATED, @@ -21,9 +23,14 @@ from openedx.core.djangoapps.notifications.audience_filters import ( ForumRoleAudienceFilter, TeamAudienceFilter ) +from openedx.core.djangoapps.notifications.base_notification import NotificationAppManager, COURSE_NOTIFICATION_TYPES from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS, ENABLE_ORA_GRADE_NOTIFICATION -from openedx.core.djangoapps.notifications.models import CourseNotificationPreference +from openedx.core.djangoapps.notifications.email import ONE_CLICK_EMAIL_UNSUB_KEY +from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, NotificationPreference +from openedx.core.djangoapps.notifications.tasks import create_notification_preference +from openedx.core.djangoapps.user_api.models import UserPreference +User = get_user_model() log = logging.getLogger(__name__) AUDIENCE_FILTER_CLASSES = { @@ -44,15 +51,43 @@ def course_enrollment_post_save(signal, sender, enrollment, metadata, **kwargs): if ENABLE_NOTIFICATIONS.is_enabled(enrollment.course.course_key): try: with transaction.atomic(): + email_opt_out = UserPreference.objects.filter( + user_id=enrollment.user.id, + key=ONE_CLICK_EMAIL_UNSUB_KEY + ).exists() CourseNotificationPreference.objects.create( user_id=enrollment.user.id, - course_id=enrollment.course.course_key + course_id=enrollment.course.course_key, + notification_preference_config=NotificationAppManager().get_notification_app_preferences( + email_opt_out + ) ) except IntegrityError: log.info(f'CourseNotificationPreference already exists for user {enrollment.user.id} ' f'and course {enrollment.course.course_key}') +@receiver(post_save, sender=User) +def create_user_account_preferences(sender, instance, created, **kwargs): # pylint: disable=unused-argument + """ + Initialize user notification preferences when new user is created. + """ + preferences = [] + if created: + try: + with transaction.atomic(): + for name in COURSE_NOTIFICATION_TYPES.keys(): + preferences.append(create_notification_preference(instance.id, name)) + NotificationPreference.objects.bulk_create(preferences, ignore_conflicts=True) + except IntegrityError: + log.info(f'Account-level CourseNotificationPreference already exists for user {instance.id}') + except ProgrammingError as e: + # This is here because there is a dependency issue in the migrations where + # this signal handler tries to run before the NotificationPreference model is created. + # In reality, this should never be hit because migrations will have already run. + log.error(f'ProgrammingError encountered while creating user preferences: {e}') + + @receiver(COURSE_UNENROLLMENT_COMPLETED) def on_user_course_unenrollment(enrollment, **kwargs): """ diff --git a/openedx/core/djangoapps/notifications/management/commands/fix_mixed_email_cadence.py b/openedx/core/djangoapps/notifications/management/commands/fix_mixed_email_cadence.py new file mode 100644 index 0000000000..cf1c5c0c5a --- /dev/null +++ b/openedx/core/djangoapps/notifications/management/commands/fix_mixed_email_cadence.py @@ -0,0 +1,57 @@ +""" +Management command to fix NotificationPreference records with invalid 'Mixed' email_cadence values +created during migration. +""" + +import logging +from django.core.management.base import BaseCommand + +from openedx.core.djangoapps.notifications.models import NotificationPreference + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Management command to identify and correct NotificationPreference records + with an invalid 'Mixed' value in the email_cadence field. + + By default, the command runs in dry-run mode and only logs the count of + affected records. Use the `--fix` flag to replace all 'Mixed' values with + 'Daily', ensuring data consistency with defined model choices. + Invoke with: + python manage.py [lms] fix_mixed_email_cadence --fix + """ + help = ( + "Identifies NotificationPreference records with 'Mixed' as email_cadence " + "and optionally replaces it with a valid value (default: 'Daily')." + ) + + def add_arguments(self, parser): + parser.add_argument( + '--fix', + action='store_true', + help='Apply the fix by replacing "Mixed" with "Daily". Default is dry-run mode.' + ) + + def handle(self, *args, **options): + fix_mode = options['fix'] + invalid_records = NotificationPreference.objects.filter(email_cadence='Mixed') + count = invalid_records.count() + + if count == 0: + logger.info("No records found with invalid 'Mixed' value in email_cadence.") + return + + logger.info(f"Found {count} NotificationPreference records with 'Mixed' email_cadence.") + + if fix_mode: + updated_count = invalid_records.update( + email_cadence=NotificationPreference.EmailCadenceChoices.DAILY + ) + logger.info(f"Successfully updated {updated_count} records. 'Mixed' replaced with 'Daily'.") + else: + logger.warning( + "Dry-run mode: no changes were made.\n" + "To apply changes, re-run the command with the --fix flag." + ) diff --git a/openedx/core/djangoapps/notifications/management/commands/migrate_preferences_to_account_level_model.py b/openedx/core/djangoapps/notifications/management/commands/migrate_preferences_to_account_level_model.py new file mode 100644 index 0000000000..96e826cd0b --- /dev/null +++ b/openedx/core/djangoapps/notifications/management/commands/migrate_preferences_to_account_level_model.py @@ -0,0 +1,325 @@ +""" +Command to migrate course-level notification preferences to account-level preferences. +""" +import gc +import logging +from typing import Dict, List, Any, Iterator +from collections import defaultdict + +from django.core.management.base import BaseCommand, CommandParser +from django.db import transaction + +from openedx.core.djangoapps.notifications.email_notifications import EmailCadence +from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, NotificationPreference +from openedx.core.djangoapps.notifications.utils import aggregate_notification_configs +from openedx.core.djangoapps.notifications.base_notification import NotificationTypeManager, COURSE_NOTIFICATION_APPS + +logger = logging.getLogger(__name__) + +DEFAULT_BATCH_SIZE = 1000 + + +class Command(BaseCommand): + """ + Migrates course-level notification preferences to account-level notification preferences. + + This command processes users in batches, aggregates their course-level preferences, + and creates new account-level preferences. It includes a dry-run mode. + Existing account-level preferences for a processed user will be deleted before + new ones are created to ensure idempotency. + """ + help = "Migrates course-level notification preferences to account-level preferences for all relevant users." + + def add_arguments(self, parser: CommandParser): + parser.add_argument( + '--batch-size', + type=int, + default=DEFAULT_BATCH_SIZE, + help=f"The number of users to process in each batch. Default: {DEFAULT_BATCH_SIZE}" + ) + parser.add_argument( + '--dry-run', + action='store_true', + help="Simulate the migration without making any database changes." + ) + parser.add_argument( + '--use-default', + nargs='+', + choices=['web', 'push', 'email', 'email_cadence'], + help="Specify which notification channels should use default values. Can accept multiple values" + " (e.g., --use-default web push email)." + ) + + @staticmethod + def _run_garbage_collection(): + """ + Run manual garbage collection + """ + try: + collected_objects = gc.collect() + logger.debug(f"Garbage collection freed {collected_objects} objects") + return collected_objects + except Exception as e: # pylint: disable=broad-except + logger.warning(f"Garbage collection failed: {e}") + return 0 + + @staticmethod + def _get_user_ids_to_process() -> Iterator[int]: + """ + Yields all distinct user IDs with course notification preferences. + """ + logger.info("Fetching all distinct user IDs with course notification preferences...") + user_id_queryset = (CourseNotificationPreference + .objects + .values_list('user_id', flat=True) + .distinct()) + # The iterator with chunk_size is memory efficient for fetching the IDs themselves. + yield from user_id_queryset.iterator() + + @staticmethod + def _create_preference_object( + user_id: int, + app_name: str, + notification_type: str, + values: Dict[str, Any], + use_default: List[str] = None + ) -> NotificationPreference: + """ + Helper function to create a NotificationPreference instance. + Args: + user_id: The user ID for whom the preference is being created + app_name: The name of the notification app + notification_type: The type of notification (e.g., 'assignment', 'discussion') + values: A dictionary containing the preference values for web, email, push, etc. + use_default: List of channels that should use default values + """ + if use_default: + non_core_defaults, core_defaults = NotificationTypeManager().get_notification_app_preference(app_name) + + if non_core_defaults and notification_type in non_core_defaults: + for default in use_default: + values[default] = non_core_defaults[notification_type][default] + + elif core_defaults and notification_type in core_defaults: + for default in use_default: + values[default] = COURSE_NOTIFICATION_APPS[app_name][f'core_{default}'] + return NotificationPreference( + user_id=user_id, + app=app_name, + type=notification_type, + web=values.get('web'), + email=values.get('email'), + push=values.get('push'), + email_cadence=values.get('email_cadence', EmailCadence.DAILY) + ) + + def _create_preferences_from_configs( + self, + user_id: int, + course_preferences_configs: List[Dict], + use_default: List[str] = None + ) -> List[NotificationPreference]: + """ + Processes a list of preference configs for a single user. + Returns a list of NotificationPreference objects to be created. + + Args: + user_id: The user ID to process preferences for + course_preferences_configs: List of preference configuration dictionaries + use_default: List of channels ('web', 'push', 'email') that should use default values + """ + new_account_preferences: List[NotificationPreference] = [] + use_default = use_default or [] + + if not course_preferences_configs: + logger.debug(f"No course preferences found for user {user_id}. Skipping.") + return new_account_preferences + + aggregated_data = aggregate_notification_configs(course_preferences_configs) + + for app_name, app_config in aggregated_data.items(): + if not isinstance(app_config, dict): + logger.warning( + f"Malformed app_config for app '{app_name}' for user {user_id}. " + f"Expected dict, got {type(app_config)}. Skipping app." + ) + continue + + notif_types = app_config.get('notification_types', {}) + if not isinstance(notif_types, dict): + logger.warning( + f"Malformed 'notification_types' for app '{app_name}' for user {user_id}. Expected dict, " + f"got {type(notif_types)}. Skipping notification_types." + ) + continue + + # Handle regular notification types + for notification_type, values in notif_types.items(): + if notification_type == 'core': + continue + if values is None or not isinstance(values, dict): + logger.warning( + f"Skipping malformed notification type data for '{notification_type}' " + f"in app '{app_name}' for user {user_id}." + ) + continue + new_account_preferences.append( + self._create_preference_object(user_id, app_name, notification_type, values, use_default) + ) + + # Handle core notification types + core_types_list = app_config.get('core_notification_types', []) + if not isinstance(core_types_list, list): + logger.warning( + f"Malformed 'core_notification_types' for app '{app_name}' for user {user_id}. " + f"Expected list, got {type(core_types_list)}. Skipping core_notification_types." + ) + continue + + core_values = notif_types.get('core', {}) + if not isinstance(core_values, dict): + logger.warning( + f"Malformed values for 'core' notification types in app '{app_name}' for user {user_id}. " + f"Expected dict, got {type(core_values)}. Using empty defaults." + ) + core_values = {} + + for core_type_name in core_types_list: + if core_type_name is None or not isinstance(core_type_name, str): + logger.warning( + f"Skipping malformed core_type_name: '{core_type_name}' in app '{app_name}' for user {user_id}." + ) + continue + new_account_preferences.append( + self._create_preference_object(user_id, app_name, core_type_name, core_values, use_default) + ) + return new_account_preferences + + def _process_batch(self, user_ids: List[int], use_default: List[str] = None) -> List[NotificationPreference]: + """ + Fetches all preferences for a batch of users and processes them. + + Args: + user_ids: List of user IDs to process + use_default: List of channels that should use default values + """ + all_new_preferences: List[NotificationPreference] = [] + + # 1. Fetch all preference data for the batch in a single query. + course_prefs = CourseNotificationPreference.objects.filter( + user_id__in=user_ids + ).values('user_id', 'notification_preference_config') + + # 2. Group the fetched data by user_id in memory. + prefs_by_user = defaultdict(list) + for pref in course_prefs: + prefs_by_user[pref['user_id']].append(pref['notification_preference_config']) + + # 3. Process each user's grouped data. + for user_id, configs in prefs_by_user.items(): + user_new_preferences = self._create_preferences_from_configs(user_id, configs, use_default) + if user_new_preferences: + all_new_preferences.extend(user_new_preferences) + logger.debug(f"User {user_id}: Aggregated {len(configs)} course preferences " + f"into {len(user_new_preferences)} account preferences.") + else: + logger.debug(f"User {user_id}: No account preferences generated from {len(configs)} " + f"course preferences.") + # Clear local references to help with garbage collection + del prefs_by_user + del course_prefs + + return all_new_preferences + + def handle(self, *args: Any, **options: Any): # pylint: disable=too-many-statements + dry_run = options['dry_run'] + batch_size = options['batch_size'] + use_default = options.get('use_default', []) + + if dry_run: + logger.info(self.style.WARNING("Performing a DRY RUN. No changes will be made to the database.")) + else: + # Clear all existing preferences once at the beginning. + # This is more efficient and safer than deleting per-user. + NotificationPreference.objects.all().delete() + logger.info('Cleared all existing account-level notification preferences.') + + if use_default: + logger.info(f"Using default values for channels: {', '.join(use_default)}") + self._run_garbage_collection() + + user_id_iterator = self._get_user_ids_to_process() + + user_id_batch: List[int] = [] + total_users_processed = 0 + total_preferences_created = 0 + + for user_id in user_id_iterator: + user_id_batch.append(user_id) + + if len(user_id_batch) >= batch_size: + try: + with transaction.atomic(): + # Process the entire batch of users + preferences_to_create = self._process_batch(user_id_batch, use_default) + + if preferences_to_create: + if not dry_run: + NotificationPreference.objects.bulk_create(preferences_to_create) + + total_preferences_created += len(preferences_to_create) + logger.info( + self.style.SUCCESS( + f"Batch complete. {'Would create' if dry_run else 'Created'} " + f"{len(preferences_to_create)} preferences for {len(user_id_batch)} users." + ) + ) + else: + logger.info(f"Batch complete. No preferences to create for {len(user_id_batch)} users.") + + total_users_processed += len(user_id_batch) + user_id_batch = [] # Reset the batch + user_id_batch.clear() + del preferences_to_create + except Exception as e: # pylint: disable=broad-except + logger.error(f"Failed to process batch containing users {user_id_batch}: {e}", exc_info=True) + # The transaction for the whole batch will be rolled back. + # Clear the batch to continue with the next set of users. + user_id_batch = [] + + if total_users_processed > 0 and total_users_processed % (batch_size * 5) == 0: + logger.info(f"PROGRESS: Total users processed so far: {total_users_processed}. " + f"Total preferences {'would be' if dry_run else ''} " + f"created: {total_preferences_created}") + + # Process any remaining users in the last, smaller batch + if user_id_batch: + try: + with transaction.atomic(): + preferences_to_create = self._process_batch(user_id_batch, use_default) + if preferences_to_create: + if not dry_run: + NotificationPreference.objects.bulk_create(preferences_to_create) + total_preferences_created += len(preferences_to_create) + logger.info( + self.style.SUCCESS( + f"Final batch complete. {'Would create' if dry_run else 'Created'} " + f"{len(preferences_to_create)} preferences for {len(user_id_batch)} users." + ) + ) + total_users_processed += len(user_id_batch) + del preferences_to_create + self._run_garbage_collection() + except Exception as e: # pylint: disable=broad-except + logger.error(f"Failed to process final batch of users {user_id_batch}: {e}", exc_info=True) + + logger.info( + self.style.SUCCESS( + f"Migration complete. Processed {total_users_processed} users. " + f"{'Would have created' if dry_run else 'Created'} a total of {total_preferences_created} " + f"account-level preferences." + ) + ) + self._run_garbage_collection() + if dry_run: + logger.info(self.style.WARNING("DRY RUN finished. No actual changes were made.")) diff --git a/openedx/core/djangoapps/notifications/management/tests/test_migrate_to_account_level_model.py b/openedx/core/djangoapps/notifications/management/tests/test_migrate_to_account_level_model.py new file mode 100644 index 0000000000..2265247029 --- /dev/null +++ b/openedx/core/djangoapps/notifications/management/tests/test_migrate_to_account_level_model.py @@ -0,0 +1,465 @@ +# pylint: disable = W0212 +""" +Test for account level migration command +""" +from unittest.mock import Mock, patch + +from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.db.models.signals import post_save +from django.test import TestCase + +from openedx.core.djangoapps.notifications.email_notifications import EmailCadence +from openedx.core.djangoapps.notifications.handlers import create_user_account_preferences +from openedx.core.djangoapps.notifications.management.commands.migrate_preferences_to_account_level_model import Command +from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, NotificationPreference + +User = get_user_model() +COMMAND_MODULE = 'openedx.core.djangoapps.notifications.management.commands.migrate_preferences_to_account_level_model' + + +class MigrateNotificationPreferencesTestCase(TestCase): + """Test cases for the migrate_preferences_to_account_level_model management command.""" + + def setUp(self): + """Set up test data.""" + # Disconnect before creating users + post_save.disconnect(create_user_account_preferences, sender=User) + self.user1 = User.objects.create_user(username='user1', email='user1@example.com') + self.user2 = User.objects.create_user(username='user2', email='user2@example.com') + self.user3 = User.objects.create_user(username='user3', email='user3@example.com') + + # Sample notification preference config + self.sample_config = { + "grading": { + "enabled": True, + "notification_types": { + "core": { + "web": True, + "push": True, + "email": True, + "email_cadence": "Daily" + }, + "ora_grade_assigned": { + "web": True, + "push": False, + "email": True, + "email_cadence": "Daily" + } + }, + "core_notification_types": ["grade_assigned", "grade_updated"] + }, + "discussion": { + "enabled": True, + "notification_types": { + "core": { + "web": False, + "push": True, + "email": False, + "email_cadence": "Weekly" + }, + "new_post": { + "web": True, + "push": True, + "email": True, + "email_cadence": "Immediately" + } + }, + "core_notification_types": ["new_response", "new_comment"] + } + } + + def tearDown(self): + """Clean up test data.""" + CourseNotificationPreference.objects.all().delete() + NotificationPreference.objects.all().delete() + User.objects.all().delete() + + def test_get_user_ids_to_process(self): + """Test that _get_user_ids_to_process returns correct user IDs.""" + # Create course preferences for users + CourseNotificationPreference.objects.create( + user=self.user1, + course_id='course-v1:Test+Course+1', + notification_preference_config=self.sample_config + ) + CourseNotificationPreference.objects.create( + user=self.user2, + course_id='course-v1:Test+Course+2', + notification_preference_config=self.sample_config + ) + + command = Command() + user_ids = list(command._get_user_ids_to_process()) + + self.assertEqual(len(user_ids), 2) + self.assertIn(self.user1.id, user_ids) + self.assertIn(self.user2.id, user_ids) + + def test_create_preference_object(self): + """Test that _create_preference_object creates correct NotificationPreference instance.""" + command = Command() + values = { + 'web': True, + 'push': False, + 'email': True, + 'email_cadence': 'Weekly' + } + + preference = command._create_preference_object( + user_id=self.user1.id, + app_name='grading', + notification_type='ora_grade_assigned', + values=values + ) + + self.assertEqual(preference.user_id, self.user1.id) + self.assertEqual(preference.app, 'grading') + self.assertEqual(preference.type, 'ora_grade_assigned') + self.assertTrue(preference.web) + self.assertFalse(preference.push) + self.assertTrue(preference.email) + self.assertEqual(preference.email_cadence, 'Weekly') + + def test_create_preference_object_with_defaults(self): + """Test _create_preference_object with missing values uses defaults.""" + command = Command() + values = {'web': True} # Missing other values + + preference = command._create_preference_object( + user_id=self.user1.id, + app_name='grading', + notification_type='test_type', + values=values + ) + + self.assertTrue(preference.web) + self.assertIsNone(preference.push) + self.assertIsNone(preference.email) + self.assertEqual(preference.email_cadence, EmailCadence.DAILY) + + @patch(f'{COMMAND_MODULE}.aggregate_notification_configs') + def test_process_user_preferences_success(self, mock_aggregate): + """Test successful processing of user preferences.""" + # Setup + CourseNotificationPreference.objects.create( + user=self.user1, + course_id='course-v1:Test+Course+1', + notification_preference_config=self.sample_config + ) + + mock_aggregate.return_value = { + 'grading': { + 'notification_types': { + 'core': {'web': True, 'push': True, 'email': True, 'email_cadence': 'Daily'}, + 'grade_assigned': {'web': True, 'push': False, 'email': True, 'email_cadence': 'Daily'} + }, + 'core_notification_types': ['grade_updated'] + } + } + + command = Command() + preferences = command._process_batch([self.user1.id]) + + self.assertEqual(len(preferences), 2) # grade_assigned + grade_updated + + # Check grade_assigned preference + grade_assigned_pref = next(p for p in preferences if p.type == 'grade_assigned') + self.assertEqual(grade_assigned_pref.app, 'grading') + self.assertTrue(grade_assigned_pref.web) + self.assertFalse(grade_assigned_pref.push) + self.assertTrue(grade_assigned_pref.email) + + # Check core notification type + grade_updated_pref = next(p for p in preferences if p.type == 'grade_updated') + self.assertEqual(grade_updated_pref.app, 'grading') + self.assertTrue(grade_updated_pref.web) + self.assertTrue(grade_updated_pref.push) + self.assertTrue(grade_updated_pref.email) + + @patch(f'{COMMAND_MODULE}.aggregate_notification_configs') + def test_process_user_preferences_no_course_preferences(self, mock_aggregate): + """Test processing user with no course preferences.""" + command = Command() + preferences = command._process_batch([self.user1.id]) + + self.assertEqual(len(preferences), 0) + mock_aggregate.assert_not_called() + + @patch(f'{COMMAND_MODULE}.aggregate_notification_configs') + def test_process_user_preferences_malformed_data(self, mock_aggregate): + """Test handling of malformed notification config data.""" + CourseNotificationPreference.objects.create( + user=self.user1, + course_id='course-v1:Test+Course+1', + notification_preference_config=self.sample_config + ) + + # Mock malformed data + mock_aggregate.return_value = { + 'grading': 'invalid_string', # Should be dict + 'discussion': { + 'notification_types': 'invalid_string', # Should be dict + 'core_notification_types': [] + }, + 'updates': { + 'notification_types': { + 'core': {'web': True, 'push': True, 'email': True}, + 'invalid_type': None # Invalid notification type data + }, + 'core_notification_types': 'invalid_string' # Should be list + } + } + + command = Command() + with self.assertLogs(level='WARNING') as log: + preferences = command._process_batch([self.user1.id]) + + self.assertEqual(len(preferences), 0) + self.assertIn('Malformed app_config', log.output[0]) + + @patch(f'{COMMAND_MODULE}.logger') + def test_handle_dry_run_mode(self, mock_logger): + """Test command execution in dry-run mode.""" + CourseNotificationPreference.objects.create( + user=self.user1, + course_id='course-v1:Test+Course+1', + notification_preference_config=self.sample_config + ) + + with patch.object(Command, '_process_batch') as mock_process: + mock_process.return_value = [ + NotificationPreference( + user_id=self.user1.id, + app='grading', + type='test_type', + web=True, + push=False, + email=True, + email_cadence='Daily' + ) + ] + + call_command('migrate_preferences_to_account_level_model', '--dry-run', '--batch-size=1') + + # Check that no actual database changes were made + self.assertEqual(NotificationPreference.objects.count(), 0) + + # Verify dry-run logging + mock_logger.info.assert_any_call( + 'Performing a DRY RUN. No changes will be made to the database.' + ) + + @patch(f'{COMMAND_MODULE}.logger') + def test_handle_use_default_mode(self, mock_logger): + """Test command execution while using default mode.""" + sample_config = { + "grading": { + "enabled": True, + "notification_types": { + "core": { + "web": True, + "push": True, + "email": True, + "email_cadence": "Daily" + }, + "ora_grade_assigned": { + "web": True, + "push": True, + "email": True, + "email_cadence": "Daily" + } + }, + "core_notification_types": [] + }, + "discussion": { + "enabled": True, + "notification_types": { + "core": { + "web": False, + "push": False, + "email": False, + "email_cadence": "Weekly" + }, + "new_discussion_post": { + "web": True, + "push": True, + "email": True, + "email_cadence": "Immediately" + } + }, + "core_notification_types": ["response_on_followed_post"] + } + } + CourseNotificationPreference.objects.create( + user=self.user1, + course_id='course-v1:Test+Course+1', + notification_preference_config=sample_config + ) + + call_command( + 'migrate_preferences_to_account_level_model', + '--use-default', + 'push' + ) + # Check that no actual database changes were made + self.assertEqual(NotificationPreference.objects.count(), 3) + self.assertEqual( + NotificationPreference.objects.get(type='ora_grade_assigned').push, + False + ) + self.assertEqual( + NotificationPreference.objects.get(type='new_discussion_post').push, + False + ) + self.assertEqual( + NotificationPreference.objects.get(type='response_on_followed_post').push, + True + ) + + def test_handle_normal_execution(self): + """Test normal command execution without dry-run.""" + CourseNotificationPreference.objects.create( + user=self.user1, + course_id='course-v1:Test+Course+1', + notification_preference_config=self.sample_config + ) + + # Create existing account preferences to test deletion + NotificationPreference.objects.create( + user=self.user1, + app='old_app', + type='old_type', + web=True, + push=False, + email=False, + email_cadence='Daily' + ) + + with patch.object(Command, '_process_batch') as mock_process: + mock_process.return_value = [ + NotificationPreference( + user_id=self.user1.id, + app='grading', + type='test_type', + web=True, + push=False, + email=True, + email_cadence='Daily' + ) + ] + + call_command('migrate_preferences_to_account_level_model', '--batch-size=1') + + # Verify old preferences were deleted and new ones created + self.assertEqual(NotificationPreference.objects.count(), 1) + new_pref = NotificationPreference.objects.first() + self.assertEqual(new_pref.app, 'grading') + self.assertEqual(new_pref.type, 'test_type') + + @patch(f'{COMMAND_MODULE}.transaction.atomic') + def test_migrate_preferences_to_account_level_model(self, mock_atomic): + """Test that users are processed in batches correctly.""" + # Mock atomic to avoid transaction issues during testing + mock_atomic.return_value.__enter__ = Mock() + mock_atomic.return_value.__exit__ = Mock(return_value=None) + + # Create course preferences for multiple users + for i, user in enumerate([self.user1, self.user2, self.user3]): + CourseNotificationPreference.objects.create( + user=user, + course_id=f'course-v1:Test+Course+{i}', + notification_preference_config=self.sample_config + ) + + call_command('migrate_preferences_to_account_level_model', '--batch-size=2') + # Check that preferences were created for each user + self.assertEqual(NotificationPreference.objects.count(), 18) + + def test_command_arguments(self): + """Test that command arguments are handled correctly.""" + command = Command() + parser = command.create_parser('test', 'migrate_preferences_to_account_level_model') + + # Test default arguments + options = parser.parse_args([]) + self.assertEqual(options.batch_size, 1000) + self.assertFalse(options.dry_run) + + # Test custom arguments + options = parser.parse_args(['--batch-size', '500', '--dry-run']) + self.assertEqual(options.batch_size, 500) + self.assertTrue(options.dry_run) + + @patch(f'{COMMAND_MODULE}.aggregate_notification_configs') + def test_process_user_preferences_with_core_types(self, mock_aggregate): + """Test processing of core notification types specifically.""" + CourseNotificationPreference.objects.create( + user=self.user1, + course_id='course-v1:Test+Course+1', + notification_preference_config=self.sample_config + ) + + mock_aggregate.return_value = { + 'discussion': { + 'notification_types': { + 'core': {'web': False, 'push': True, 'email': False, 'email_cadence': 'Weekly'} + }, + 'core_notification_types': ['new_response', 'new_comment', None, 123] # Include invalid types + } + } + + command = Command() + with self.assertLogs(level='WARNING') as log: + preferences = command._process_batch([self.user1.id]) + + # Should create 2 valid core preferences (ignoring None and 123) + valid_prefs = [p for p in preferences if p.type in ['new_response', 'new_comment']] + self.assertEqual(len(valid_prefs), 2) + + # Check that invalid core types were logged as warnings + warning_logs = [log for log in log.output if 'Skipping malformed core_type_name' in log] + self.assertEqual(len(warning_logs), 2) + + def test_progress_logging(self): + """Test that progress is logged at appropriate intervals.""" + # Create enough users to trigger progress logging + users = [] + for i in range(10): + user = User.objects.create_user(username=f'userX{i}', email=f'userx{i}@example.com') + users.append(user) + CourseNotificationPreference.objects.create( + user=user, + course_id=f'course-v1:Test+Course+{i}', + notification_preference_config=self.sample_config + ) + + with patch.object(Command, '_process_batch') as mock_process: + mock_process.return_value = [] + + with patch(f'{COMMAND_MODULE}.logger') as mock_logger: + call_command('migrate_preferences_to_account_level_model', '--batch-size=1') + + # Check that progress was logged (every 5 batches) + progress_calls = [call for call in mock_logger.info.call_args_list + if 'PROGRESS:' in str(call)] + self.assertGreater(len(progress_calls), 0) + + def test_empty_batch_handling(self): + """Test handling when no preferences need to be created.""" + CourseNotificationPreference.objects.create( + user=self.user1, + course_id='course-v1:Test+Course+1', + notification_preference_config=self.sample_config + ) + + with patch.object(Command, '_process_batch') as mock_process: + mock_process.return_value = [] # No preferences to create + + with patch(f'{COMMAND_MODULE}.logger') as mock_logger: + call_command('migrate_preferences_to_account_level_model', '--batch-size=1') + + # Should log that no preferences were created + no_prefs_calls = [call for call in mock_logger.info.call_args_list + if 'No preferences to create' in str(call)] + self.assertEqual(len(no_prefs_calls), 1) diff --git a/openedx/core/djangoapps/notifications/migrations/0007_alter_notification_group_by_id.py b/openedx/core/djangoapps/notifications/migrations/0007_alter_notification_group_by_id.py new file mode 100644 index 0000000000..006198e5ee --- /dev/null +++ b/openedx/core/djangoapps/notifications/migrations/0007_alter_notification_group_by_id.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.20 on 2025-05-06 13:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0006_notification_group_by_id'), + ] + + operations = [ + migrations.AlterField( + model_name='notification', + name='group_by_id', + field=models.CharField(db_index=True, default='', max_length=255), + ), + ] diff --git a/openedx/core/djangoapps/notifications/migrations/0008_notificationpreference.py b/openedx/core/djangoapps/notifications/migrations/0008_notificationpreference.py new file mode 100644 index 0000000000..4427906ca4 --- /dev/null +++ b/openedx/core/djangoapps/notifications/migrations/0008_notificationpreference.py @@ -0,0 +1,37 @@ +# Generated by Django 4.2.21 on 2025-05-29 08:27 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django.utils.timezone +import model_utils.fields + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('notifications', '0007_alter_notification_group_by_id'), + ] + + operations = [ + migrations.CreateModel( + name='NotificationPreference', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', model_utils.fields.AutoCreatedField(default=django.utils.timezone.now, editable=False, verbose_name='created')), + ('modified', model_utils.fields.AutoLastModifiedField(default=django.utils.timezone.now, editable=False, verbose_name='modified')), + ('type', models.CharField(db_index=True, max_length=128)), + ('app', models.CharField(db_index=True, max_length=128)), + ('web', models.BooleanField(default=True)), + ('push', models.BooleanField(default=False)), + ('email', models.BooleanField(default=False)), + ('email_cadence', models.CharField(choices=[('Daily', 'Daily'), ('Weekly', 'Weekly'), ('Immediately', 'Immediately')], max_length=64)), + ('is_active', models.BooleanField(default=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_preference', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'unique_together': {('user', 'app', 'type')}, + }, + ), + ] diff --git a/openedx/core/djangoapps/notifications/migrations/0009_notification_push.py b/openedx/core/djangoapps/notifications/migrations/0009_notification_push.py new file mode 100644 index 0000000000..8314f8fe59 --- /dev/null +++ b/openedx/core/djangoapps/notifications/migrations/0009_notification_push.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.18 on 2025-03-12 22:30 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0008_notificationpreference'), + ] + + operations = [ + migrations.AddField( + model_name='notification', + name='push', + field=models.BooleanField(default=False), + ), + ] diff --git a/openedx/core/djangoapps/notifications/models.py b/openedx/core/djangoapps/notifications/models.py index 77f7b991b5..a555571e9f 100644 --- a/openedx/core/djangoapps/notifications/models.py +++ b/openedx/core/djangoapps/notifications/models.py @@ -14,6 +14,9 @@ from openedx.core.djangoapps.notifications.base_notification import ( NotificationPreferenceSyncManager, get_notification_content ) +from openedx.core.djangoapps.notifications.email import ONE_CLICK_EMAIL_UNSUB_KEY +from openedx.core.djangoapps.notifications.email_notifications import EmailCadence +from openedx.core.djangoapps.user_api.models import UserPreference User = get_user_model() log = logging.getLogger(__name__) @@ -23,7 +26,7 @@ NOTIFICATION_CHANNELS = ['web', 'push', 'email'] ADDITIONAL_NOTIFICATION_CHANNEL_SETTINGS = ['email_cadence'] # Update this version when there is a change to any course specific notification type or app. -COURSE_NOTIFICATION_CONFIG_VERSION = 12 +COURSE_NOTIFICATION_CONFIG_VERSION = 15 def get_course_notification_preference_config(): @@ -107,9 +110,10 @@ class Notification(TimeStampedModel): content_url = models.URLField(null=True, blank=True) web = models.BooleanField(default=True, null=False, blank=False) email = models.BooleanField(default=False, null=False, blank=False) + push = models.BooleanField(default=False, null=False, blank=False) last_read = models.DateTimeField(null=True, blank=True) last_seen = models.DateTimeField(null=True, blank=True) - group_by_id = models.CharField(max_length=42, db_index=True, null=False, default="") + group_by_id = models.CharField(max_length=255, db_index=True, null=False, default="") def __str__(self): return f'{self.user.username} - {self.course_id} - {self.app_name} - {self.notification_type}' @@ -122,6 +126,57 @@ class Notification(TimeStampedModel): return get_notification_content(self.notification_type, self.content_context) +class NotificationPreference(TimeStampedModel): + """ + Model to store notification preferences for users at account level + """ + + class EmailCadenceChoices(models.TextChoices): + DAILY = 'Daily' + WEEKLY = 'Weekly' + IMMEDIATELY = 'Immediately' + + class Meta: + # Ensures user do not have duplicate preferences. + unique_together = ('user', 'app', 'type',) + + user = models.ForeignKey(User, related_name="notification_preference", on_delete=models.CASCADE) + type = models.CharField(max_length=128, db_index=True) + app = models.CharField(max_length=128, null=False, blank=False, db_index=True) + web = models.BooleanField(default=True, null=False, blank=False) + push = models.BooleanField(default=False, null=False, blank=False) + email = models.BooleanField(default=False, null=False, blank=False) + email_cadence = models.CharField(max_length=64, choices=EmailCadenceChoices.choices, null=False, blank=False) + is_active = models.BooleanField(default=True) + + def is_enabled_for_any_channel(self, *args, **kwargs) -> bool: + """ + Returns True if the notification preference is enabled for any channel. + """ + return self.web or self.push or self.email + + def get_channels_for_notification_type(self, *args, **kwargs) -> list: + """ + Returns the channels for the given app name and notification type. + Sample Response: + ['web', 'push'] + """ + channels = [] + if self.web: + channels.append('web') + if self.push: + channels.append('push') + if self.email: + channels.append('email') + return channels + + def get_email_cadence_for_notification_type(self, *args, **kwargs) -> str: + """ + Returns the email cadence for the notification type. + """ + return self.email_cadence + + class CourseNotificationPreference(TimeStampedModel): """ Model to store notification preferences for users @@ -146,24 +201,59 @@ class CourseNotificationPreference(TimeStampedModel): """ Returns updated courses preferences for a user """ - preferences, _ = CourseNotificationPreference.objects.get_or_create( - user_id=user_id, - course_id=course_id, - is_active=True, - ) + email_opt_out = False + try: + preferences = CourseNotificationPreference.objects.get(user_id=user_id, course_id=course_id, is_active=True) + except CourseNotificationPreference.DoesNotExist: + email_opt_out = UserPreference.objects.filter(user_id=user_id, key=ONE_CLICK_EMAIL_UNSUB_KEY).exists() + preferences = CourseNotificationPreference.objects.create( + user_id=user_id, + course_id=course_id, + is_active=True, + notification_preference_config=NotificationAppManager().get_notification_app_preferences(email_opt_out) + ) current_config_version = get_course_notification_preference_config_version() if current_config_version != preferences.config_version: try: current_prefs = preferences.notification_preference_config - new_prefs = NotificationPreferenceSyncManager.update_preferences(current_prefs) + new_prefs = NotificationPreferenceSyncManager.update_preferences(current_prefs, email_opt_out) preferences.config_version = current_config_version preferences.notification_preference_config = new_prefs preferences.save() - # pylint: disable-next=broad-except + # pylint: disable-next=broad-except except Exception as e: log.error(f'Unable to update notification preference to new config. {e}') return preferences + @staticmethod + def get_user_notification_preferences(user): + """ + Checks if all user preferences have updated versions and returns the user preferences. + Updates any preferences that need to be updated to the latest config version. + """ + preferences = CourseNotificationPreference.objects.filter(user=user, is_active=True) + email_opt_out = UserPreference.objects.filter(user_id=user.id, key=ONE_CLICK_EMAIL_UNSUB_KEY).exists() + current_config_version = get_course_notification_preference_config_version() + preferences_to_update = [] + + try: + for preference in preferences: + if preference.config_version != current_config_version: + current_prefs = preference.notification_preference_config + new_prefs = NotificationPreferenceSyncManager.update_preferences(current_prefs, email_opt_out) + preference.config_version = current_config_version + preference.notification_preference_config = new_prefs + preferences_to_update.append(preference) + if preferences_to_update: + CourseNotificationPreference.objects.bulk_update( + preferences_to_update, + ['config_version', 'notification_preference_config'] + ) + except Exception as e: # pylint: disable=broad-exception-caught + log.error(f'Unable to update notification preference to new config: {str(e)}') + + return preferences + @staticmethod def get_updated_user_course_preferences(user, course_id): return CourseNotificationPreference.get_user_course_preference(user.id, course_id) @@ -266,3 +356,19 @@ class CourseNotificationPreference(TimeStampedModel): } """ return self.get_notification_types(app_name).get('core', {}) + + def is_email_enabled_for_notification_type(self, app_name, notification_type) -> bool: + """ + Returns True if the email is enabled for the given app name and notification type. + """ + if self.is_core(app_name, notification_type): + return self.get_core_config(app_name).get('email', False) + return self.get_notification_type_config(app_name, notification_type).get('email', False) + + def get_email_cadence_for_notification_type(self, app_name, notification_type) -> str: + """ + Returns the email cadence for the given app name and notification type. + """ + if self.is_core(app_name, notification_type): + return self.get_core_config(app_name).get('email_cadence', EmailCadence.NEVER) + return self.get_notification_type_config(app_name, notification_type).get('email_cadence', EmailCadence.NEVER) diff --git a/openedx/core/djangoapps/notifications/notification_content.py b/openedx/core/djangoapps/notifications/notification_content.py index c2103cb835..aa1e87d002 100644 --- a/openedx/core/djangoapps/notifications/notification_content.py +++ b/openedx/core/djangoapps/notifications/notification_content.py @@ -31,16 +31,7 @@ def get_notification_context_with_author_pronoun(context: Dict) -> Dict: # Returns notification content for the new_comment notification. -def get_new_comment_notification_context(context): - """ - Returns the context for the new_comment notification - """ - if not context.get('grouped'): - return get_notification_context_with_author_pronoun(context) - num_repliers = context['grouped_count'] - repliers_string = f"{num_repliers - 1} other{'s' if num_repliers > 2 else ''}" - context['replier_name'] = f"{context['replier_name_list'][0]} and {repliers_string}" - return context +get_new_comment_notification_context = get_notification_context_with_author_pronoun # Returns notification content for the comment_on_followed_post notification. diff --git a/lms/djangoapps/learner_dashboard/api/v0/tests/__init__.py b/openedx/core/djangoapps/notifications/push/__init__.py similarity index 100% rename from lms/djangoapps/learner_dashboard/api/v0/tests/__init__.py rename to openedx/core/djangoapps/notifications/push/__init__.py diff --git a/openedx/core/djangoapps/notifications/push/message_type.py b/openedx/core/djangoapps/notifications/push/message_type.py new file mode 100644 index 0000000000..dd061e092d --- /dev/null +++ b/openedx/core/djangoapps/notifications/push/message_type.py @@ -0,0 +1,10 @@ +""" +Push notifications MessageType +""" +from openedx.core.djangoapps.ace_common.message import BaseMessageType + + +class PushNotificationMessageType(BaseMessageType): + """ + Edx-ace MessageType for Push Notifications + """ diff --git a/openedx/core/djangoapps/notifications/push/tasks.py b/openedx/core/djangoapps/notifications/push/tasks.py new file mode 100644 index 0000000000..c9193a8a7f --- /dev/null +++ b/openedx/core/djangoapps/notifications/push/tasks.py @@ -0,0 +1,46 @@ +""" Tasks for sending notification to ace push channel """ +from celery.utils.log import get_task_logger +from django.conf import settings +from django.contrib.auth import get_user_model +from edx_ace import ace + +from .message_type import PushNotificationMessageType + +User = get_user_model() +logger = get_task_logger(__name__) + + +def send_ace_msg_to_push_channel(audience_ids, notification_object): + """ + Send mobile notifications using ace to push channels. + """ + if not audience_ids: + return + + # We are releasing this feature gradually. For now, it is only tested with the discussion app. + # We might have a list here in the future. + if notification_object.app_name != 'discussion': + return + + notification_type = notification_object.notification_type + + post_data = { + 'notification_id': notification_object.id, + 'notification_type': notification_type, + 'course_id': str(notification_object.course_id), + 'content_url': notification_object.content_url, + **notification_object.content_context + } + emails = list(User.objects.filter(id__in=audience_ids).values_list('email', flat=True)) + context = {'post_data': post_data} + + message = PushNotificationMessageType( + app_label="notifications", name="push" + ).personalize(None, 'en', context) + message.options['emails'] = emails + message.options['notification_type'] = notification_type + message.options['skip_disable_user_policy'] = True + + ace.send(message, limit_to_channels=getattr(settings, 'ACE_PUSH_CHANNELS', [])) + log_msg = 'Sent mobile notification for %s to ace push channel. Audience IDs: %s' + logger.info(log_msg, notification_type, audience_ids) diff --git a/openedx/features/learner_profile/__init__.py b/openedx/core/djangoapps/notifications/push/tests/__init__.py similarity index 100% rename from openedx/features/learner_profile/__init__.py rename to openedx/core/djangoapps/notifications/push/tests/__init__.py diff --git a/openedx/core/djangoapps/notifications/push/tests/test_tasks.py b/openedx/core/djangoapps/notifications/push/tests/test_tasks.py new file mode 100644 index 0000000000..1838c08b74 --- /dev/null +++ b/openedx/core/djangoapps/notifications/push/tests/test_tasks.py @@ -0,0 +1,65 @@ +""" +Tests for push notifications tasks. +""" +from unittest import mock + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.notifications.push.tasks import send_ace_msg_to_push_channel +from openedx.core.djangoapps.notifications.tests.utils import create_notification +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +class SendNotificationsTest(ModuleStoreTestCase): + """ + Tests for send_notifications. + """ + + def setUp(self): + """ + Create a course and users for the course. + """ + + super().setUp() + self.user_1 = UserFactory() + self.user_2 = UserFactory() + self.course_1 = CourseFactory.create( + org='testorg', + number='testcourse', + run='testrun' + ) + + self.notification = create_notification( + self.user, self.course_1.id, app_name='discussion', notification_type='new_comment' + ) + + @mock.patch('openedx.core.djangoapps.notifications.push.tasks.ace.send') + def test_send_ace_msg_success(self, mock_ace_send): + """ Test send_ace_msg_success """ + send_ace_msg_to_push_channel([self.user_1.id, self.user_2.id], self.notification) + + mock_ace_send.assert_called_once() + message_sent = mock_ace_send.call_args[0][0] + assert message_sent.options['emails'] == [self.user_1.email, self.user_2.email] + assert message_sent.options['notification_type'] == 'new_comment' + + @mock.patch('openedx.core.djangoapps.notifications.push.tasks.ace.send') + def test_send_ace_msg_no_sender(self, mock_ace_send): + """ Test when sender is not valid """ + send_ace_msg_to_push_channel([self.user_1.id, self.user_2.id], self.notification) + + mock_ace_send.assert_called_once() + + @mock.patch('openedx.core.djangoapps.notifications.push.tasks.ace.send') + def test_send_ace_msg_empty_audience(self, mock_ace_send): + """ Test send_ace_msg_success with empty audience """ + send_ace_msg_to_push_channel([], self.notification) + mock_ace_send.assert_not_called() + + @mock.patch('openedx.core.djangoapps.notifications.push.tasks.ace.send') + def test_send_ace_msg_non_discussion_app(self, mock_ace_send): + """ Test send_ace_msg_success with non-discussion app """ + self.notification.app_name = 'ecommerce' + self.notification.save() + send_ace_msg_to_push_channel([1], self.notification) + mock_ace_send.assert_not_called() diff --git a/openedx/core/djangoapps/notifications/serializers.py b/openedx/core/djangoapps/notifications/serializers.py index 79c8c4af9d..582b33a1c5 100644 --- a/openedx/core/djangoapps/notifications/serializers.py +++ b/openedx/core/djangoapps/notifications/serializers.py @@ -1,6 +1,7 @@ """ Serializers for the notifications API. """ + from django.core.exceptions import ValidationError from rest_framework import serializers @@ -9,18 +10,59 @@ from openedx.core.djangoapps.content.course_overviews.models import CourseOvervi from openedx.core.djangoapps.notifications.models import ( CourseNotificationPreference, Notification, - get_notification_channels, get_additional_notification_channel_settings + get_additional_notification_channel_settings, + get_notification_channels ) + from .base_notification import COURSE_NOTIFICATION_APPS, COURSE_NOTIFICATION_TYPES, EmailCadence +from .email.utils import is_notification_type_channel_editable from .utils import remove_preferences_with_no_access def add_info_to_notification_config(config_obj): """ - Add info of all notification types + Enhances the notification configuration by appending descriptive 'info' to each notification type. + + This function supports two different structures of `config_obj`, depending on the source of the data: + either from the account preferences API (`AggregatedNotificationPreferences`) or the course preferences + API (`UserNotificationPreferenceView`). + + Supported input structures: + + 1. From account preferences API: + { + 'notification_app': { + 'notification_types': { + 'core': { ... }, + 'non-core': { ... } + } + } + } + + 2. From course preferences API: + { + 'notification_preference_config': { + 'notification_app': { + 'notification_types': { + 'core': { ... }, + 'non-core': { ... } + } + } + } + } + + For each notification type: + - If the type is 'core', its info is fetched from `COURSE_NOTIFICATION_APPS[notification_app]['core_info']`. + - For all other types, info is fetched from `COURSE_NOTIFICATION_TYPES[notification_type]['info']`. + + Parameters: + config_obj (dict): The notification configuration object to enhance. + + Returns: + dict: The enhanced configuration object with added 'info' fields. """ - config = config_obj['notification_preference_config'] + config = config_obj.get('notification_preference_config', config_obj) for notification_app, app_prefs in config.items(): notification_types = app_prefs.get('notification_types', {}) for notification_type, type_prefs in notification_types.items(): @@ -74,6 +116,9 @@ class UserCourseNotificationPreferenceSerializer(serializers.ModelSerializer): user = self.context['user'] preferences = add_info_to_notification_config(preferences) preferences = remove_preferences_with_no_access(preferences, user) + preferences['notification_preference_config'] = add_non_editable_in_preference( + preferences.get('notification_preference_config', {}) + ) return preferences def get_course_name(self, obj): @@ -142,6 +187,19 @@ class UserNotificationPreferenceUpdateSerializer(serializers.Serializer): f'{notification_channel} is not a valid notification channel setting.' ) + if notification_app and notification_type and notification_channel: + if not is_notification_type_channel_editable( + notification_app, + notification_type, + 'email' if notification_channel == 'email_cadence' else notification_channel + ): + raise ValidationError({ + 'notification_channel': ( + f'{notification_channel} is not editable for notification type ' + f'{notification_type}.' + ) + }) + return attrs def update(self, instance, validated_data): @@ -171,7 +229,7 @@ class UserNotificationPreferenceUpdateSerializer(serializers.Serializer): elif notification_channel and not notification_type: app_prefs = user_notification_preference_config[notification_app] for notification_type_name, notification_type_preferences in app_prefs['notification_types'].items(): - non_editable_channels = app_prefs['non_editable'].get(notification_type_name, []) + non_editable_channels = get_non_editable_channels(notification_app).get(notification_type_name, []) if notification_channel not in non_editable_channels: app_prefs['notification_types'][notification_type_name][notification_channel] = value @@ -198,7 +256,146 @@ class NotificationSerializer(serializers.ModelSerializer): 'content_context', 'content', 'content_url', + 'course_id', 'last_read', 'last_seen', 'created', ) + + +def validate_email_cadence(email_cadence: str) -> str: + """ + Validate email cadence value. + """ + if EmailCadence.get_email_cadence_value(email_cadence) is None: + raise ValidationError(f'{email_cadence} is not a valid email cadence.') + return email_cadence + + +def validate_notification_app(notification_app: str) -> str: + """ + Validate notification app value. + """ + if not COURSE_NOTIFICATION_APPS.get(notification_app): + raise ValidationError(f'{notification_app} is not a valid notification app.') + return notification_app + + +def validate_notification_app_enabled(notification_app: str) -> str: + """ + Validate notification app is enabled. + """ + + if COURSE_NOTIFICATION_APPS.get(notification_app) and COURSE_NOTIFICATION_APPS.get(notification_app)['enabled']: + return notification_app + raise ValidationError(f'{notification_app} is not a valid notification app.') + + +def validate_notification_type(notification_type: str) -> str: + """ + Validate notification type value. + """ + if not COURSE_NOTIFICATION_TYPES.get(notification_type): + raise ValidationError(f'{notification_type} is not a valid notification type.') + return notification_type + + +def validate_notification_channel(notification_channel: str) -> str: + """ + Validate notification channel value. + """ + valid_channels = set(get_notification_channels()) | set(get_additional_notification_channel_settings()) + if notification_channel not in valid_channels: + raise ValidationError(f'{notification_channel} is not a valid notification channel setting.') + return notification_channel + + +def get_non_editable_channels(app_name): + """ + Returns a dict of notification: [non-editable channels] for the given app name. + """ + non_editable = {"core": COURSE_NOTIFICATION_APPS[app_name].get("non_editable", [])} + for type_name, type_dict in COURSE_NOTIFICATION_TYPES.items(): + if type_dict.get("non_editable") and not type_dict["is_core"]: + non_editable[type_name] = type_dict["non_editable"] + return non_editable + + +def add_non_editable_in_preference(preference): + """ + Add non_editable preferences to the preference dict + """ + for app_name, app_dict in preference.items(): + non_editable = {} + for type_name in app_dict.get('notification_types', {}).keys(): + if type_name == "core": + non_editable_channels = COURSE_NOTIFICATION_APPS.get(app_name, {}).get('non_editable', []) + else: + non_editable_channels = COURSE_NOTIFICATION_TYPES.get(type_name, {}).get('non_editable', []) + if non_editable_channels: + non_editable[type_name] = non_editable_channels + app_dict['non_editable'] = non_editable + return preference + + +class UserNotificationPreferenceUpdateAllSerializer(serializers.Serializer): + """ + Serializer for user notification preferences update with custom field validators. + """ + notification_app = serializers.CharField( + required=True, + validators=[validate_notification_app, validate_notification_app_enabled] + ) + value = serializers.BooleanField(required=False) + notification_type = serializers.CharField( + required=True, + ) + notification_channel = serializers.CharField( + required=False, + validators=[validate_notification_channel] + ) + email_cadence = serializers.CharField( + required=False, + validators=[validate_email_cadence] + ) + + def validate(self, attrs): + """ + Cross-field validation for notification preference update. + """ + notification_app = attrs.get('notification_app') + notification_type = attrs.get('notification_type') + notification_channel = attrs.get('notification_channel') + email_cadence = attrs.get('email_cadence') + + # Validate email_cadence requirements + if email_cadence and not notification_type: + raise ValidationError({ + 'notification_type': 'notification_type is required for email_cadence.' + }) + + # Validate notification_channel requirements + if not email_cadence and notification_type and not notification_channel: + raise ValidationError({ + 'notification_channel': 'notification_channel is required for notification_type.' + }) + + # Validate notification type + if all([not COURSE_NOTIFICATION_TYPES.get(notification_type), notification_type != "core"]): + raise ValidationError(f'{notification_type} is not a valid notification type.') + + # Validate notification type and channel is editable + if notification_channel and notification_type: + if not is_notification_type_channel_editable( + notification_app, + notification_type, + "email" if notification_channel == "email_cadence" else notification_channel + ): + raise ValidationError({ + 'notification_channel': ( + f'{notification_channel} is not editable for notification type ' + f'{notification_type}.' + ) + }) + + return attrs diff --git a/openedx/core/djangoapps/notifications/tasks.py b/openedx/core/djangoapps/notifications/tasks.py index 75ad3f1ecd..0258f70ea2 100644 --- a/openedx/core/djangoapps/notifications/tasks.py +++ b/openedx/core/djangoapps/notifications/tasks.py @@ -16,23 +16,35 @@ from pytz import UTC from common.djangoapps.student.models import CourseEnrollment from openedx.core.djangoapps.notifications.audience_filters import NotificationFilter from openedx.core.djangoapps.notifications.base_notification import ( + COURSE_NOTIFICATION_APPS, + COURSE_NOTIFICATION_TYPES, get_default_values_of_preference, get_notification_content ) -from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATION_GROUPING, ENABLE_NOTIFICATIONS + +from openedx.core.djangoapps.notifications.email.tasks import send_immediate_cadence_email +from openedx.core.djangoapps.notifications.config.waffle import ( + ENABLE_NOTIFICATION_GROUPING, + ENABLE_NOTIFICATIONS, + ENABLE_ACCOUNT_LEVEL_PREFERENCES, + ENABLE_PUSH_NOTIFICATIONS +) +from openedx.core.djangoapps.notifications.email_notifications import EmailCadence from openedx.core.djangoapps.notifications.events import notification_generated_event from openedx.core.djangoapps.notifications.grouping_notifications import ( + NotificationRegistry, get_user_existing_notifications, - group_user_notifications, NotificationRegistry, + group_user_notifications ) from openedx.core.djangoapps.notifications.models import ( CourseNotificationPreference, Notification, + NotificationPreference, get_course_notification_preference_config_version ) +from openedx.core.djangoapps.notifications.push.tasks import send_ace_msg_to_push_channel from openedx.core.djangoapps.notifications.utils import clean_arguments, get_list_in_batches - logger = get_task_logger(__name__) @@ -114,12 +126,14 @@ def delete_expired_notifications(): logger.info(f'{total_deleted} Notifications deleted in {time_elapsed} seconds.') +# pylint: disable=too-many-statements @shared_task @set_code_owner_attribute def send_notifications(user_ids, course_key: str, app_name, notification_type, context, content_url): """ Send notifications to the users. """ + # pylint: disable=too-many-statements course_key = CourseKey.from_string(course_key) if not ENABLE_NOTIFICATIONS.is_enabled(course_key): return @@ -127,36 +141,60 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c if not is_notification_valid(notification_type, context): raise ValidationError(f"Notification is not valid {app_name} {notification_type} {context}") + account_level_pref_enabled = ENABLE_ACCOUNT_LEVEL_PREFERENCES.is_enabled() + user_ids = list(set(user_ids)) batch_size = settings.NOTIFICATION_CREATION_BATCH_SIZE group_by_id = context.pop('group_by_id', '') grouping_function = NotificationRegistry.get_grouper(notification_type) waffle_flag_enabled = ENABLE_NOTIFICATION_GROUPING.is_enabled(course_key) grouping_enabled = waffle_flag_enabled and group_by_id and grouping_function is not None - notifications_generated = False - notification_content = '' + generated_notification = None sender_id = context.pop('sender_id', None) default_web_config = get_default_values_of_preference(app_name, notification_type).get('web', False) generated_notification_audience = [] + email_notification_mapping = {} + push_notification_audience = [] + is_push_notification_enabled = ENABLE_PUSH_NOTIFICATIONS.is_enabled(course_key) + + if group_by_id and not grouping_enabled: + logger.info( + f"Waffle flag for group notifications: {waffle_flag_enabled}. " + f"Grouper registered for '{notification_type}': {bool(grouping_function)}. " + f"Group by ID: {group_by_id} ==Temp Log==" + ) for batch_user_ids in get_list_in_batches(user_ids, batch_size): logger.debug(f'Sending notifications to {len(batch_user_ids)} users in {course_key}') batch_user_ids = NotificationFilter().apply_filters(batch_user_ids, course_key, notification_type) - logger.debug(f'After applying filters, sending notifications to {len(batch_user_ids)} users in {course_key}') + logger.info(f'After applying filters, sending notifications to {len(batch_user_ids)} users in {course_key}') existing_notifications = ( - get_user_existing_notifications(batch_user_ids, notification_type, group_by_id, course_key))\ + get_user_existing_notifications(batch_user_ids, notification_type, group_by_id, course_key)) \ if grouping_enabled else {} # check if what is preferences of user and make decision to send notification or not - preferences = CourseNotificationPreference.objects.filter( - user_id__in=batch_user_ids, - course_id=course_key, - ) - preferences = list(preferences) + if account_level_pref_enabled: + preferences = NotificationPreference.objects.filter( + user_id__in=batch_user_ids, + app=app_name, + type=notification_type + ) + else: + preferences = CourseNotificationPreference.objects.filter( + user_id__in=batch_user_ids, + course_id=course_key, + ) + + preferences = list(preferences) if default_web_config: - preferences = create_notification_pref_if_not_exists(batch_user_ids, preferences, course_key) + if account_level_pref_enabled: + preferences = create_account_notification_pref_if_not_exists( + batch_user_ids, preferences, notification_type + ) + else: + preferences = create_notification_pref_if_not_exists(batch_user_ids, preferences, course_key) if not preferences: continue @@ -164,14 +202,17 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c notifications = [] for preference in preferences: user_id = preference.user_id - preference = update_user_preference(preference, user_id, course_key) + if not account_level_pref_enabled: + preference = update_user_preference(preference, user_id, course_key) if ( preference and - preference.is_enabled_for_any_channel(app_name, notification_type) and - preference.get_app_config(app_name).get('enabled', False) + preference.is_enabled_for_any_channel(app_name, notification_type) ): notification_preferences = preference.get_channels_for_notification_type(app_name, notification_type) + email_enabled = 'email' in notification_preferences + email_cadence = preference.get_email_cadence_for_notification_type(app_name, notification_type) + push_notification = is_push_notification_enabled and 'push' in notification_preferences new_notification = Notification( user_id=user_id, app_name=app_name, @@ -180,26 +221,41 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c content_url=content_url, course_id=course_key, web='web' in notification_preferences, - email='email' in notification_preferences, + email=email_enabled, + push=push_notification, group_by_id=group_by_id, ) + if email_enabled and (email_cadence == EmailCadence.IMMEDIATELY): + email_notification_mapping[user_id] = new_notification + + if push_notification: + push_notification_audience.append(user_id) + if grouping_enabled and existing_notifications.get(user_id, None): group_user_notifications(new_notification, existing_notifications[user_id]) else: notifications.append(new_notification) - generated_notification_audience.append(user_id) + + if not generated_notification: + generated_notification = new_notification + + generated_notification_audience.append(user_id) # send notification to users but use bulk_create - notification_objects = Notification.objects.bulk_create(notifications) - if notification_objects and not notifications_generated: - notifications_generated = True - notification_content = notification_objects[0].content + Notification.objects.bulk_create(notifications) - if notifications_generated: + if email_notification_mapping: + send_immediate_cadence_email(email_notification_mapping, course_key) + + if generated_notification: notification_generated_event( generated_notification_audience, app_name, notification_type, course_key, content_url, - notification_content, sender_id=sender_id + generated_notification.content, sender_id=sender_id ) + info_msg = "Sending %s %s notification to ace push channel for user ids %s" + logger.info(info_msg, generated_notification.app_name, + generated_notification.notification_type, push_notification_audience) + send_ace_msg_to_push_channel(push_notification_audience, generated_notification) def is_notification_valid(notification_type, context): @@ -223,6 +279,82 @@ def update_user_preference(preference: CourseNotificationPreference, user_id, co return preference +def update_account_user_preference(user_id: int) -> None: + """ + Update account level user preferences to ensure all notification types are present. + """ + notification_types = set(COURSE_NOTIFICATION_TYPES.keys()) + # Get existing notification types for the user + existing_types = set( + NotificationPreference.objects + .filter(user_id=user_id, type__in=notification_types) + .values_list('type', flat=True) + ) + + # Find missing notification types + missing_types = notification_types - existing_types + + if not missing_types: + return + + # Create new preferences for missing types + new_preferences = [ + create_notification_preference(user_id, notification_type) + for notification_type in missing_types + ] + + # Bulk create all new preferences + NotificationPreference.objects.bulk_create(new_preferences) + return + + +def create_notification_preference(user_id: int, notification_type: str) -> NotificationPreference: + """ + Create a single notification preference with appropriate defaults. + + Args: + user_id: ID of the user + notification_type: Type of notification + + Returns: + NotificationPreference instance + """ + notification_config = COURSE_NOTIFICATION_TYPES.get(notification_type, {}) + is_core = notification_config.get('is_core', False) + app = COURSE_NOTIFICATION_TYPES[notification_type]['notification_app'] + email_cadence = notification_config.get('email_cadence', EmailCadence.DAILY) + if is_core: + email_cadence = COURSE_NOTIFICATION_APPS[app]['core_email_cadence'] + return NotificationPreference( + user_id=user_id, + type=notification_type, + app=app, + web=_get_channel_default(is_core, notification_type, 'web'), + push=_get_channel_default(is_core, notification_type, 'push'), + email=_get_channel_default(is_core, notification_type, 'email'), + email_cadence=email_cadence, + ) + + +def _get_channel_default(is_core: bool, notification_type: str, channel: str) -> bool: + """ + Get the default value for a notification channel. + + Args: + is_core: Whether this is a core notification + notification_type: Type of notification + channel: Channel name (web, push, email) + + Returns: + Default boolean value for the channel + """ + if is_core: + notification_app = COURSE_NOTIFICATION_TYPES[notification_type]['notification_app'] + return COURSE_NOTIFICATION_APPS[notification_app][f'core_{channel}'] + + return COURSE_NOTIFICATION_TYPES[notification_type][channel] + + def create_notification_pref_if_not_exists(user_ids: List, preferences: List, course_id: CourseKey): """ Create notification preference if not exist. @@ -241,3 +373,24 @@ def create_notification_pref_if_not_exists(user_ids: List, preferences: List, co CourseNotificationPreference.objects.bulk_create(new_preferences, ignore_conflicts=True) preferences = preferences + new_preferences return preferences + + +def create_account_notification_pref_if_not_exists(user_ids: List, preferences: List, notification_type: str): + """ + Create account level notification preference if not exist. + """ + new_preferences = [] + + for user_id in user_ids: + if not any(preference.user_id == int(user_id) for preference in preferences): + new_preferences.append(create_notification_preference( + user_id=int(user_id), + notification_type=notification_type, + + )) + if new_preferences: + # ignoring conflicts because it is possible that preference is already created by another process + # conflicts may arise because of constraint on user_id and course_id fields in model + NotificationPreference.objects.bulk_create(new_preferences, ignore_conflicts=True) + preferences = preferences + new_preferences + return preferences diff --git a/openedx/core/djangoapps/notifications/templates/notifications/digest_content.html b/openedx/core/djangoapps/notifications/templates/notifications/digest_content.html index d482cd0c44..05613ff8a0 100644 --- a/openedx/core/djangoapps/notifications/templates/notifications/digest_content.html +++ b/openedx/core/djangoapps/notifications/templates/notifications/digest_content.html @@ -1,60 +1,68 @@ +{% load i18n %} {% for notification_app in email_content %} -

    - {{ notification_app.title }} -

    - {% if notification_app.help_text %} -

    - - {{ notification_app.help_text }} - - {% if notification_app.help_text_url %} - - - View all - +

    +

    + {{ notification_app.translated_title }} +

    + {% if notification_app.help_text %} +
    + + {{ notification_app.help_text }} - {% endif %} -

    - {% endif %} -

    -

    - - - {% for notification in notification_app.notifications %} - - - + + {% endfor %} + +
    - - -
    - - {{ notification.email_content | truncatechars_html:600 | safe }} -
    - {% if notification.details %} -
    - {{ notification.details | safe }} + {% if notification_app.help_text_url %} + + + {% trans "View all" as tmsg %}{{ tmsg | force_escape }} + + + {% endif %} +
    + {% endif %} +
    + + + {% for notification in notification_app.notifications %} + + + - - {% endfor %} - -
    + + +
    + + {{ notification.email_content | truncatechars_html:600 | safe }}
    - {% endif %} -
    - - {{ notification.course_name }} - {{ "·"|safe }} - {{ notification.time_ago }} - - - - View - - -
    -
    -

    -

    + {% if notification.details %} +

    + {{ notification.details | safe }} +
    + {% endif %} +
    + + {{ notification.course_name }} + {{ "·"|safe }} + {{ notification.time_ago }} + + + + {% trans "View" as tmsg %}{{ tmsg | force_escape }} {{ notification.view_text|default:""}} + + +
    +
    +

    + {% if notification_app.show_remaining_count %} +
    + + + {{ notification_app.remaining_count }} {% trans "more" as tmsg %}{{ tmsg | force_escape }} + +
    + {% endif %} +
    {% endfor %} diff --git a/openedx/core/djangoapps/notifications/templates/notifications/digest_footer.html b/openedx/core/djangoapps/notifications/templates/notifications/digest_footer.html index 34f4bf09d8..80e9f681cc 100644 --- a/openedx/core/djangoapps/notifications/templates/notifications/digest_footer.html +++ b/openedx/core/djangoapps/notifications/templates/notifications/digest_footer.html @@ -1,3 +1,4 @@ +{% load i18n %} @@ -6,7 +7,7 @@
    - Logo + Logo @@ -30,21 +31,25 @@ diff --git a/openedx/core/djangoapps/notifications/templates/notifications/digest_header.html b/openedx/core/djangoapps/notifications/templates/notifications/digest_header.html index 84a702d4c2..4d2c91c180 100644 --- a/openedx/core/djangoapps/notifications/templates/notifications/digest_header.html +++ b/openedx/core/djangoapps/notifications/templates/notifications/digest_header.html @@ -1,3 +1,4 @@ +{% load i18n %}
    -

    - You are receiving this email because you have subscribed to email digest -

    -

    +

    + {% if footer_email_reason %} + {{ footer_email_reason }} + {% else %} + {% trans "You are receiving this email because you have subscribed to email digest" as tmsg %}{{ tmsg | force_escape }} + {% endif %} +
    +
    - Notification Settings + {% trans "Notification Settings" as tmsg %}{{ tmsg | force_escape }} - Unsubscribe from email digest for learning activity + {% trans "Unsubscribe from email digest for learning activity" as tmsg %}{{ tmsg | force_escape }} -

    -

    - © {% now "Y" %} {{ platform_name }}. All Rights Reserved
    +

    +
    + © {% now "Y" %} {{ platform_name }}. {% trans "All rights reserved" as tmsg %}{{ tmsg | force_escape }}
    {{ mailing_address }} -

    +
    @@ -41,7 +46,7 @@
    - Unsubscribe + {% trans "Unsubscribe" as tmsg %}{{ tmsg | force_escape }}
    - logo_url + logo_url
    - {{ digest_frequency }} email digest + {% if digest_frequency == "Weekly" %} + {% trans "Weekly Notifications Digest" as tmsg %}{{ tmsg | force_escape }} + {% else %} + {% trans "Daily Notifications Digest" as tmsg %}{{ tmsg | force_escape }} + {% endif %}
    - +
    {% for update in email_digest_updates %} @@ -55,7 +60,7 @@ diff --git a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/email_digest/email/body.html b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/email_digest/email/body.html index 4d4daa7ca2..1963dc0998 100644 --- a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/email_digest/email/body.html +++ b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/email_digest/email/body.html @@ -1,3 +1,4 @@ +{% load i18n %} @@ -11,7 +12,7 @@ - diff --git a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/email_digest/email/body.txt b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/email_digest/email/body.txt index 3bbe26faf7..ab3439bf8d 100644 --- a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/email_digest/email/body.txt +++ b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/email_digest/email/body.txt @@ -1 +1,7 @@ -{{ digest_frequency }} Notifications Digest for {% if digest_frequency == "Weekly" %}the Week of {% endif %}{{ start_date }} +{% load i18n %} + +{% if digest_frequency == "Weekly" %} + {% trans "Weekly Notifications Digest for the Week of" %} {{ start_date }} +{% else %} + {% trans "Daily Notifications Digest for" %} {{ start_date }} +{% endif %} diff --git a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/email_digest/email/head.html b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/email_digest/email/head.html index 8d63916b7a..7a24f14872 100644 --- a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/email_digest/email/head.html +++ b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/email_digest/email/head.html @@ -1,3 +1,5 @@ +{% load i18n %} +{% get_current_language as LANGUAGE_CODE %} -{{ platform_name }} +{{ platform_name }} diff --git a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/email_digest/email/subject.txt b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/email_digest/email/subject.txt index 3bbe26faf7..ab3439bf8d 100644 --- a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/email_digest/email/subject.txt +++ b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/email_digest/email/subject.txt @@ -1 +1,7 @@ -{{ digest_frequency }} Notifications Digest for {% if digest_frequency == "Weekly" %}the Week of {% endif %}{{ start_date }} +{% load i18n %} + +{% if digest_frequency == "Weekly" %} + {% trans "Weekly Notifications Digest for the Week of" %} {{ start_date }} +{% else %} + {% trans "Daily Notifications Digest for" %} {{ start_date }} +{% endif %} diff --git a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/body.html b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/body.html new file mode 100644 index 0000000000..47eba46313 --- /dev/null +++ b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/body.html @@ -0,0 +1,21 @@ +{% load i18n %} + + + + +
    +
    - {{update.title}} + {{update.translated_title}}
    + {% include 'notifications/digest_content.html' %}
    + + + + + + + + +
    + {% include 'notifications/immediate_email_content.html' %} +
    + {% include 'notifications/digest_footer.html' %} +
    + diff --git a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/body.txt b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/body.txt new file mode 100644 index 0000000000..79b3b245f2 --- /dev/null +++ b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/body.txt @@ -0,0 +1 @@ +{{ content_title | safe }} diff --git a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/from_name.txt b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/from_name.txt new file mode 100644 index 0000000000..dcbc23c004 --- /dev/null +++ b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/from_name.txt @@ -0,0 +1 @@ +{{ platform_name }} diff --git a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/head.html b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/head.html new file mode 100644 index 0000000000..7a24f14872 --- /dev/null +++ b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/head.html @@ -0,0 +1,5 @@ +{% load i18n %} +{% get_current_language as LANGUAGE_CODE %} + +{{ platform_name }} + diff --git a/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/subject.txt b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/subject.txt new file mode 100644 index 0000000000..79b3b245f2 --- /dev/null +++ b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/immediate_email/email/subject.txt @@ -0,0 +1 @@ +{{ content_title | safe }} diff --git a/openedx/features/learner_profile/tests/__init__.py b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/push/push/body.txt similarity index 100% rename from openedx/features/learner_profile/tests/__init__.py rename to openedx/core/djangoapps/notifications/templates/notifications/edx_ace/push/push/body.txt diff --git a/openedx/features/learner_profile/tests/views/__init__.py b/openedx/core/djangoapps/notifications/templates/notifications/edx_ace/push/push/title.txt similarity index 100% rename from openedx/features/learner_profile/tests/views/__init__.py rename to openedx/core/djangoapps/notifications/templates/notifications/edx_ace/push/push/title.txt diff --git a/openedx/core/djangoapps/notifications/templates/notifications/immediate_email_content.html b/openedx/core/djangoapps/notifications/templates/notifications/immediate_email_content.html new file mode 100644 index 0000000000..37f69582f5 --- /dev/null +++ b/openedx/core/djangoapps/notifications/templates/notifications/immediate_email_content.html @@ -0,0 +1,31 @@ +{% load i18n %} +
    + + Logo + + + + {% trans "Unsubscribe" as tmsg %}{{ tmsg | force_escape }} + + +
    +
    +
    +

    + {{ course_name }} +

    +

    + {{ content_title | safe }} +

    +
    +
    +
    {{ content | safe }}
    +
    +
    +

    + + {% trans "View" as tmsg %}{{ tmsg | force_escape }} {{ view_text|default:""}} + +

    +
    +
    diff --git a/openedx/core/djangoapps/notifications/tests/test_base_notification.py b/openedx/core/djangoapps/notifications/tests/test_base_notification.py index 9a49fa987e..ff9b0c9ed6 100644 --- a/openedx/core/djangoapps/notifications/tests/test_base_notification.py +++ b/openedx/core/djangoapps/notifications/tests/test_base_notification.py @@ -186,42 +186,6 @@ class NotificationPreferenceSyncManagerTest(ModuleStoreTestCase): assert preference_type['email'] == email_value assert preference_type['push'] == push_value - def test_non_editable_addition_and_removal(self): - """ - Tests if non_editable updates on existing preferences - """ - current_config_version = get_course_notification_preference_config_version() - base_notification.COURSE_NOTIFICATION_TYPES[self.default_type_name]['non_editable'] = ['web'] - self._set_notification_config_version(current_config_version + 1) - new_config = CourseNotificationPreference.get_updated_user_course_preferences(self.user, self.course.id) - preferences = new_config.notification_preference_config - preference_non_editable = preferences[self.default_app_name]['non_editable'][self.default_type_name] - assert 'web' in preference_non_editable - base_notification.COURSE_NOTIFICATION_TYPES[self.default_type_name]['non_editable'] = [] - self._set_notification_config_version(current_config_version + 2) - new_config = CourseNotificationPreference.get_updated_user_course_preferences(self.user, self.course.id) - preferences = new_config.notification_preference_config - preference_non_editable = preferences[self.default_app_name]['non_editable'].get(self.default_type_name, []) - assert preference_non_editable == [] - - def test_non_editable_addition_and_removal_for_core_notification(self): - """ - Tests if non_editable updates on existing preferences of core notification - """ - current_config_version = get_course_notification_preference_config_version() - base_notification.COURSE_NOTIFICATION_APPS[self.default_app_name]['non_editable'] = ['web'] - self._set_notification_config_version(current_config_version + 1) - new_config = CourseNotificationPreference.get_updated_user_course_preferences(self.user, self.course.id) - preferences = new_config.notification_preference_config - preference_non_editable = preferences[self.default_app_name]['non_editable']['core'] - assert 'web' in preference_non_editable - base_notification.COURSE_NOTIFICATION_APPS[self.default_app_name]['non_editable'] = [] - self._set_notification_config_version(current_config_version + 2) - new_config = CourseNotificationPreference.get_updated_user_course_preferences(self.user, self.course.id) - preferences = new_config.notification_preference_config - preference_non_editable = preferences[self.default_app_name]['non_editable'].get('core', []) - assert preference_non_editable == [] - def test_notification_type_in_core(self): """ Tests addition/removal of core in notification type diff --git a/openedx/core/djangoapps/notifications/tests/test_models.py b/openedx/core/djangoapps/notifications/tests/test_models.py new file mode 100644 index 0000000000..fed0ab2e36 --- /dev/null +++ b/openedx/core/djangoapps/notifications/tests/test_models.py @@ -0,0 +1,44 @@ +""" +Test the notification app models +""" +import unittest +from unittest import mock + +import pytest + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.notifications.base_notification import NotificationAppManager +from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, \ + COURSE_NOTIFICATION_CONFIG_VERSION + + +@pytest.mark.django_db +class TestPreferenceModel(unittest.TestCase): + """ + Test the CourseNotificationPreference model. + """ + + def test_get_user_notification_preferences_method(self): + """ + Test the get_user_notification_preferences method. and check if version is updated properly. + """ + # Create a mock user and notification preference + user = UserFactory() + CourseNotificationPreference.objects.create( + user_id=user.id, + course_id='course-v1:edX+DemoX+Demo_Course', + is_active=True, + notification_preference_config=NotificationAppManager().get_notification_app_preferences(True) + ) + # Check if the notification preference is created + preference = CourseNotificationPreference.objects.get(user_id=user.id) + self.assertIsNotNone(preference) + self.assertTrue(preference.is_active) + + with mock.patch( + 'openedx.core.djangoapps.notifications.models.COURSE_NOTIFICATION_CONFIG_VERSION', + COURSE_NOTIFICATION_CONFIG_VERSION + 1 + ): + updated_preferences = preference.get_user_notification_preferences(user) + for updated_preference in updated_preferences: + assert updated_preference.config_version == COURSE_NOTIFICATION_CONFIG_VERSION + 1 diff --git a/openedx/core/djangoapps/notifications/tests/test_notification_grouping.py b/openedx/core/djangoapps/notifications/tests/test_notification_grouping.py index fea954a0ea..debd72d901 100644 --- a/openedx/core/djangoapps/notifications/tests/test_notification_grouping.py +++ b/openedx/core/djangoapps/notifications/tests/test_notification_grouping.py @@ -2,19 +2,22 @@ Tests for notification grouping module """ +import ddt import unittest from unittest.mock import MagicMock, patch from datetime import datetime from pytz import utc +from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.notifications.grouping_notifications import ( BaseNotificationGrouper, NotificationRegistry, - NewCommentGrouper, group_user_notifications, get_user_existing_notifications, NewPostGrouper ) from openedx.core.djangoapps.notifications.models import Notification +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory class TestNotificationRegistry(unittest.TestCase): @@ -22,6 +25,10 @@ class TestNotificationRegistry(unittest.TestCase): Tests for the NotificationRegistry class """ + @patch.dict( + 'openedx.core.djangoapps.notifications.base_notification.COURSE_NOTIFICATION_TYPES', + {'test_notification': 'Test Notification'} + ) def test_register_and_get_grouper(self): """ Test that the register and get_grouper methods work as expected @@ -43,65 +50,6 @@ class TestNotificationRegistry(unittest.TestCase): self.assertIsNone(grouper) -class TestNewCommentGrouper(unittest.TestCase): - """ - Tests for the NewCommentGrouper class - """ - - def setUp(self): - """ - Set up the test - """ - self.new_notification = MagicMock(spec=Notification) - self.old_notification = MagicMock(spec=Notification) - self.old_notification.content_context = { - 'replier_name': 'User1' - } - - def test_group_creates_grouping_keys(self): - """ - Test that the function creates the grouping keys - """ - updated_context = NewCommentGrouper().group(self.new_notification, self.old_notification) - - self.assertIn('replier_name_list', updated_context) - self.assertIn('grouped_count', updated_context) - self.assertEqual(updated_context['grouped_count'], 2) - self.assertTrue(updated_context['grouped']) - - def test_group_appends_to_existing_grouping(self): - """ - Test that the function appends to the existing grouping - """ - # Mock a pre-grouped notification - self.old_notification.content_context = { - 'replier_name': 'User1', - 'replier_name_list': ['User1', 'User2'], - 'grouped': True, - 'grouped_count': 2 - } - self.new_notification.content_context = {'replier_name': 'User3'} - - updated_context = NewCommentGrouper().group(self.new_notification, self.old_notification) - - self.assertIn('replier_name_list', updated_context) - self.assertEqual(len(updated_context['replier_name_list']), 3) - self.assertEqual(updated_context['grouped_count'], 3) - - def test_group_email_content(self): - """ - Tests email_content in content_context when grouping notification - """ - self.old_notification.content_context['email_content'] = 'old content' - self.new_notification.content_context = { - 'email_content': 'new content', - 'replier_name': 'user_2', - } - content_context = NewCommentGrouper().group(self.new_notification, self.old_notification) - self.assertIn('email_content', content_context) - self.assertEqual(content_context['email_content'], 'new content') - - class TestNewPostGrouper(unittest.TestCase): """ Tests for the NewPostGrouper class @@ -143,7 +91,8 @@ class TestNewPostGrouper(unittest.TestCase): self.assertFalse(updated_context.get('grouped', False)) -class TestGroupUserNotifications(unittest.TestCase): +@ddt.ddt +class TestGroupUserNotifications(ModuleStoreTestCase): """ Tests for the group_user_notifications function """ @@ -154,7 +103,7 @@ class TestGroupUserNotifications(unittest.TestCase): Test that the function groups notifications using the appropriate grou """ # Mock the grouper - mock_grouper = MagicMock(spec=NewCommentGrouper) + mock_grouper = MagicMock(spec=NewPostGrouper) mock_get_grouper.return_value = mock_grouper new_notification = MagicMock(spec=Notification) @@ -179,6 +128,36 @@ class TestGroupUserNotifications(unittest.TestCase): self.assertFalse(old_notification.save.called) + @ddt.data(datetime(2023, 1, 1, tzinfo=utc), None) + def test_not_grouped_when_notification_is_seen(self, last_seen): + """ + Notification is not grouped if the notification is marked as seen + """ + course = CourseFactory() + user = UserFactory() + notification_params = { + 'app_name': 'discussion', + 'notification_type': 'new_discussion_post', + 'course_id': course.id, + 'group_by_id': course.id, + 'content_url': 'http://example.com', + 'user': user, + 'last_seen': last_seen, + } + Notification.objects.create(content_context={ + 'username': 'User1', + 'post_title': ' Post title', + 'replier_name': 'User 1', + + }, **notification_params) + existing_notifications = get_user_existing_notifications( + [user.id], 'new_discussion_post', course.id, course.id + ) + if last_seen is None: + assert existing_notifications[user.id] is not None + else: + assert existing_notifications[user.id] is None + class TestGetUserExistingNotifications(unittest.TestCase): """ @@ -202,12 +181,12 @@ class TestGetUserExistingNotifications(unittest.TestCase): mock_filter.return_value = [mock_notification1, mock_notification2] user_ids = [1, 2] - notification_type = 'new_comment' + notification_type = 'new_discussion_post' group_by_id = 'group_id_1' course_id = 'course_1' result = get_user_existing_notifications(user_ids, notification_type, group_by_id, course_id) # Verify the results - self.assertEqual(result[1], mock_notification1) + self.assertEqual(result[1], mock_notification2) self.assertIsNone(result[2]) # user 2 has no notifications diff --git a/openedx/core/djangoapps/notifications/tests/test_tasks.py b/openedx/core/djangoapps/notifications/tests/test_tasks.py index 6dca9fd1f9..aba99c65e0 100644 --- a/openedx/core/djangoapps/notifications/tests/test_tasks.py +++ b/openedx/core/djangoapps/notifications/tests/test_tasks.py @@ -15,7 +15,7 @@ from common.djangoapps.student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from ..config.waffle import ENABLE_NOTIFICATIONS, ENABLE_NOTIFICATION_GROUPING +from ..config.waffle import ENABLE_NOTIFICATION_GROUPING, ENABLE_NOTIFICATIONS, ENABLE_PUSH_NOTIFICATIONS from ..models import CourseNotificationPreference, Notification from ..tasks import ( create_notification_pref_if_not_exists, @@ -116,6 +116,7 @@ class SendNotificationsTest(ModuleStoreTestCase): ) @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + @override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True) @ddt.data( ('discussion', 'new_comment_on_response'), # core notification ('discussion', 'new_response'), # non core notification @@ -168,6 +169,7 @@ class SendNotificationsTest(ModuleStoreTestCase): self.assertEqual(len(Notification.objects.all()), created_notifications_count) @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + @override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True) def test_notification_not_send_with_preference_disabled(self): """ Tests notification not send if preference is disabled @@ -192,23 +194,28 @@ class SendNotificationsTest(ModuleStoreTestCase): @override_waffle_flag(ENABLE_NOTIFICATION_GROUPING, True) @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + @override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True) def test_send_notification_with_grouping_enabled(self): """ Test send_notifications with grouping enabled. """ + ( + self.preference_v1.notification_preference_config['discussion'] + ['notification_types']['new_discussion_post']['web'] + ) = True + self.preference_v1.save() with patch('openedx.core.djangoapps.notifications.tasks.group_user_notifications') as user_notifications_mock: context = { - 'post_title': 'Post title', - 'author_name': 'author name', - 'replier_name': 'replier name', - 'group_by_id': 'group_by_id', + 'post_title': 'Test Post', + 'username': 'Test Author', + 'group_by_id': 'group_by_id' } content_url = 'https://example.com/' send_notifications( [self.user.id], str(self.course_1.id), 'discussion', - 'new_comment', + 'new_discussion_post', {**context}, content_url ) @@ -216,39 +223,13 @@ class SendNotificationsTest(ModuleStoreTestCase): [self.user.id], str(self.course_1.id), 'discussion', - 'new_comment', + 'new_discussion_post', {**context}, content_url ) self.assertEqual(Notification.objects.filter(user_id=self.user.id).count(), 1) user_notifications_mock.assert_called_once() - @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) - @ddt.data( - ('discussion', 'new_comment_on_response'), # core notification - ('discussion', 'new_response'), # non core notification - ) - @ddt.unpack - def test_send_with_app_disabled_notifications(self, app_name, notification_type): - """ - Test send_notifications does not create a new notification if the app is disabled. - """ - self.preference_v1.notification_preference_config['discussion']['enabled'] = False - self.preference_v1.save() - - context = { - 'post_title': 'Post title', - 'replier_name': 'replier name', - } - content_url = 'https://example.com/' - - # Call the `send_notifications` function. - send_notifications([self.user.id], str(self.course_1.id), app_name, notification_type, context, content_url) - - # Assert that `Notification` objects are not created for the users. - notification = Notification.objects.filter(user_id=self.user.id).first() - self.assertIsNone(notification) - @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) def test_notification_not_created_when_context_is_incomplete(self): try: @@ -288,9 +269,9 @@ class SendBatchNotificationsTest(ModuleStoreTestCase): @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) @ddt.data( - (settings.NOTIFICATION_CREATION_BATCH_SIZE, 10, 4), - (settings.NOTIFICATION_CREATION_BATCH_SIZE + 10, 12, 7), - (settings.NOTIFICATION_CREATION_BATCH_SIZE - 10, 10, 4), + (settings.NOTIFICATION_CREATION_BATCH_SIZE, 14, 7), + (settings.NOTIFICATION_CREATION_BATCH_SIZE + 10, 16, 10), + (settings.NOTIFICATION_CREATION_BATCH_SIZE - 10, 14, 6), ) @ddt.unpack def test_notification_is_send_in_batch(self, creation_size, prefs_query_count, notifications_query_count): @@ -319,6 +300,7 @@ class SendBatchNotificationsTest(ModuleStoreTestCase): for preference in preferences: discussion_config = preference.notification_preference_config['discussion'] discussion_config['notification_types'][notification_type]['web'] = True + discussion_config['notification_types'][notification_type]['push'] = True preference.save() # Creating notifications and asserting query count @@ -340,7 +322,7 @@ class SendBatchNotificationsTest(ModuleStoreTestCase): "username": "Test Author" } with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True): - with self.assertNumQueries(10): + with self.assertNumQueries(14): send_notifications(user_ids, str(self.course.id), notification_app, notification_type, context, "http://test.url") @@ -359,9 +341,10 @@ class SendBatchNotificationsTest(ModuleStoreTestCase): "replier_name": "Replier Name" } with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True): - with self.assertNumQueries(12): - send_notifications(user_ids, str(self.course.id), notification_app, notification_type, - context, "http://test.url") + with override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True): + with self.assertNumQueries(16): + send_notifications(user_ids, str(self.course.id), notification_app, notification_type, + context, "http://test.url") def _update_user_preference(self, user_id, pref_exists): """ @@ -373,6 +356,7 @@ class SendBatchNotificationsTest(ModuleStoreTestCase): CourseNotificationPreference.objects.filter(user_id=user_id, course_id=self.course.id).delete() @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + @override_waffle_flag(ENABLE_PUSH_NOTIFICATIONS, active=True) @ddt.data( ("new_response", True, True, 2), ("new_response", False, False, 2), diff --git a/openedx/core/djangoapps/notifications/tests/test_tasks_with_account_level_pref.py b/openedx/core/djangoapps/notifications/tests/test_tasks_with_account_level_pref.py new file mode 100644 index 0000000000..f320352610 --- /dev/null +++ b/openedx/core/djangoapps/notifications/tests/test_tasks_with_account_level_pref.py @@ -0,0 +1,578 @@ +""" +Tests for notifications tasks. +""" + +import datetime +from unittest.mock import patch + +import ddt +from django.conf import settings +from django.core.exceptions import ValidationError +from edx_toggles.toggles.testutils import override_waffle_flag + +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.tests.factories import UserFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from ..config.waffle import ENABLE_ACCOUNT_LEVEL_PREFERENCES, ENABLE_NOTIFICATION_GROUPING, ENABLE_NOTIFICATIONS +from ..models import CourseNotificationPreference, Notification, NotificationPreference +from ..tasks import ( + create_notification_pref_if_not_exists, + delete_notifications, + send_notifications, + update_user_preference +) +from .utils import create_notification + + +@patch('openedx.core.djangoapps.notifications.models.COURSE_NOTIFICATION_CONFIG_VERSION', 1) +@override_waffle_flag(ENABLE_ACCOUNT_LEVEL_PREFERENCES, active=True) +class TestNotificationsTasks(ModuleStoreTestCase): + """ + Tests for notifications tasks. + """ + + def setUp(self): + """ + Create a course and users for the course. + """ + + super().setUp() + self.user = UserFactory() + self.user_1 = UserFactory() + self.user_2 = UserFactory() + self.course_1 = CourseFactory.create( + org='testorg', + number='testcourse', + run='testrun' + ) + self.course_2 = CourseFactory.create( + org='testorg', + number='testcourse_2', + run='testrun' + ) + self.preference_v1 = CourseNotificationPreference.objects.create( + user_id=self.user.id, + course_id=self.course_1.id, + config_version=0, + ) + self.preference_v2 = CourseNotificationPreference.objects.create( + user_id=self.user.id, + course_id=self.course_2.id, + config_version=1, + ) + + def test_update_user_preference(self): + """ + Test whether update_user_preference updates the preference with the latest config version. + """ + # Test whether update_user_preference updates the preference with a different config version + updated_preference = update_user_preference(self.preference_v1, self.user, self.course_1.id) + self.assertEqual(updated_preference.config_version, 1) + + # Test whether update_user_preference does not update the preference if the config version is the same + updated_preference = update_user_preference(self.preference_v2, self.user, self.course_2.id) + self.assertEqual(updated_preference.config_version, 1) + + @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + def test_create_notification_pref_if_not_exists(self): + """ + Test whether create_notification_pref_if_not_exists creates a new preference if it doesn't exist. + """ + # Test whether create_notification_pref_if_not_exists creates a new preference if it doesn't exist + user_ids = [self.user.id, self.user_1.id, self.user_2.id] + preferences = [self.preference_v2] + updated_preferences = create_notification_pref_if_not_exists(user_ids, preferences, self.course_2.id) + self.assertEqual(len(updated_preferences), 3) # Should have created two new preferences + + # Test whether create_notification_pref_if_not_exists doesn't create a new preference if it already exists + updated_preferences = create_notification_pref_if_not_exists(user_ids, preferences, self.course_2.id) + self.assertEqual(len(updated_preferences), 3) # No new preferences should be created this time + + +@ddt.ddt +@override_waffle_flag(ENABLE_ACCOUNT_LEVEL_PREFERENCES, active=True) +class SendNotificationsTest(ModuleStoreTestCase): + """ + Tests for send_notifications. + """ + + def setUp(self): + """ + Create a course and users for the course. + """ + + super().setUp() + self.user = UserFactory() + self.course_1 = CourseFactory.create( + org='testorg', + number='testcourse', + run='testrun' + ) + + self.preference_v1 = CourseNotificationPreference.objects.create( + user_id=self.user.id, + course_id=self.course_1.id, + config_version=0, + ) + + @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + @ddt.data( + ('discussion', 'new_comment_on_response'), # core notification + ('discussion', 'new_response'), # non core notification + ) + @ddt.unpack + def test_send_notifications(self, app_name, notification_type): + """ + Test whether send_notifications creates a new notification. + """ + context = { + 'post_title': 'Post title', + 'replier_name': 'replier name', + } + content_url = 'https://example.com/' + + # Call the `send_notifications` function. + with patch('openedx.core.djangoapps.notifications.tasks.notification_generated_event') as event_mock: + send_notifications([self.user.id], str(self.course_1.id), app_name, notification_type, context, content_url) + assert event_mock.called + assert event_mock.call_args[0][0] == [self.user.id] + assert event_mock.call_args[0][1] == app_name + assert event_mock.call_args[0][2] == notification_type + + # Assert that `Notification` objects have been created for the users. + notification = Notification.objects.filter(user_id=self.user.id).first() + # Assert that the `Notification` objects have the correct properties. + self.assertEqual(notification.user_id, self.user.id) + self.assertEqual(notification.app_name, app_name) + self.assertEqual(notification.notification_type, notification_type) + self.assertEqual(notification.content_context, context) + self.assertEqual(notification.content_url, content_url) + self.assertEqual(notification.course_id, self.course_1.id) + + @ddt.data(True, False) + def test_enable_notification_flag(self, flag_value): + """ + Tests if notification is sent when flag is enabled and notification + is not sent when flag is disabled + """ + app_name = "discussion" + notification_type = "new_response" + context = { + 'post_title': 'Post title', + 'replier_name': 'replier name', + } + content_url = 'https://example.com/' + with override_waffle_flag(ENABLE_NOTIFICATIONS, active=flag_value): + send_notifications([self.user.id], str(self.course_1.id), app_name, notification_type, context, content_url) + created_notifications_count = 1 if flag_value else 0 + self.assertEqual(len(Notification.objects.all()), created_notifications_count) + + @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + def test_notification_not_send_with_preference_disabled(self): + """ + Tests notification not send if preference is disabled + """ + app_name = "discussion" + notification_type = "new_response" + context = { + 'post_title': 'Post title', + 'replier_name': 'replier name', + } + content_url = 'https://example.com/' + + preference = CourseNotificationPreference.get_user_course_preference(self.user.id, self.course_1.id) + app_prefs = preference.notification_preference_config[app_name] + app_prefs['notification_types']['core']['web'] = False + app_prefs['notification_types']['core']['email'] = False + app_prefs['notification_types']['core']['push'] = False + preference.save() + account_preferences, __created = NotificationPreference.objects.get_or_create( + user_id=self.user.id, + app=app_name, + type=notification_type, + ) + account_preferences.web = False + account_preferences.email = False + account_preferences.push = False + account_preferences.save() + + send_notifications([self.user.id], str(self.course_1.id), app_name, notification_type, context, content_url) + self.assertEqual(len(Notification.objects.all()), 0) + + @override_waffle_flag(ENABLE_NOTIFICATION_GROUPING, True) + @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + def test_send_notification_with_grouping_enabled(self): + """ + Test send_notifications with grouping enabled. + """ + ( + self.preference_v1.notification_preference_config['discussion'] + ['notification_types']['new_discussion_post']['web'] + ) = True + self.preference_v1.save() + + account_preferences, __created = NotificationPreference.objects.get_or_create( + user_id=self.user.id, + app='discussion', + type='new_discussion_post', + ) + account_preferences.web = True + account_preferences.save() + with patch('openedx.core.djangoapps.notifications.tasks.group_user_notifications') as user_notifications_mock: + context = { + 'post_title': 'Test Post', + 'username': 'Test Author', + 'group_by_id': 'group_by_id' + } + content_url = 'https://example.com/' + send_notifications( + [self.user.id], + str(self.course_1.id), + 'discussion', + 'new_discussion_post', + {**context}, + content_url + ) + send_notifications( + [self.user.id], + str(self.course_1.id), + 'discussion', + 'new_discussion_post', + {**context}, + content_url + ) + self.assertEqual(Notification.objects.filter(user_id=self.user.id).count(), 1) + user_notifications_mock.assert_called_once() + + @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + def test_notification_not_created_when_context_is_incomplete(self): + try: + send_notifications([self.user.id], str(self.course_1.id), "discussion", "new_comment", {}, "") + except Exception as exc: # pylint: disable=broad-except + assert isinstance(exc, ValidationError) + + +@ddt.ddt +@override_waffle_flag(ENABLE_ACCOUNT_LEVEL_PREFERENCES, active=True) +class SendBatchNotificationsTest(ModuleStoreTestCase): + """ + Test that notification and notification preferences are created in batches + """ + + def setUp(self): + """ + Setups test case + """ + super().setUp() + self.course = CourseFactory.create( + org='test_org', + number='test_course', + run='test_run' + ) + + def _create_users(self, num_of_users): + """ + Create users and enroll them in course + """ + users = [ + UserFactory.create(username=f'user{i}', email=f'user{i}@example.com') + for i in range(num_of_users) + ] + for user in users: + CourseEnrollment.enroll(user=user, course_key=self.course.id) + return users + + @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + @ddt.data( + (settings.NOTIFICATION_CREATION_BATCH_SIZE, 14, 5), + (settings.NOTIFICATION_CREATION_BATCH_SIZE + 10, 16, 7), + (settings.NOTIFICATION_CREATION_BATCH_SIZE - 10, 14, 5), + ) + @ddt.unpack + def test_notification_is_send_in_batch(self, creation_size, prefs_query_count, notifications_query_count): + """ + Tests notifications and notification preferences are created in batches + """ + notification_app = "discussion" + notification_type = "new_discussion_post" + users = self._create_users(creation_size) + user_ids = [user.id for user in users] + context = { + "post_title": "Test Post", + "username": "Test Author" + } + + # Creating preferences and asserting query count + with self.assertNumQueries(prefs_query_count): + send_notifications(user_ids, str(self.course.id), notification_app, notification_type, + context, "http://test.url") + + # Updating preferences for notification creation + preferences = CourseNotificationPreference.objects.filter( + user_id__in=user_ids, + course_id=self.course.id + ) + for preference in preferences: + discussion_config = preference.notification_preference_config['discussion'] + discussion_config['notification_types'][notification_type]['web'] = True + preference.save() + + # Creating notifications and asserting query count + with self.assertNumQueries(notifications_query_count): + send_notifications(user_ids, str(self.course.id), notification_app, notification_type, + context, "http://test.url") + + def test_preference_not_created_for_default_off_preference(self): + """ + Tests if new preferences are NOT created when default preference for + notification type is False + """ + notification_app = "discussion" + notification_type = "new_discussion_post" + users = self._create_users(20) + user_ids = [user.id for user in users] + context = { + "post_title": "Test Post", + "username": "Test Author" + } + with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True): + with self.assertNumQueries(14): + send_notifications(user_ids, str(self.course.id), notification_app, notification_type, + context, "http://test.url") + + def test_preference_created_for_default_on_preference(self): + """ + Tests if new preferences are created when default preference for + notification type is True + """ + notification_app = "discussion" + notification_type = "new_comment" + users = self._create_users(20) + NotificationPreference.objects.all().delete() + user_ids = [user.id for user in users] + context = { + "post_title": "Test Post", + "author_name": "Test Author", + "replier_name": "Replier Name" + } + with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True): + with self.assertNumQueries(16): + send_notifications(user_ids, str(self.course.id), notification_app, notification_type, + context, "http://test.url") + + def _update_user_preference(self, user_id, pref_exists): + """ + Removes or creates user preference based on pref_exists + """ + if pref_exists: + CourseNotificationPreference.objects.get_or_create(user_id=user_id, course_id=self.course.id) + else: + CourseNotificationPreference.objects.filter(user_id=user_id, course_id=self.course.id).delete() + + @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + @ddt.data( + ("new_response", True, True, 2), + ("new_response", False, False, 2), + ("new_response", True, False, 2), + ("new_discussion_post", True, True, 0), + ("new_discussion_post", False, False, 0), + ("new_discussion_post", True, False, 0), + ) + @ddt.unpack + def test_preference_enabled_in_batch_audience(self, notification_type, + user_1_pref_exists, user_2_pref_exists, generated_count): + """ + Tests if users with preference enabled in batch gets notification + """ + users = self._create_users(2) + user_ids = [user.id for user in users] + self._update_user_preference(user_ids[0], user_1_pref_exists) + self._update_user_preference(user_ids[1], user_2_pref_exists) + + app_name = "discussion" + context = { + 'post_title': 'Post title', + 'username': 'Username', + 'replier_name': 'replier name', + 'author_name': 'Authorname' + } + content_url = 'https://example.com/' + send_notifications(user_ids, str(self.course.id), app_name, notification_type, context, content_url) + self.assertEqual(len(Notification.objects.all()), generated_count) + + +@override_waffle_flag(ENABLE_ACCOUNT_LEVEL_PREFERENCES, active=True) +class TestDeleteNotificationTask(ModuleStoreTestCase): + """ + Tests delete_notification_function + """ + + def setUp(self): + """ + Setup + """ + super().setUp() + self.user = UserFactory() + self.course_1 = CourseFactory.create(org='org', number='num', run='run_01') + self.course_2 = CourseFactory.create(org='org', number='num', run='run_02') + Notification.objects.all().delete() + + def test_app_name_param(self): + """ + Tests if app_name parameter works as expected + """ + assert not Notification.objects.all() + create_notification(self.user, self.course_1.id, app_name='discussion', notification_type='new_comment') + create_notification(self.user, self.course_1.id, app_name='updates', notification_type='course_updates') + delete_notifications({'app_name': 'discussion'}) + assert not Notification.objects.filter(app_name='discussion') + assert Notification.objects.filter(app_name='updates') + + def test_notification_type_param(self): + """ + Tests if notification_type parameter works as expected + """ + assert not Notification.objects.all() + create_notification(self.user, self.course_1.id, app_name='discussion', notification_type='new_comment') + create_notification(self.user, self.course_1.id, app_name='discussion', notification_type='new_response') + delete_notifications({'notification_type': 'new_comment'}) + assert not Notification.objects.filter(notification_type='new_comment') + assert Notification.objects.filter(notification_type='new_response') + + def test_created_param(self): + """ + Tests if created parameter works as expected + """ + assert not Notification.objects.all() + create_notification(self.user, self.course_1.id, created=datetime.datetime(2024, 2, 10)) + create_notification(self.user, self.course_2.id, created=datetime.datetime(2024, 3, 12, 5)) + kwargs = { + 'created': { + 'created__gte': datetime.datetime(2024, 3, 12, 0, 0, 0), + 'created__lte': datetime.datetime(2024, 3, 12, 23, 59, 59), + } + } + delete_notifications(kwargs) + self.assertEqual(Notification.objects.all().count(), 1) + + def test_course_id_param(self): + """ + Tests if course_id parameter works as expected + """ + assert not Notification.objects.all() + create_notification(self.user, self.course_1.id) + create_notification(self.user, self.course_2.id) + delete_notifications({'course_id': self.course_1.id}) + assert not Notification.objects.filter(course_id=self.course_1.id) + assert Notification.objects.filter(course_id=self.course_2.id) + + +@ddt.ddt +class NotificationCreationOnChannelsTests(ModuleStoreTestCase): + """ + Tests for notification creation and channels value. + """ + + def setUp(self): + """ + Create a course and users for tests. + """ + + super().setUp() + self.user = UserFactory() + self.course = CourseFactory.create( + org='testorg', + number='testcourse', + run='testrun' + ) + + self.preference = CourseNotificationPreference.objects.create( + user_id=self.user.id, + course_id=self.course.id, + config_version=0, + ) + self.account_preference, __created = NotificationPreference.objects.get_or_create( + user_id=self.user.id, + app='discussion', + type='new_discussion_post', + web=False, + email=False, + ) + + @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + @ddt.data( + (False, False, 0), + (False, True, 1), + (True, False, 1), + (True, True, 1), + ) + @ddt.unpack + def test_notification_is_created_when_any_channel_is_enabled(self, web_value, email_value, generated_count): + """ + Tests if notification is created if any preference is enabled + """ + app_name = 'discussion' + notification_type = 'new_discussion_post' + app_prefs = self.preference.notification_preference_config[app_name] + app_prefs['notification_types'][notification_type]['web'] = web_value + app_prefs['notification_types'][notification_type]['email'] = email_value + kwargs = { + 'user_ids': [self.user.id], + 'course_key': str(self.course.id), + 'app_name': app_name, + 'notification_type': notification_type, + 'content_url': 'https://example.com/', + 'context': { + 'post_title': 'Post title', + 'username': 'user name', + }, + } + self.preference.save() + with patch('openedx.core.djangoapps.notifications.tasks.notification_generated_event') as event_mock: + send_notifications(**kwargs) + notifications = Notification.objects.all() + assert len(notifications) == generated_count + if notifications: + notification = Notification.objects.all()[0] + assert notification.web == web_value + assert notification.email == email_value + + @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + @override_waffle_flag(ENABLE_ACCOUNT_LEVEL_PREFERENCES, active=True) + @ddt.data( + (False, False, 0), + (False, True, 1), + (True, False, 1), + (True, True, 1), + ) + @ddt.unpack + def test_notification_is_created_when_any_channel_is_account_level(self, web_value, email_value, generated_count): + """ + Tests if notification is created if any preference is enabled on account level preferences + """ + app_name = 'discussion' + notification_type = 'new_discussion_post' + self.account_preference.web = web_value + self.account_preference.email = email_value + self.account_preference.save() + kwargs = { + 'user_ids': [self.user.id], + 'course_key': str(self.course.id), + 'app_name': app_name, + 'notification_type': notification_type, + 'content_url': 'https://example.com/', + 'context': { + 'post_title': 'Post title', + 'username': 'user name', + }, + } + with patch('openedx.core.djangoapps.notifications.tasks.notification_generated_event') as event_mock: + send_notifications(**kwargs) + notifications = Notification.objects.all() + assert len(notifications) == generated_count + if notifications: + notification = Notification.objects.all()[0] + assert notification.web == web_value + assert notification.email == email_value diff --git a/openedx/core/djangoapps/notifications/tests/test_utils.py b/openedx/core/djangoapps/notifications/tests/test_utils.py new file mode 100644 index 0000000000..c00f2ba237 --- /dev/null +++ b/openedx/core/djangoapps/notifications/tests/test_utils.py @@ -0,0 +1,349 @@ +""" +Test cases for the notification utility functions. +""" +import copy +import unittest + +import pytest + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.django_comment_common.models import assign_role, FORUM_ROLE_MODERATOR +from openedx.core.djangoapps.notifications.utils import aggregate_notification_configs, \ + filter_out_visible_preferences_by_course_ids + + +class TestAggregateNotificationConfigs(unittest.TestCase): + """ + Test cases for the aggregate_notification_configs function. + """ + + def test_empty_configs_list_returns_default(self): + """ + If the configs list is empty, the default config should be returned. + """ + default_config = [{ + "grading": { + "enabled": False, + "non_editable": {}, + "notification_types": { + "core": { + "web": False, + "push": False, + "email": False, + "email_cadence": "Daily" + } + } + } + }] + + result = aggregate_notification_configs(default_config) + assert result == default_config[0] + + def test_enable_notification_type(self): + """ + If a config enables a notification type, it should be enabled in the result. + """ + + config_list = [ + { + "grading": { + "enabled": False, + "non_editable": {}, + "notification_types": { + "core": { + "web": False, + "push": False, + "email": False, + "email_cadence": "Weekly" + } + } + } + }, + { + "grading": { + "enabled": True, + "notification_types": { + "core": { + "web": True, + "push": True, + "email": True, + "email_cadence": "Weekly" + } + } + } + }] + + result = aggregate_notification_configs(config_list) + assert result["grading"]["enabled"] is True + assert result["grading"]["notification_types"]["core"]["web"] is True + assert result["grading"]["notification_types"]["core"]["push"] is True + assert result["grading"]["notification_types"]["core"]["email"] is True + # Use default email_cadence + assert result["grading"]["notification_types"]["core"]["email_cadence"] == "Weekly" + + def test_merge_core_notification_types(self): + """ + Core notification types should be merged across configs. + """ + + config_list = [ + { + "discussion": { + "enabled": True, + "core_notification_types": ["new_comment"], + "notification_types": {} + } + }, + { + "discussion": { + "core_notification_types": ["new_response", "new_comment"] + } + + }] + + result = aggregate_notification_configs(config_list) + assert set(result["discussion"]["core_notification_types"]) == { + "new_comment", "new_response" + } + + def test_multiple_configs_aggregate(self): + """ + Multiple configs should be aggregated together. + """ + + config_list = [ + { + "updates": { + "enabled": False, + "notification_types": { + "course_updates": { + "web": False, + "push": False, + "email": False, + "email_cadence": "Weekly" + } + } + } + }, + { + "updates": { + "enabled": True, + "notification_types": { + "course_updates": { + "web": True, + "email_cadence": "Weekly" + } + } + } + }, + { + "updates": { + "notification_types": { + "course_updates": { + "push": True, + "email_cadence": "Weekly" + } + } + } + } + ] + + result = aggregate_notification_configs(config_list) + assert result["updates"]["enabled"] is True + assert result["updates"]["notification_types"]["course_updates"]["web"] is True + assert result["updates"]["notification_types"]["course_updates"]["push"] is True + assert result["updates"]["notification_types"]["course_updates"]["email"] is False + # Use default email_cadence + assert result["updates"]["notification_types"]["course_updates"]["email_cadence"] == "Weekly" + + def test_ignore_unknown_notification_types(self): + """ + Unknown notification types should be ignored. + """ + config_list = [ + { + "grading": { + "enabled": False, + "notification_types": { + "core": { + "web": False, + "push": False, + "email": False, + "email_cadence": "Daily" + } + } + } + }, + { + "grading": { + "notification_types": { + "unknown_type": { + "web": True, + "push": True, + "email": True + } + } + } + }] + + result = aggregate_notification_configs(config_list) + assert "unknown_type" not in result["grading"]["notification_types"] + assert result["grading"]["notification_types"]["core"]["web"] is False + + def test_ignore_unknown_categories(self): + """ + Unknown categories should be ignored. + """ + + config_list = [ + { + "grading": { + "enabled": False, + "notification_types": {} + } + }, + { + "unknown_category": { + "enabled": True, + "notification_types": {} + } + }] + + result = aggregate_notification_configs(config_list) + assert "unknown_category" not in result + assert result["grading"]["enabled"] is False + + def test_preserves_default_structure(self): + """ + The resulting config should have the same structure as the default config. + """ + + config_list = [ + { + "discussion": { + "enabled": False, + "non_editable": {"core": ["web"]}, + "notification_types": { + "core": { + "web": False, + "push": False, + "email": False, + "email_cadence": "Weekly" + } + }, + "core_notification_types": [] + } + }, + { + "discussion": { + "enabled": True, + "extra_field": "should_not_appear" + } + } + ] + + result = aggregate_notification_configs(config_list) + assert set(result["discussion"].keys()) == { + "enabled", "non_editable", "notification_types", "core_notification_types" + } + assert "extra_field" not in result["discussion"] + + def test_if_email_cadence_has_diff_set_mix_as_value(self): + """ + If email_cadence is different in the configs, set it to "Mixed". + """ + config_list = [ + { + "grading": { + "enabled": False, + "notification_types": { + "core": { + "web": False, + "push": False, + "email": False, + "email_cadence": "Daily" + } + } + } + }, + { + "grading": { + "enabled": True, + "notification_types": { + "core": { + "web": True, + "push": True, + "email": True, + "email_cadence": "Weekly" + } + } + } + }, + { + "grading": { + "notification_types": { + "core": { + "email_cadence": "Monthly" + } + } + } + } + ] + + result = aggregate_notification_configs(config_list) + assert result["grading"]["notification_types"]["core"]["email_cadence"] == "Mixed" + + +@pytest.mark.django_db +class TestVisibilityFilter(unittest.TestCase): + """ + Test cases for the filter_out_visible_preferences_by_course_ids function. + """ + + def setUp(self): + self.user = UserFactory() + self.course_key = "course-v1:edX+DemoX+Demo_Course" + self.mock_preferences = { + 'discussion': { + 'enabled': True, + 'non_editable': {'core': ['web']}, + 'notification_types': { + 'core': {'web': True, 'push': True, 'email': True, 'email_cadence': 'Daily'}, + 'content_reported': {'web': True, 'push': True, 'email': True, 'email_cadence': 'Daily'}, + 'new_question_post': {'web': False, 'push': False, 'email': False, 'email_cadence': 'Daily'}, + 'new_discussion_post': {'web': False, 'push': False, 'email': False, 'email_cadence': 'Daily'}, + 'new_instructor_all_learners_post': { + 'web': True, 'push': False, 'email': False, 'email_cadence': 'Daily' + } + }, + 'core_notification_types': [ + 'new_response', 'comment_on_followed_post', + 'response_endorsed_on_thread', 'new_comment_on_response', + 'new_comment', 'response_on_followed_post', 'response_endorsed' + ] + } + } + + def test_visibility_filter_with_no_role(self): + """ + Test that the preferences are filtered out correctly when the user has no role. + """ + updated_preferences = filter_out_visible_preferences_by_course_ids( + self.user, + copy.deepcopy(self.mock_preferences), + [self.course_key] + ) + assert updated_preferences != self.mock_preferences + assert not updated_preferences["discussion"]["notification_types"].get("content_reported", False) + + def test_visibility_filter_with_instructor_role(self): + """ + Instructors should see all preferences. + """ + updated_preferences = filter_out_visible_preferences_by_course_ids( + self.user, + self.mock_preferences, + [self.course_key] + ) + assign_role(self.course_key, self.user, FORUM_ROLE_MODERATOR) + assert updated_preferences == self.mock_preferences diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index 70e6fbc573..30837c9b86 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -1,12 +1,16 @@ """ Tests for the views in the notifications app. """ +import itertools import json +from copy import deepcopy from datetime import datetime, timedelta from unittest import mock +from unittest.mock import patch import ddt from django.conf import settings +from django.contrib.auth import get_user_model from django.test.utils import override_settings from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_flag @@ -17,7 +21,7 @@ from rest_framework import status from rest_framework.test import APIClient, APITestCase from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.roles import CourseStaffRole +from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole from common.djangoapps.student.tests.factories import UserFactory from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory @@ -27,18 +31,25 @@ from openedx.core.djangoapps.django_comment_common.models import ( FORUM_ROLE_MODERATOR ) from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS +from openedx.core.djangoapps.notifications.email import ONE_CLICK_EMAIL_UNSUB_KEY +from openedx.core.djangoapps.notifications.email.utils import encrypt_object, encrypt_string from openedx.core.djangoapps.notifications.models import ( CourseNotificationPreference, Notification, - get_course_notification_preference_config_version + get_course_notification_preference_config_version, NotificationPreference ) -from openedx.core.djangoapps.notifications.serializers import NotificationCourseEnrollmentSerializer -from openedx.core.djangoapps.notifications.email.utils import encrypt_object, encrypt_string +from openedx.core.djangoapps.notifications.serializers import NotificationCourseEnrollmentSerializer, \ + add_non_editable_in_preference +from openedx.core.djangoapps.user_api.models import UserPreference +from openedx.core.djangoapps.notifications.email.utils import update_user_preferences_from_patch from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -from ..base_notification import COURSE_NOTIFICATION_APPS, COURSE_NOTIFICATION_TYPES, NotificationAppManager -from ..utils import get_notification_types_with_visibility_settings +from ..base_notification import COURSE_NOTIFICATION_APPS, COURSE_NOTIFICATION_TYPES, NotificationAppManager, \ + NotificationTypeManager +from ..utils import get_notification_types_with_visibility_settings, exclude_inaccessible_preferences + +User = get_user_model() @ddt.ddt @@ -110,6 +121,7 @@ class CourseEnrollmentListViewTest(ModuleStoreTestCase): @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) +@ddt.ddt class CourseEnrollmentPostSaveTest(ModuleStoreTestCase): """ Tests for the post_save signal for CourseEnrollment. @@ -168,6 +180,68 @@ class CourseEnrollmentPostSaveTest(ModuleStoreTestCase): self.assertEqual(notification_preferences.count(), 1) self.assertEqual(notification_preferences[0].user, self.user) + def test_disabled_email_preference_is_generated_after_unsubscribe(self): + """ + Test the post_save signal for CourseEnrollment for user with one-click unsubscribe. + """ + UserPreference.objects.create(user_id=self.user.id, key=ONE_CLICK_EMAIL_UNSUB_KEY) + enrollment_data = CourseEnrollmentData( + user=UserData( + pii=UserPersonalData( + username=self.user.username, + email=self.user.email, + name=self.user.profile.name, + ), + id=self.user.id, + is_active=self.user.is_active, + ), + course=CourseData( + course_key=self.course.id, + display_name=self.course.display_name, + ), + mode=self.course_enrollment.mode, + is_active=self.course_enrollment.is_active, + creation_date=self.course_enrollment.created, + ) + COURSE_ENROLLMENT_CREATED.send_event( + enrollment=enrollment_data + ) + + notification_preferences = CourseNotificationPreference.objects.all() + + self.assertEqual(notification_preferences.count(), 1) + self.assertEqual(notification_preferences[0].user, self.user) + + email_preferences = [ + notification["email"] + for app in notification_preferences[0].notification_preference_config.values() + for notification in app["notification_types"].values() + ] + + self.assertEqual(email_preferences, [False] * len(email_preferences)) + + @ddt.data(*itertools.product(('web', 'email'), (True, False))) + @ddt.unpack + def test_course_preference_creation_for_inactive_enrollments_on_unsub( + self, + channel, + value + ): + """ + Test that unsubscribing through one click email does not create new course preferences for inactive enrollments + if not already exists. + """ + self.course_enrollment.is_active = False + self.course_enrollment.save() + encrypted_username = encrypt_string(self.user.username) + encrypted_patch = encrypt_object({ + 'channel': channel, + 'value': value + }) + update_user_preferences_from_patch(encrypted_username, encrypted_patch) + + self.assertEqual(CourseNotificationPreference.objects.all().count(), 0) + @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) @ddt.ddt @@ -217,12 +291,10 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): enrollment=enrollment_data ) - def _expected_api_response(self, course=None): + def _expected_api_response(self, is_staff=False): """ Helper method to return expected API response. """ - if course is None: - course = self.course response = { 'id': 1, 'course_name': 'course-v1:testorg+testcourse+testrun Course', @@ -265,13 +337,14 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): 'content_reported': { 'web': True, 'email': True, - 'push': True, + 'push': False, 'info': '', 'email_cadence': 'Daily', }, }, 'non_editable': { - 'core': ['web'] + 'new_discussion_post': ['push'], + 'new_question_post': ['push'], } }, 'updates': { @@ -281,7 +354,7 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): 'course_updates': { 'web': True, 'email': False, - 'push': True, + 'push': False, 'email_cadence': 'Daily', 'info': '' }, @@ -293,18 +366,21 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): 'info': 'Notifications for new announcements and updates from the course team.' } }, - 'non_editable': {} + 'non_editable': { + 'course_updates': ['push'] + } }, 'grading': { 'enabled': True, 'core_notification_types': [], 'notification_types': { - 'ora_staff_notification': { - 'web': False, + 'ora_staff_notifications': { + 'web': True, 'email': False, 'push': False, 'email_cadence': 'Daily', - 'info': '' + 'info': 'Notifications for when a submission is made for ORA that includes staff grading ' + 'step.' }, 'core': { 'web': True, @@ -321,10 +397,38 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): 'info': '' }, }, - 'non_editable': {} + 'non_editable': { + 'ora_grade_assigned': ['push'] + } + }, + "enrollments": { + "enabled": True, + "core_notification_types": [], + "notification_types": { + "audit_access_expiring_soon": { + "web": True, + "email": False, + "push": False, + "email_cadence": "Daily", + "info": "" + }, + "core": { + "web": True, + "email": True, + "push": True, + "email_cadence": "Daily", + "info": "Notifications for enrollments." + } + }, + "non_editable": {} } } } + if is_staff: + response['notification_preference_config']['grading']['non_editable'] = { + 'ora_staff_notifications': ['push'], + 'ora_grade_assigned': ['push'] + } return response def test_get_user_notification_preference_without_login(self): @@ -380,11 +484,10 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): response = self.client.get(self.path) self.assertEqual(response.status_code, status.HTTP_200_OK) - expected_response = self._expected_api_response() + expected_response = self._expected_api_response(is_staff=bool(role)) if not role: expected_response = remove_notifications_with_visibility_settings(expected_response) - self.assertEqual(response.data, expected_response) event_name, event_data = mock_emit.call_args[0] self.assertEqual(event_name, 'edx.notifications.preferences.viewed') @@ -468,6 +571,29 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): for _, type_prefs in app_prefs.get('notification_types', {}).items(): assert 'info' not in type_prefs.keys() + def test_non_editable_is_not_saved_in_json(self): + default_prefs = NotificationAppManager().get_notification_app_preferences() + for app_prefs in default_prefs.values(): + assert 'non_editable' not in app_prefs.keys() + + @ddt.data(*itertools.product(('email', 'web'), (True, False))) + @ddt.unpack + def test_unsub_user_preferences_removal_on_email_enabled(self, channel, value): + """ + Test one click unsub user preference should be removed on email enable for any app. + """ + UserPreference.objects.create(user=self.user, key=ONE_CLICK_EMAIL_UNSUB_KEY) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) + payload = { + 'notification_app': 'discussion', + 'notification_type': 'core', + 'notification_channel': channel, + 'value': value + } + self.client.patch(self.path, json.dumps(payload), content_type='application/json') + result = 0 if channel == 'email' and value else 1 + self.assertEqual(UserPreference.objects.count(), result) + @ddt.ddt class NotificationListAPIViewTest(APITestCase): @@ -705,6 +831,7 @@ class NotificationCountViewSetTestCase(ModuleStoreTestCase): Notification.objects.create(user=self.user, app_name='App Name 1', notification_type='Type B') Notification.objects.create(user=self.user, app_name='App Name 2', notification_type='Type A') Notification.objects.create(user=self.user, app_name='App Name 3', notification_type='Type C') + Notification.objects.create(user=self.user, app_name='App Name 4', notification_type='Type D', web=False) @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) @ddt.unpack @@ -719,7 +846,8 @@ class NotificationCountViewSetTestCase(ModuleStoreTestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.data['count'], 4) self.assertEqual(response.data['count_by_app_name'], { - 'App Name 1': 2, 'App Name 2': 1, 'App Name 3': 1, 'discussion': 0, 'updates': 0, 'grading': 0}) + 'App Name 1': 2, 'App Name 2': 1, 'App Name 3': 1, 'discussion': 0, + 'updates': 0, 'grading': 0, 'enrollments': 0}) self.assertEqual(response.data['show_notifications_tray'], True) def test_get_unseen_notifications_count_for_unauthenticated_user(self): @@ -740,7 +868,8 @@ class NotificationCountViewSetTestCase(ModuleStoreTestCase): self.assertEqual(response.status_code, 200) self.assertEqual(response.data['count'], 0) - self.assertEqual(response.data['count_by_app_name'], {'discussion': 0, 'updates': 0, 'grading': 0}) + self.assertEqual(response.data['count_by_app_name'], {'discussion': 0, 'updates': 0, + 'grading': 0, 'enrollments': 0}) def test_get_expiry_days_in_count_view(self): """ @@ -903,6 +1032,7 @@ class UpdatePreferenceFromEncryptedDataView(ModuleStoreTestCase): """ Tests if preference is updated when encrypted url is hit """ + def setUp(self): """ Setup test case @@ -968,3 +1098,755 @@ def remove_notifications_with_visibility_settings(expected_response): notification_type ) return expected_response + + +@ddt.ddt +class UpdateAllNotificationPreferencesViewTests(APITestCase): + """ + Tests for the UpdateAllNotificationPreferencesView. + """ + + def setUp(self): + # Create test user + self.user = User.objects.create_user( + username='testuser', + password='testpass123' + ) + self.client = APIClient() + self.client.force_authenticate(user=self.user) + self.url = reverse('update-all-notification-preferences') + + # Complex notification config structure + self.base_config = { + "grading": { + "enabled": True, + "non_editable": {}, + "notification_types": { + "core": { + "web": True, + "push": True, + "email": True, + "email_cadence": "Daily" + }, + "ora_staff_notifications": { + "web": False, + "push": False, + "email": False, + "email_cadence": "Daily" + } + }, + "core_notification_types": [] + }, + "updates": { + "enabled": True, + "non_editable": {}, + "notification_types": { + "core": { + "web": True, + "push": True, + "email": True, + "email_cadence": "Daily" + }, + "course_updates": { + "web": True, + "push": True, + "email": False, + "email_cadence": "Daily" + } + }, + "core_notification_types": [] + }, + "discussion": { + "enabled": True, + "non_editable": { + "core": ["web"] + }, + "notification_types": { + "core": { + "web": True, + "push": True, + "email": True, + "email_cadence": "Daily" + }, + "content_reported": { + "web": True, + "push": True, + "email": True, + "email_cadence": "Daily" + }, + "new_question_post": { + "web": True, + "push": False, + "email": False, + "email_cadence": "Daily" + }, + "new_discussion_post": { + "web": True, + "push": False, + "email": False, + "email_cadence": "Daily" + } + }, + "core_notification_types": [ + "new_comment_on_response", + "new_comment", + "new_response", + "response_on_followed_post", + "comment_on_followed_post", + "response_endorsed_on_thread", + "response_endorsed" + ] + } + } + + # Create test notification preferences + self.preferences = [] + for i in range(3): + pref = CourseNotificationPreference.objects.create( + user=self.user, + course_id=f'course-v1:TestX+Test{i}+2024', + notification_preference_config=deepcopy(self.base_config), + is_active=True + ) + self.preferences.append(pref) + + # Create an inactive preference + self.inactive_pref = CourseNotificationPreference.objects.create( + user=self.user, + course_id='course-v1:TestX+Inactive+2024', + notification_preference_config=deepcopy(self.base_config), + is_active=False + ) + + def test_update_discussion_notification(self): + """ + Test updating discussion notification settings + """ + data = { + 'notification_app': 'discussion', + 'notification_type': 'core', + 'notification_channel': 'web', + 'value': False + } + + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'success') + self.assertEqual(response.data['data']['total_updated'], 3) + + # Verify database updates + for pref in CourseNotificationPreference.objects.filter(is_active=True): + self.assertFalse( + pref.notification_preference_config['discussion'][ + 'notification_types']['core']['web'] + ) + + def test_update_non_editable_field(self): + """ + Test attempting to update a non-editable field + """ + data = { + 'notification_app': 'discussion', + 'notification_type': 'core', + 'notification_channel': 'web', + 'value': False + } + + response = self.client.post(self.url, data, format='json') + + # Should fail because 'web' is non-editable for 'core' in discussion + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'success') + + # Verify database remains unchanged + for pref in CourseNotificationPreference.objects.filter(is_active=True): + self.assertFalse( + pref.notification_preference_config['discussion']['notification_types']['core']['web'] + ) + + def test_update_email_cadence(self): + """ + Test updating email cadence setting + """ + data = { + 'notification_app': 'discussion', + 'notification_type': 'content_reported', + 'email_cadence': 'Weekly' + } + + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'success') + + # Verify database updates + for pref in CourseNotificationPreference.objects.filter(is_active=True): + notification_type = pref.notification_preference_config['discussion']['notification_types'][ + 'content_reported'] + self.assertEqual( + notification_type['email_cadence'], + 'Weekly' + ) + + @patch.dict('openedx.core.djangoapps.notifications.serializers.COURSE_NOTIFICATION_APPS', { + **COURSE_NOTIFICATION_APPS, + 'grading': { + 'enabled': False, + 'core_info': 'Notifications for submission grading.', + 'core_web': True, + 'core_email': True, + 'core_push': True, + 'core_email_cadence': 'Daily', + 'non_editable': [] + } + }) + def test_update_disabled_app(self): + """ + Test updating notification for a disabled app + """ + # Disable the grading app in all preferences + for pref in self.preferences: + config = pref.notification_preference_config + config['grading']['enabled'] = False + pref.notification_preference_config = config + pref.save() + + data = { + 'notification_app': 'grading', + 'notification_type': 'core', + 'notification_channel': 'email', + 'value': False + } + response = self.client.post(self.url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual(response.data['status'], 'error') + + def test_invalid_serializer_data(self): + """ + Test handling of invalid input data + """ + test_cases = [ + { + 'notification_app': 'invalid_app', + 'notification_type': 'core', + 'notification_channel': 'push', + 'value': False + }, + { + 'notification_app': 'discussion', + 'notification_type': 'invalid_type', + 'notification_channel': 'push', + 'value': False + }, + { + 'notification_app': 'discussion', + 'notification_type': 'core', + 'notification_channel': 'invalid_channel', + 'value': False + }, + { + 'notification_app': 'discussion', + 'notification_type': 'core', + 'notification_channel': 'email_cadence', + 'value': 'Invalid_Cadence' + } + ] + + for test_case in test_cases: + response = self.client.post(self.url, test_case, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + @ddt.data(*itertools.product(('email', 'web'), (True, False))) + @ddt.unpack + def test_unsub_user_preferences_removal_on_account_email_enabled(self, channel, value): + """ + Test one click unsub user preference should be removed on email enable for any app through account preferences + """ + UserPreference.objects.create(user=self.user, key=ONE_CLICK_EMAIL_UNSUB_KEY) + payload = { + 'notification_app': 'grading', + 'notification_type': 'core', + 'notification_channel': channel, + 'value': value + } + self.client.post(self.url, payload, format='json') + result = 0 if channel == 'email' and value else 1 + self.assertEqual(UserPreference.objects.count(), result) + + +class GetAggregateNotificationPreferencesTest(APITestCase): + """ + Tests for the GetAggregateNotificationPreferences API view. + """ + + def setUp(self): + # Set up a user and API client + self.user = User.objects.create_user(username='testuser', password='testpass') + self.client = APIClient() + self.client.force_authenticate(user=self.user) + self.url = reverse('notification-preferences-aggregated') # Adjust with the actual name + + def test_no_active_notification_preferences(self): + """ + Test case: No active notification preferences found for the user + """ + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + self.assertEqual(response.data['status'], 'error') + self.assertEqual(response.data['message'], 'No active notification preferences found') + + @patch('openedx.core.djangoapps.notifications.views.aggregate_notification_configs') + def test_with_active_notification_preferences(self, mock_aggregate): + """ + Test case: Active notification preferences found for the user + """ + # Mock aggregate_notification_configs for a controlled output + mock_aggregate.return_value = {'mocked': {'notification_types': {}}} + + # Create active notification preferences for the user + CourseNotificationPreference.objects.create( + user=self.user, + is_active=True, + notification_preference_config={'example': 'config'} + ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'success') + self.assertEqual(response.data['message'], 'Notification preferences retrieved') + self.assertDictEqual(response.data['data'], {'mocked': {'notification_types': {}, 'non_editable': {}}}) + + def test_unauthenticated_user(self): + """ + Test case: Request without authentication + """ + # Test case: Request without authentication + self.client.logout() # Remove authentication + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + @mock.patch.dict(COURSE_NOTIFICATION_APPS, { + **COURSE_NOTIFICATION_APPS, + **{ + 'discussion': { + 'name': 'content_reported', + 'non_editable': ["web"] + } + } + }) + @mock.patch.dict(COURSE_NOTIFICATION_TYPES, { + **COURSE_NOTIFICATION_TYPES, + **{ + 'course_updates': { + **COURSE_NOTIFICATION_TYPES['course_updates'], + 'non_editable': ["email"] + } + } + }) + def test_non_editable_is_added_in_api_response(self): + CourseNotificationPreference.objects.create(user=self.user, is_active=True) + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + prefs = response.data['data'] + self.assertDictEqual(prefs['updates']['non_editable'], {'course_updates': ['email']}) + self.assertDictEqual(prefs['discussion']['non_editable'], { + 'new_discussion_post': ['push'], + 'new_question_post': ['push'], + 'core': ['web'] + }) + + +@ddt.ddt +class TestNotificationPreferencesView(ModuleStoreTestCase): + """ + Tests for the NotificationPreferencesView API view. + """ + + def setUp(self): + # Set up a user and API client + super().setUp() + self.default_data = { + "status": "success", + "message": "Notification preferences retrieved successfully.", + "data": { + "discussion": { + "enabled": True, + "core_notification_types": [ + "new_comment_on_response", + "new_comment", + "new_response", + "response_on_followed_post", + "comment_on_followed_post", + "response_endorsed_on_thread", + "response_endorsed" + ], + "notification_types": { + "new_discussion_post": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "new_question_post": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "content_reported": { + "web": True, + "email": True, + "push": False, + "email_cadence": "Daily" + }, + "new_instructor_all_learners_post": { + "web": True, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "core": { + "web": True, + "email": True, + "push": True, + "email_cadence": "Daily" + } + }, + "non_editable": { + "new_discussion_post": ["push"], + "new_question_post": ["push"], + "content_reported": ["push"], + "new_instructor_all_learners_post": ["push"] + } + }, + "updates": { + "enabled": True, + "core_notification_types": [], + "notification_types": { + "course_updates": { + "web": True, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "core": { + "web": True, + "email": True, + "push": True, + "email_cadence": "Daily" + } + }, + "non_editable": { + "course_updates": ["push"], + } + }, + "grading": { + "enabled": True, + "core_notification_types": [], + "notification_types": { + "ora_staff_notifications": { + "web": True, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "ora_grade_assigned": { + "web": True, + "email": True, + "push": False, + "email_cadence": "Daily" + }, + "core": { + "web": True, + "email": True, + "push": True, + "email_cadence": "Daily" + } + }, + "non_editable": { + "ora_grade_assigned": ["push"], + "ora_staff_notifications": ["push"] + } + }, + "enrollments": { + "enabled": True, + "core_notification_types": [], + "notification_types": { + "audit_access_expiring_soon": { + "web": True, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "core": { + "web": True, + "email": True, + "push": True, + "email_cadence": "Daily" + } + }, + "non_editable": {} + } + } + } + self.TEST_PASSWORD = 'testpass' + self.user = UserFactory(password=self.TEST_PASSWORD) + self.client = APIClient() + self.client.force_authenticate(user=self.user) + self.url = reverse('notification-preferences-aggregated-v2') # Adjust with the actual name + self.course = CourseFactory.create(display_name='test course 1', run="Testing_course_1") + + @ddt.data( + ("forum", FORUM_ROLE_ADMINISTRATOR, ['content_reported'], ['ora_staff_notifications']), + ("forum", FORUM_ROLE_MODERATOR, ['content_reported'], ['ora_staff_notifications']), + ("forum", FORUM_ROLE_COMMUNITY_TA, ['content_reported'], ['ora_staff_notifications']), + ("course", CourseStaffRole.ROLE, ['ora_staff_notifications'], ['content_reported']), + ("course", CourseInstructorRole.ROLE, ['ora_staff_notifications'], ['content_reported']), + (None, None, [], ['ora_staff_notifications', 'content_reported']), + ) + @ddt.unpack + def test_get_notification_preferences(self, role_type, role, visible_apps, hidden_apps): + """ + Test: Notification preferences visibility for users with forum, course, or no role. + """ + role_instance = None + + if role_type == "course": + if role == CourseInstructorRole.ROLE: + CourseStaffRole(self.course.id).add_users(self.user) + else: + CourseInstructorRole(self.course.id).add_users(self.user) + self.client.login(username=self.user.username, password='testpass') + + elif role_type == "forum": + role_instance = RoleFactory(name=role, course_id=self.course.id) + role_instance.users.add(self.user) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'success') + self.assertIn('data', response.data) + + expected_data = exclude_inaccessible_preferences(self.default_data['data'], self.user) + expected_data = add_non_editable_in_preference(expected_data) + + self.assertEqual(response.data['data'], expected_data) + + notification_apps = {} + for app in ['discussion', 'grading']: + notification_apps.update(response.data['data'][app]['notification_types']) + + for app in visible_apps: + self.assertIn(app, notification_apps, msg=f"{app} should be visible for role: {role_type}") + + for app in hidden_apps: + self.assertNotIn(app, notification_apps, msg=f"{app} should NOT be visible for role: {role_type}") + + if role_type == "forum": + role_instance.users.clear() + elif role_type == "course": + if role == CourseInstructorRole.ROLE: + CourseStaffRole(self.course.id).remove_users(self.user) + else: + CourseInstructorRole(self.course.id).remove_users(self.user) + + def test_if_data_is_correctly_aggregated(self): + """ + Test case: Check if the data is correctly formatted + """ + + self.client.get(self.url) + NotificationPreference.objects.all().update( + web=False, + push=False, + email=False, + ) + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'success') + self.assertIn('data', response.data) + data = { + "status": "success", + "message": "Notification preferences retrieved successfully.", + "data": { + "discussion": { + "enabled": True, + "core_notification_types": [ + "new_comment_on_response", + "new_comment", + "new_response", + "response_on_followed_post", + "comment_on_followed_post", + "response_endorsed_on_thread", + "response_endorsed" + ], + "notification_types": { + "new_discussion_post": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "new_question_post": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "new_instructor_all_learners_post": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "core": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + } + }, + "non_editable": { + "new_discussion_post": ["push"], + "new_question_post": ["push"], + "new_instructor_all_learners_post": ["push"] + } + }, + "updates": { + "enabled": True, + "core_notification_types": [], + "notification_types": { + "course_updates": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "core": { + "web": True, + "email": True, + "push": True, + "email_cadence": "Daily" + } + }, + "non_editable": { + "course_updates": ["push"], + } + }, + "grading": { + "enabled": True, + "core_notification_types": [], + "notification_types": { + "ora_grade_assigned": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "core": { + "web": True, + "email": True, + "push": True, + "email_cadence": "Daily" + } + }, + "non_editable": { + "ora_grade_assigned": ["push"] + } + }, + "enrollments": { + "enabled": True, + "core_notification_types": [], + "notification_types": { + "audit_access_expiring_soon": { + "web": False, + "email": False, + "push": False, + "email_cadence": "Daily" + }, + "core": { + "web": True, + "email": True, + "push": True, + "email_cadence": "Daily" + } + }, + "non_editable": {} + } + } + } + self.assertEqual(response.data, data) + + def test_api_view_permissions(self): + """ + Test case: Ensure the API view has the correct permissions + """ + # Check if the view requires authentication + self.client.logout() + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + # Re-authenticate and check again + self.client.force_authenticate(user=self.user) + response = self.client.get(self.url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_update_preferences_core(self): + """ + Test case: Update notification preferences for the authenticated user + """ + update_data = { + "notification_app": "discussion", + "notification_type": "core", + "notification_channel": "email_cadence", + "email_cadence": "Weekly" + } + __, core_types = NotificationTypeManager().get_notification_app_preference('discussion') + self.client.get(self.url) + response = self.client.put(self.url, update_data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'success') + cadence_set = NotificationPreference.objects.filter(user=self.user, type__in=core_types).values_list( + 'email_cadence', flat=True + ) + self.assertEqual(len(set(cadence_set)), 1) + self.assertIn('Weekly', set(cadence_set)) + + def test_update_preferences(self): + """ + Test case: Update notification preferences for the authenticated user + """ + update_data = { + "notification_app": "discussion", + "notification_type": "new_discussion_post", + "notification_channel": "web", + "value": True + } + self.client.get(self.url) + response = self.client.put(self.url, update_data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'success') + preference = NotificationPreference.objects.get( + type='new_discussion_post', + user__id=self.user.id + ) + self.assertEqual(preference.web, True) + + def test_update_preferences_non_core_email(self): + """ + Test case: Update notification preferences for the authenticated user + """ + update_data = { + "notification_app": "discussion", + "notification_type": "new_discussion_post", + "notification_channel": "email_cadence", + "email_cadence": 'Weekly' + } + self.client.get(self.url) + response = self.client.put(self.url, update_data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['status'], 'success') + preference = NotificationPreference.objects.get( + type='new_discussion_post', + user__id=self.user.id + ) + self.assertEqual(preference.email_cadence, 'Weekly') diff --git a/openedx/core/djangoapps/notifications/urls.py b/openedx/core/djangoapps/notifications/urls.py index 7f611bc2c4..17e9f272f9 100644 --- a/openedx/core/djangoapps/notifications/urls.py +++ b/openedx/core/djangoapps/notifications/urls.py @@ -11,13 +11,13 @@ from .views import ( NotificationCountView, NotificationListAPIView, NotificationReadAPIView, + UpdateAllNotificationPreferencesView, UserNotificationPreferenceView, - preference_update_from_encrypted_username_view, + preference_update_from_encrypted_username_view, AggregatedNotificationPreferences, NotificationPreferencesView ) router = routers.DefaultRouter() - urlpatterns = [ path('enrollments/', CourseEnrollmentListView.as_view(), name='enrollment-list'), re_path( @@ -25,6 +25,16 @@ urlpatterns = [ UserNotificationPreferenceView.as_view(), name='notification-preferences' ), + path( + 'configurations/', + AggregatedNotificationPreferences.as_view(), + name='notification-preferences-aggregated' + ), + path( + 'v2/configurations/', + NotificationPreferencesView.as_view(), + name='notification-preferences-aggregated-v2' + ), path('', NotificationListAPIView.as_view(), name='notifications-list'), path('count/', NotificationCountView.as_view(), name='notifications-count'), path( @@ -35,6 +45,11 @@ urlpatterns = [ path('read/', NotificationReadAPIView.as_view(), name='notifications-read'), path('preferences/update///', preference_update_from_encrypted_username_view, name='preference_update_from_encrypted_username_view'), + path( + 'preferences/update-all/', + UpdateAllNotificationPreferencesView.as_view(), + name='update-all-notification-preferences' + ), ] urlpatterns += router.urls diff --git a/openedx/core/djangoapps/notifications/utils.py b/openedx/core/djangoapps/notifications/utils.py index fa948dcf42..fac192113e 100644 --- a/openedx/core/djangoapps/notifications/utils.py +++ b/openedx/core/djangoapps/notifications/utils.py @@ -1,11 +1,15 @@ """ Utils function for notifications app """ -from typing import Dict, List +import copy +from typing import Dict, List, Set + +from opaque_keys.edx.keys import CourseKey from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment from openedx.core.djangoapps.django_comment_common.models import Role -from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS, ENABLE_NEW_NOTIFICATION_VIEW +from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS, ENABLE_NOTIFY_ALL_LEARNERS +from openedx.core.djangoapps.notifications.email_notifications import EmailCadence from openedx.core.lib.cache_utils import request_cached @@ -47,13 +51,6 @@ def get_show_notifications_tray(user): return show_notifications_tray -def get_is_new_notification_view_enabled(): - """ - Returns True if the waffle flag for the new notification view is enabled, False otherwise. - """ - return ENABLE_NEW_NOTIFICATION_VIEW.is_enabled() - - def get_list_in_batches(input_list, batch_size): """ Divides the list of objects into list of list of objects each of length batch_size. @@ -138,12 +135,21 @@ def remove_preferences_with_no_access(preferences: dict, user) -> dict: user=user, course_id=preferences['course_id'] ).values_list('role', flat=True) - preferences['notification_preference_config'] = filter_out_visible_notifications( + + user_preferences = filter_out_visible_notifications( user_preferences, notifications_with_visibility_settings, user_forum_roles, user_course_roles ) + + course_key = CourseKey.from_string(preferences['course_id']) + discussion_config = user_preferences.get('discussion', {}) + notification_types = discussion_config.get('notification_types', {}) + + if notification_types and not ENABLE_NOTIFY_ALL_LEARNERS.is_enabled(course_key): + notification_types.pop('new_instructor_all_learners_post', None) + return preferences @@ -158,3 +164,168 @@ def clean_arguments(kwargs): if kwargs.get('created', {}): clean_kwargs.update(kwargs.get('created')) return clean_kwargs + + +def update_notification_types( + app_config: Dict, + user_app_config: Dict, +) -> None: + """ + Update notification types for a specific category configuration. + """ + if "notification_types" not in user_app_config: + return + + for type_key, type_config in user_app_config["notification_types"].items(): + if type_key not in app_config["notification_types"]: + continue + + update_notification_fields( + app_config["notification_types"][type_key], + type_config, + ) + + +def update_notification_fields( + target_config: Dict, + source_config: Dict, +) -> None: + """ + Update individual notification fields (web, push, email) and email_cadence. + """ + for field in ["web", "push", "email"]: + if field in source_config: + target_config[field] |= source_config[field] + if "email_cadence" in source_config: + if not target_config.get("email_cadence") or isinstance(target_config.get("email_cadence"), str): + target_config["email_cadence"] = set() + + target_config["email_cadence"].add(source_config["email_cadence"]) + + +def update_core_notification_types(app_config: Dict, user_config: Dict) -> None: + """ + Update core notification types by merging existing and new types. + """ + if "core_notification_types" not in user_config: + return + + existing_types: Set = set(app_config.get("core_notification_types", [])) + existing_types.update(user_config["core_notification_types"]) + app_config["core_notification_types"] = list(existing_types) + + +def process_app_config( + app_config: Dict, + user_config: Dict, + app: str, + default_config: Dict, +) -> None: + """ + Process a single category configuration against another config. + """ + if app not in user_config: + return + + user_app_config = user_config[app] + + # Update enabled status + app_config["enabled"] |= user_app_config.get("enabled", False) + + # Update core notification types + update_core_notification_types(app_config, user_app_config) + + # Update notification types + update_notification_types(app_config, user_app_config) + + +def aggregate_notification_configs(existing_user_configs: List[Dict]) -> Dict: + """ + Update default notification config with values from other configs. + Rules: + 1. Start with default config as base + 2. If any value is True in other configs, make it True + 3. Set email_cadence to "Mixed" if different cadences found, else use default + + Args: + existing_user_configs: List of notification config dictionaries to apply + + Returns: + Updated config following the same structure + """ + if not existing_user_configs: + return {} + + result_config = copy.deepcopy(existing_user_configs[0]) + apps = result_config.keys() + + for app in apps: + app_config = result_config[app] + + for user_config in existing_user_configs: + process_app_config(app_config, user_config, app, existing_user_configs[0]) + + # if email_cadence is mixed, set it to "Mixed" + for app in result_config: + for type_key, type_config in result_config[app]["notification_types"].items(): + if len(type_config.get("email_cadence", [])) > 1: + result_config[app]["notification_types"][type_key]["email_cadence"] = "Mixed" + else: + if result_config[app]["notification_types"][type_key].get('email_cadence'): + result_config[app]["notification_types"][type_key]["email_cadence"] = ( + result_config[app]["notification_types"][type_key]["email_cadence"].pop()) + else: + result_config[app]["notification_types"][type_key]["email_cadence"] = EmailCadence.DAILY + return result_config + + +def filter_out_visible_preferences_by_course_ids(user, preferences: Dict, course_ids: List) -> Dict: + """ + Filter out notifications visible to forum roles from user preferences. + """ + forum_roles = Role.objects.filter(users__id=user.id).values_list('name', flat=True) + course_roles = CourseAccessRole.objects.filter( + user=user, + course_id__in=course_ids + ).values_list('role', flat=True) + notification_types_with_visibility = get_notification_types_with_visibility_settings() + return filter_out_visible_notifications( + preferences, + notification_types_with_visibility, + forum_roles, + course_roles + ) + + +def get_user_forum_access_roles(user_id: int) -> List[str]: + """ + Get forum roles for the given user in all course. + + :param user_id: User ID + :return: List of forum roles + """ + return list(Role.objects.filter(users__id=user_id).values_list('name', flat=True)) + + +def exclude_inaccessible_preferences(user_preferences: dict, user): + """ + Exclude notifications from user preferences that the user has no access to, + based on forum and course roles. + + :param user_preferences: Dictionary of user notification preferences + :param user: Django User object + :return: Updated user_preferences dictionary (modified in-place) + """ + forum_roles = get_user_forum_access_roles(user.id) + visible_notifications = get_notification_types_with_visibility_settings() + course_roles = CourseAccessRole.objects.filter( + user=user + ).values_list('role', flat=True) + + filter_out_visible_notifications( + user_preferences, + visible_notifications, + forum_roles, + course_roles + ) + return user_preferences diff --git a/openedx/core/djangoapps/notifications/views.py b/openedx/core/djangoapps/notifications/views.py index e87274088f..58fd13b882 100644 --- a/openedx/core/djangoapps/notifications/views.py +++ b/openedx/core/djangoapps/notifications/views.py @@ -1,9 +1,11 @@ """ Views for the notifications API. """ +import copy from datetime import datetime, timedelta from django.conf import settings +from django.db import transaction from django.db.models import Count from django.shortcuts import get_object_or_404 from django.utils.translation import gettext as _ @@ -16,15 +18,17 @@ from rest_framework.response import Response from rest_framework.views import APIView from common.djangoapps.student.models import CourseEnrollment +from openedx.core.djangoapps.notifications.email import ONE_CLICK_EMAIL_UNSUB_KEY from openedx.core.djangoapps.notifications.email.utils import update_user_preferences_from_patch -from openedx.core.djangoapps.notifications.models import ( - CourseNotificationPreference, - get_course_notification_preference_config_version -) +from openedx.core.djangoapps.notifications.models import get_course_notification_preference_config_version, \ + NotificationPreference from openedx.core.djangoapps.notifications.permissions import allow_any_authenticated_user +from openedx.core.djangoapps.notifications.serializers import add_info_to_notification_config +from openedx.core.djangoapps.user_api.models import UserPreference -from .base_notification import COURSE_NOTIFICATION_APPS -from .config.waffle import ENABLE_NOTIFICATIONS +from .base_notification import COURSE_NOTIFICATION_APPS, NotificationAppManager, COURSE_NOTIFICATION_TYPES, \ + NotificationTypeManager +from .config.waffle import ENABLE_NOTIFICATIONS, ENABLE_NOTIFY_ALL_LEARNERS from .events import ( notification_preference_update_event, notification_preferences_viewed_event, @@ -32,14 +36,22 @@ from .events import ( notification_tray_opened_event, notifications_app_all_read_event ) -from .models import Notification +from .models import CourseNotificationPreference, Notification from .serializers import ( NotificationCourseEnrollmentSerializer, NotificationSerializer, UserCourseNotificationPreferenceSerializer, + UserNotificationPreferenceUpdateAllSerializer, UserNotificationPreferenceUpdateSerializer, + add_non_editable_in_preference +) +from .tasks import create_notification_preference +from .utils import ( + aggregate_notification_configs, + filter_out_visible_preferences_by_course_ids, + get_show_notifications_tray, + exclude_inaccessible_preferences ) -from .utils import get_show_notifications_tray, get_is_new_notification_view_enabled @allow_any_authenticated_user() @@ -227,6 +239,12 @@ class UserNotificationPreferenceView(APIView): ) preference_update.is_valid(raise_exception=True) updated_notification_preferences = preference_update.save() + + if request.data.get('notification_channel', '') == 'email' and request.data.get('value', False): + UserPreference.objects.filter( + user_id=request.user.id, + key=ONE_CLICK_EMAIL_UNSUB_KEY + ).delete() notification_preference_update_event(request.user, course_id, preference_update.validated_data) serializer_context = { @@ -323,13 +341,12 @@ class NotificationCountView(APIView): # Get the unseen notifications count for each app name. count_by_app_name = ( Notification.objects - .filter(user_id=request.user, last_seen__isnull=True) + .filter(user_id=request.user, last_seen__isnull=True, web=True) .values('app_name') .annotate(count=Count('*')) ) count_total = 0 show_notifications_tray = get_show_notifications_tray(self.request.user) - is_new_notification_view_enabled = get_is_new_notification_view_enabled() count_by_app_name_dict = { app_name: 0 for app_name in COURSE_NOTIFICATION_APPS @@ -346,7 +363,6 @@ class NotificationCountView(APIView): "count": count_total, "count_by_app_name": count_by_app_name_dict, "notification_expiry_days": settings.NOTIFICATIONS_EXPIRY, - "is_new_notification_view_enabled": is_new_notification_view_enabled }) @@ -444,3 +460,364 @@ def preference_update_from_encrypted_username_view(request, username, patch): """ update_user_preferences_from_patch(username, patch) return Response({"result": "success"}, status=status.HTTP_200_OK) + + +@allow_any_authenticated_user() +class UpdateAllNotificationPreferencesView(APIView): + """ + API view for updating all notification preferences for the current user. + """ + + def post(self, request): + """ + Update all notification preferences for the current user. + """ + # check if request have required params + serializer = UserNotificationPreferenceUpdateAllSerializer(data=request.data) + if not serializer.is_valid(): + return Response({ + 'status': 'error', + 'message': serializer.errors + }, status=status.HTTP_400_BAD_REQUEST) + # check if required config is not editable + try: + with transaction.atomic(): + # Get all active notification preferences for the current user + notification_preferences = ( + CourseNotificationPreference.objects + .select_for_update() + .filter( + user=request.user, + is_active=True + ) + ) + + if not notification_preferences.exists(): + return Response({ + 'status': 'error', + 'message': 'No active notification preferences found' + }, status=status.HTTP_404_NOT_FOUND) + + data = serializer.validated_data + app = data['notification_app'] + email_cadence = data.get('email_cadence', None) + channel = data.get('notification_channel', 'email_cadence' if email_cadence else None) + notification_type = data['notification_type'] + value = data.get('value', email_cadence if email_cadence else None) + + updated_courses = [] + errors = [] + + # Update each preference + for preference in notification_preferences: + try: + # Create a deep copy of the current config + updated_config = copy.deepcopy(preference.notification_preference_config) + + # Check if the path exists and update the value + if ( + updated_config.get(app, {}) + .get('notification_types', {}) + .get(notification_type, {}) + .get(channel) + ) is not None: + + # Update the specific setting in the config + updated_config[app]['notification_types'][notification_type][channel] = value + + # Update the notification preference + preference.notification_preference_config = updated_config + preference.save() + + updated_courses.append({ + 'course_id': str(preference.course_id), + 'current_setting': updated_config[app]['notification_types'][notification_type] + }) + else: + errors.append({ + 'course_id': str(preference.course_id), + 'error': f'Invalid path: {app}.notification_types.{notification_type}.{channel}' + }) + + except (KeyError, AttributeError, ValueError) as e: + errors.append({ + 'course_id': str(preference.course_id), + 'error': str(e) + }) + if channel == 'email' and value: + UserPreference.objects.filter( + user_id=request.user, + key=ONE_CLICK_EMAIL_UNSUB_KEY + ).delete() + response_data = { + 'status': 'success' if updated_courses else 'partial_success' if errors else 'error', + 'message': 'Notification preferences update completed', + 'data': { + 'updated_value': value, + 'notification_type': notification_type, + 'channel': channel, + 'app': app, + 'successfully_updated_courses': updated_courses, + 'total_updated': len(updated_courses), + 'total_courses': notification_preferences.count() + } + } + if errors: + response_data['errors'] = errors + event_data = { + 'notification_app': app, + 'notification_type': notification_type, + 'notification_channel': channel, + 'value': value, + 'email_cadence': value + } + notification_preference_update_event( + request.user, + [course['course_id'] for course in updated_courses], + event_data + ) + return Response( + response_data, + status=status.HTTP_200_OK if updated_courses else status.HTTP_400_BAD_REQUEST + ) + + except (KeyError, AttributeError, ValueError) as e: + return Response({ + 'status': 'error', + 'message': str(e) + }, status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +@allow_any_authenticated_user() +class AggregatedNotificationPreferences(APIView): + """ + API view for getting the aggregate notification preferences for the current user. + """ + + def get(self, request): + """ + API view for getting the aggregate notification preferences for the current user. + """ + notification_preferences = CourseNotificationPreference.get_user_notification_preferences(request.user) + if not notification_preferences.exists(): + return Response({ + 'status': 'error', + 'message': 'No active notification preferences found' + }, status=status.HTTP_404_NOT_FOUND) + notification_configs = notification_preferences.values_list('notification_preference_config', flat=True) + notification_configs = aggregate_notification_configs( + notification_configs + ) + course_ids = notification_preferences.values_list('course_id', flat=True) + + filter_out_visible_preferences_by_course_ids( + request.user, + notification_configs, + course_ids, + ) + + notification_preferences_viewed_event(request) + notification_configs = add_info_to_notification_config(notification_configs) + + discussion_config = notification_configs.get('discussion', {}) + notification_types = discussion_config.get('notification_types', {}) + + if not any(ENABLE_NOTIFY_ALL_LEARNERS.is_enabled(course_key) for course_key in course_ids): + notification_types.pop('new_instructor_all_learners_post', None) + + return Response({ + 'status': 'success', + 'message': 'Notification preferences retrieved', + 'data': add_non_editable_in_preference(notification_configs) + }, status=status.HTTP_200_OK) + + +@allow_any_authenticated_user() +class NotificationPreferencesView(APIView): + """ + API view to retrieve and structure the notification preferences for the + authenticated user. + """ + + def get(self, request): + """ + Handles GET requests to retrieve notification preferences. + + This method fetches the user's active notification preferences and + merges them with a default structure provided by NotificationAppManager. + This provides a complete view of all possible notifications and the + user's current settings for them. + + Returns: + Response: A DRF Response object containing the structured + notification preferences or an error message. + """ + user_preferences_qs = NotificationPreference.objects.filter(user=request.user) + user_preferences_map = {pref.type: pref for pref in user_preferences_qs} + + # Ensure all notification types are present in the user's preferences. + # If any are missing, create them with default values. + diff = set(COURSE_NOTIFICATION_TYPES.keys()) - set(user_preferences_map.keys()) + missing_types = [] + for missing_type in diff: + new_pref = create_notification_preference( + user_id=request.user.id, + notification_type=missing_type, + + ) + missing_types.append(new_pref) + user_preferences_map[missing_type] = new_pref + if missing_types: + NotificationPreference.objects.bulk_create(missing_types) + + # If no user preferences are found, return an error response. + if not user_preferences_map: + return Response({ + 'status': 'error', + 'message': 'No active notification preferences found for this user.' + }, status=status.HTTP_404_NOT_FOUND) + + # Get the structured preferences from the NotificationAppManager. + # This will include all apps and their notification types. + structured_preferences = NotificationAppManager().get_notification_app_preferences() + + for app_name, app_settings in structured_preferences.items(): + notification_types = app_settings.get('notification_types', {}) + + # Process all notification types (core and non-core) in a single loop. + for type_name, type_details in notification_types.items(): + if type_name == 'core': + if structured_preferences[app_name]['core_notification_types']: + # If the app has core notification types, use the first one as the type name. + # This assumes that the first core notification type is representative of the core settings. + notification_type = structured_preferences[app_name]['core_notification_types'][0] + else: + notification_type = 'core' + user_pref = user_preferences_map.get(notification_type) + else: + user_pref = user_preferences_map.get(type_name) + if user_pref: + # If a preference exists, update the dictionary for this type. + # This directly modifies the 'type_details' dictionary. + type_details['web'] = user_pref.web + type_details['email'] = user_pref.email + type_details['push'] = user_pref.push + type_details['email_cadence'] = user_pref.email_cadence + exclude_inaccessible_preferences(structured_preferences, request.user) + return Response({ + 'status': 'success', + 'message': 'Notification preferences retrieved successfully.', + 'data': add_non_editable_in_preference(structured_preferences) + }, status=status.HTTP_200_OK) + + def put(self, request): + """ + Handles PUT requests to update notification preferences. + + This method updates the user's notification preferences based on the + provided data in the request body. It expects a dictionary with + notification types and their settings. + + Returns: + Response: A DRF Response object indicating success or failure. + """ + # Validate incoming data + serializer = UserNotificationPreferenceUpdateAllSerializer(data=request.data) + if not serializer.is_valid(): + return Response({ + 'status': 'error', + 'message': serializer.errors + }, status=status.HTTP_400_BAD_REQUEST) + + # Get validated data for easier access + validated_data = serializer.validated_data + + # Build query set based on notification type + query_set = NotificationPreference.objects.filter(user_id=request.user.id) + + if validated_data['notification_type'] == 'core': + # Get core notification types for the app + __, core_types = NotificationTypeManager().get_notification_app_preference( + notification_app=validated_data['notification_app'] + ) + query_set = query_set.filter(type__in=core_types) + else: + # Filter by single notification type + query_set = query_set.filter(type=validated_data['notification_type']) + + # Prepare update data based on channel type + updated_data = self._prepare_update_data(validated_data) + + # Update preferences + query_set.update(**updated_data) + + # Log the event + self._log_preference_update_event(request.user, validated_data) + + # Prepare and return response + response_data = self._prepare_response_data(validated_data) + return Response(response_data, status=status.HTTP_200_OK) + + def _prepare_update_data(self, validated_data): + """ + Prepare the data dictionary for updating notification preferences. + + Args: + validated_data (dict): Validated serializer data + + Returns: + dict: Dictionary with update data + """ + channel = validated_data['notification_channel'] + + if channel == 'email_cadence': + return {channel: validated_data['email_cadence']} + else: + return {channel: validated_data['value']} + + def _log_preference_update_event(self, user, validated_data): + """ + Log the notification preference update event. + + Args: + user: The user making the update + validated_data (dict): Validated serializer data + """ + event_data = { + 'notification_app': validated_data['notification_app'], + 'notification_type': validated_data['notification_type'], + 'notification_channel': validated_data['notification_channel'], + 'value': validated_data.get('value'), + 'email_cadence': validated_data.get('email_cadence'), + } + notification_preference_update_event(user, [], event_data) + + def _prepare_response_data(self, validated_data): + """ + Prepare the response data dictionary. + + Args: + validated_data (dict): Validated serializer data + + Returns: + dict: Response data dictionary + """ + email_cadence = validated_data.get('email_cadence', None) + # Determine the updated value + updated_value = validated_data.get('value', email_cadence if email_cadence else None) + + # Determine the channel + channel = validated_data.get('notification_channel') + if not channel and validated_data.get('email_cadence'): + channel = 'email_cadence' + + return { + 'status': 'success', + 'message': 'Notification preferences update completed', + 'data': { + 'updated_value': updated_value, + 'notification_type': validated_data['notification_type'], + 'channel': channel, + 'app': validated_data['notification_app'], + } + } diff --git a/openedx/core/djangoapps/password_policy/compliance.py b/openedx/core/djangoapps/password_policy/compliance.py index 78e5ae902b..fdd103d243 100644 --- a/openedx/core/djangoapps/password_policy/compliance.py +++ b/openedx/core/djangoapps/password_policy/compliance.py @@ -97,7 +97,7 @@ def enforce_compliance_on_login(user, password): platform_name=settings.PLATFORM_NAME, deadline=strftime_localized(deadline, DEFAULT_SHORT_DATE_FORMAT), anchor_tag_open=HTML('').format( - account_settings_url=settings.LMS_ROOT_URL + "/account/settings" + account_settings_url=settings.ACCOUNT_MICROFRONTEND_URL ), anchor_tag_close=HTML('') ) diff --git a/openedx/core/djangoapps/programs/README.rst b/openedx/core/djangoapps/programs/README.rst index 6c5a3946c2..c2ccb4c023 100644 --- a/openedx/core/djangoapps/programs/README.rst +++ b/openedx/core/djangoapps/programs/README.rst @@ -2,20 +2,29 @@ Status: Maintenance Responsibilities ================ -The Programs app is responsible (along with the `credentials app`_) -for communicating with the `credentials service`_, which is -the system of record for a learner's Program Certificates, and which (when enabled by the edX -instance) is the system of record for accessing all of a learner's credentials. +The Programs app is responsible for: -It also hosts program discussion forum and program live configuration. - -.. _credentials service: https://github.com/openedx/credentials - -.. _credentials app: https://github.com/openedx/edx-platform/tree/master/openedx/core/djangoapps/credentials +* Communicating with the `credentials service`_ (along with the `credentials app`_). +* Program discussion forum and program live configuration. +* The REST API used to render the program dashboard. Legacy routes for this API, left over + from the deprecated remnants of the legacy learner dashboard, exist alongside future-proofed + routes which will work when the deprecated, legacy Program Dashboard is replaced with functionality + in the Learner Dashboard MFE. See Also ======== -* ``lms/djangoapps/learner_dashboard/``, which hosts the program dashboard. -* ``openedx/core/djangoapps/credentials`` +* `course_discovery_`: The system of record for the definition of a program. +* `credentials service_`: The system of record for a learner's Program Certificates and Program Records. +* `learner_record_`: The MFE displaying Program Records to learners. +* `legacy learner_dashboard_`: The legacy front-end for the program dashboard. +.. _course_discovery: https://github.com/openedx/course-discovery/ + +.. _credentials app: https://github.com/openedx/edx-platform/tree/master/openedx/core/djangoapps/credentials + +.. _credentials service: https://github.com/openedx/credentials + +.. _legacy learner_dashboard: https://github.com/openedx/edx-platform/tree/master/lms/djangoapps/learner_dashboard + +.. _learner_record: https://github.com/openedx/frontend-app-learner-record \ No newline at end of file diff --git a/openedx/features/learner_profile/views/__init__.py b/openedx/core/djangoapps/programs/rest_api/__init__.py similarity index 100% rename from openedx/features/learner_profile/views/__init__.py rename to openedx/core/djangoapps/programs/rest_api/__init__.py diff --git a/openedx/core/djangoapps/programs/rest_api/urls.py b/openedx/core/djangoapps/programs/rest_api/urls.py new file mode 100644 index 0000000000..6533f709b8 --- /dev/null +++ b/openedx/core/djangoapps/programs/rest_api/urls.py @@ -0,0 +1,21 @@ +""" +Programs API URLs. + +This is legacy URLs for the program dashboard API from when the legacy learner +dashboard existed. Current-and-future advertised URLs for this API will be +under `api/learner_home`. This is why there is a version numbering discrepancy. +While these will still be reachable from `/dashboard/v0/programs` for backward +compatibility, the API will now be part of `/learner_dashboard/v1/programs`. +""" + +from django.urls import include, path + +from openedx.core.djangoapps.programs.rest_api.v1 import ( + urls as v1_programs_rest_api_urls, +) + +app_name = "openedx.core.djangoapps.programs" + +urlpatterns = [ + path("v0/", include((v1_programs_rest_api_urls, "v0"), namespace="v0")), +] diff --git a/pavelib/paver_tests/__init__.py b/openedx/core/djangoapps/programs/rest_api/v1/__init__.py similarity index 100% rename from pavelib/paver_tests/__init__.py rename to openedx/core/djangoapps/programs/rest_api/v1/__init__.py diff --git a/pavelib/utils/__init__.py b/openedx/core/djangoapps/programs/rest_api/v1/tests/__init__.py similarity index 100% rename from pavelib/utils/__init__.py rename to openedx/core/djangoapps/programs/rest_api/v1/tests/__init__.py diff --git a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py b/openedx/core/djangoapps/programs/rest_api/v1/tests/test_views.py similarity index 62% rename from lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py rename to openedx/core/djangoapps/programs/rest_api/v1/tests/test_views.py index 48479920a4..2864f41a92 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py +++ b/openedx/core/djangoapps/programs/rest_api/v1/tests/test_views.py @@ -1,17 +1,14 @@ """ -Unit tests for Learner Dashboard REST APIs and Views +Unit tests for Programs REST APIs and Views """ from unittest import mock from uuid import uuid4 from django.core.cache import cache +from django.test.utils import override_settings from django.urls import reverse_lazy from enterprise.models import EnterpriseCourseEnrollment -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import ( - CourseFactory as ModuleStoreCourseFactory, -) from common.djangoapps.student.tests.factories import ( CourseEnrollmentFactory, @@ -35,22 +32,30 @@ from openedx.core.djangoapps.site_configuration.tests.test_util import ( with_site_configuration, ) from openedx.core.djangolib.testing.utils import skip_unless_lms +from openedx.features.enterprise_support.api import enterprise_is_enabled from openedx.features.enterprise_support.tests.factories import ( EnterpriseCourseEnrollmentFactory, EnterpriseCustomerFactory, EnterpriseCustomerUserFactory, ) +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import ( + CourseFactory as ModuleStoreCourseFactory, +) -PROGRAMS_UTILS_MODULE = 'openedx.core.djangoapps.programs.utils' +PROGRAMS_UTILS_MODULE = "openedx.core.djangoapps.programs.utils" @skip_unless_lms -@mock.patch(PROGRAMS_UTILS_MODULE + '.get_pathways') -@mock.patch(PROGRAMS_UTILS_MODULE + '.get_programs') +@mock.patch(PROGRAMS_UTILS_MODULE + ".get_pathways") +@mock.patch(PROGRAMS_UTILS_MODULE + ".get_programs") class TestProgramProgressDetailView(ProgramsApiConfigMixin, SharedModuleStoreTestCase): """Unit tests for the program progress detail page.""" + program_uuid = str(uuid4()) - url = reverse_lazy('learner_dashboard:v0:program_progress_detail', kwargs={'program_uuid': program_uuid}) + url = reverse_lazy( + "openedx.core.djangoapps.programs:v0:program_progress_detail", kwargs={"program_uuid": program_uuid} + ) @classmethod def setUpClass(cls): @@ -62,9 +67,9 @@ class TestProgramProgressDetailView(ProgramsApiConfigMixin, SharedModuleStoreTes cls.program_data = ProgramFactory(uuid=cls.program_uuid, courses=[course]) cls.pathway_data = PathwayFactory() - cls.program_data['pathway_ids'] = [cls.pathway_data['id']] - cls.pathway_data['program_uuids'] = [cls.program_data['uuid']] - del cls.pathway_data['programs'] # lint-amnesty, pylint: disable=unsupported-delete-operation + cls.program_data["pathway_ids"] = [cls.pathway_data["id"]] + cls.pathway_data["program_uuids"] = [cls.program_data["uuid"]] + del cls.pathway_data["programs"] # lint-amnesty, pylint: disable=unsupported-delete-operation def setUp(self): super().setUp() @@ -74,25 +79,25 @@ class TestProgramProgressDetailView(ProgramsApiConfigMixin, SharedModuleStoreTes def assert_program_data_present(self, response): """Verify that program data is present.""" - self.assertContains(response, 'program_data') - self.assertContains(response, 'course_data') - self.assertContains(response, 'urls') - self.assertContains(response, 'certificate_data') - self.assertContains(response, self.program_data['title']) + self.assertContains(response, "program_data") + self.assertContains(response, "course_data") + self.assertContains(response, "urls") + self.assertContains(response, "certificate_data") + self.assertContains(response, self.program_data["title"]) def assert_pathway_data_present(self, response): - """ Verify that the correct pathway data is present. """ - self.assertContains(response, 'industry_pathways') - self.assertContains(response, 'credit_pathways') + """Verify that the correct pathway data is present.""" + self.assertContains(response, "industry_pathways") + self.assertContains(response, "credit_pathways") - industry_pathways = response.data['industry_pathways'] - credit_pathways = response.data['credit_pathways'] - if self.pathway_data['pathway_type'] == PathwayType.CREDIT.value: - credit_pathway, = credit_pathways # Verify that there is only one credit pathway + industry_pathways = response.data["industry_pathways"] + credit_pathways = response.data["credit_pathways"] + if self.pathway_data["pathway_type"] == PathwayType.CREDIT.value: + (credit_pathway,) = credit_pathways # Verify that there is only one credit pathway assert self.pathway_data == credit_pathway assert [] == industry_pathways - elif self.pathway_data['pathway_type'] == PathwayType.INDUSTRY.value: - industry_pathway, = industry_pathways # Verify that there is only one industry pathway + elif self.pathway_data["pathway_type"] == PathwayType.INDUSTRY.value: + (industry_pathway,) = industry_pathways # Verify that there is only one industry pathway assert self.pathway_data == industry_pathway assert [] == credit_pathways @@ -104,11 +109,11 @@ class TestProgramProgressDetailView(ProgramsApiConfigMixin, SharedModuleStoreTes mock_get_programs.return_value = self.program_data mock_get_pathways.return_value = self.pathway_data - with mock.patch('lms.djangoapps.learner_dashboard.api.v0.views.get_certificates') as certs: - certs.return_value = [{'type': 'program', 'url': '/'}] + with mock.patch("openedx.core.djangoapps.programs.rest_api.v1.views.get_certificates") as certs: + certs.return_value = [{"type": "program", "url": "/"}] response = self.client.get(self.url) - assert response.status_code == 200 + self.assertEqual(200, response.status_code) self.assert_program_data_present(response) self.assert_pathway_data_present(response) @@ -135,15 +140,16 @@ class TestProgramProgressDetailView(ProgramsApiConfigMixin, SharedModuleStoreTes response = self.client.get(self.url) assert response.status_code == 404 - assert response.data['error_code'] == 'No program data available.' + assert response.data["error_code"] == "No program data available." +@skip_unless_lms class TestProgramsView(SharedModuleStoreTestCase, ProgramCacheMixin): """Unit tests for the program details page.""" enterprise_uuid = str(uuid4()) program_uuid = str(uuid4()) - url = reverse_lazy('learner_dashboard:v0:program_list', kwargs={'enterprise_uuid': enterprise_uuid}) + url = reverse_lazy("openedx.core.djangoapps.programs:v0:program_list", kwargs={"enterprise_uuid": enterprise_uuid}) @classmethod def setUpClass(cls): @@ -155,30 +161,27 @@ class TestProgramsView(SharedModuleStoreTestCase, ProgramCacheMixin): course = CourseFactory(course_runs=[course_run]) enterprise_customer = EnterpriseCustomerFactory(uuid=cls.enterprise_uuid) enterprise_customer_user = EnterpriseCustomerUserFactory( - user_id=cls.user.id, - enterprise_customer=enterprise_customer - ) - CourseEnrollmentFactory( - is_active=True, - course_id=modulestore_course.id, - user=cls.user + user_id=cls.user.id, enterprise_customer=enterprise_customer ) + CourseEnrollmentFactory(is_active=True, course_id=modulestore_course.id, user=cls.user) EnterpriseCourseEnrollmentFactory( course_id=modulestore_course.id, - enterprise_customer_user=enterprise_customer_user + enterprise_customer_user=enterprise_customer_user, ) cls.program = ProgramFactory( uuid=cls.program_uuid, courses=[course], - title='Journey to cooking', - type='MicroMasters', - authoring_organizations=[{ - 'key': 'MAX', - 'logo_image_url': 'http://test.org/media/organization/logos/test-logo.png' - }], + title="Journey to cooking", + type="MicroMasters", + authoring_organizations=[ + { + "key": "MAX", + "logo_image_url": "http://test.org/media/organization/logos/test-logo.png", + } + ], ) - cls.site = SiteFactory(domain='test.localhost') + cls.site = SiteFactory(domain="test.localhost") def setUp(self): super().setUp() @@ -187,10 +190,12 @@ class TestProgramsView(SharedModuleStoreTestCase, ProgramCacheMixin): ProgramEnrollmentFactory.create( user=self.user, program_uuid=self.program_uuid, - external_user_key='0001', + external_user_key="0001", ) - @with_site_configuration(configuration={'COURSE_CATALOG_API_URL': 'foo'}) + @with_site_configuration(configuration={"COURSE_CATALOG_API_URL": "foo"}) + @override_settings(FEATURES=dict(ENABLE_ENTERPRISE_INTEGRATION=True)) + @enterprise_is_enabled() def test_program_list(self): """ Verify API returns proper response. @@ -198,7 +203,7 @@ class TestProgramsView(SharedModuleStoreTestCase, ProgramCacheMixin): cache.set( SITE_PROGRAM_UUIDS_CACHE_KEY_TPL.format(domain=self.site.domain), [self.program_uuid], - None + None, ) response = self.client.get(self.url) @@ -206,33 +211,33 @@ class TestProgramsView(SharedModuleStoreTestCase, ProgramCacheMixin): program = response.data[0] assert len(program) - assert program['uuid'] == self.program['uuid'] - assert program['title'] == self.program['title'] - assert program['type'] == self.program['type'] - assert program['authoring_organizations'] == self.program['authoring_organizations'] - assert program['banner_image'] == self.program['banner_image'] - assert program['progress'] == { - 'uuid': self.program['uuid'], - 'completed': 0, - 'in_progress': 0, - 'not_started': 1, - 'all_unenrolled': False, + assert program["uuid"] == self.program["uuid"] + assert program["title"] == self.program["title"] + assert program["type"] == self.program["type"] + assert program["authoring_organizations"] == self.program["authoring_organizations"] + assert program["banner_image"] == self.program["banner_image"] + assert program["progress"] == { + "uuid": self.program["uuid"], + "completed": 0, + "in_progress": 0, + "not_started": 1, + "all_unenrolled": False, } - @with_site_configuration(configuration={'COURSE_CATALOG_API_URL': 'foo'}) + @with_site_configuration(configuration={"COURSE_CATALOG_API_URL": "foo"}) + @override_settings(FEATURES=dict(ENABLE_ENTERPRISE_INTEGRATION=True)) + @enterprise_is_enabled() def test_program_empty_list_if_no_enterprise_enrollments(self): """ Verify API returns empty response if no enterprise enrollments exists for a learner. """ # delete all enterprise course enrollments for the user - EnterpriseCourseEnrollment.objects.filter( - enterprise_customer_user__user_id=self.user.id - ).delete() + EnterpriseCourseEnrollment.objects.filter(enterprise_customer_user__user_id=self.user.id).delete() cache.set( SITE_PROGRAM_UUIDS_CACHE_KEY_TPL.format(domain=self.site.domain), [self.program_uuid], - None + None, ) response = self.client.get(self.url) diff --git a/openedx/core/djangoapps/programs/rest_api/v1/urls.py b/openedx/core/djangoapps/programs/rest_api/v1/urls.py new file mode 100644 index 0000000000..415a543a92 --- /dev/null +++ b/openedx/core/djangoapps/programs/rest_api/v1/urls.py @@ -0,0 +1,26 @@ +""" +REST APIs for Programs. +""" + +from django.urls import re_path + +from openedx.core.djangoapps.programs.rest_api.v1.views import ( + ProgramProgressDetailView, + Programs, +) + +ENTERPRISE_UUID_PATTERN = r"[0-9a-fA-F]{8}-?[0-9a-fA-F]{4}-?4[0-9a-fA-F]{3}-?[89abAB][0-9a-fA-F]{3}-?[0-9a-fA-F]{12}" +PROGRAM_UUID_PATTERN = r"[0-9a-f-]+" + +urlpatterns = [ + re_path( + rf"^programs/(?P{ENTERPRISE_UUID_PATTERN})/$", + Programs.as_view(), + name="program_list", + ), + re_path( + rf"^programs/(?P{PROGRAM_UUID_PATTERN})/progress_details/$", + ProgramProgressDetailView.as_view(), + name="program_progress_detail", + ), +] diff --git a/openedx/core/djangoapps/programs/rest_api/v1/views.py b/openedx/core/djangoapps/programs/rest_api/v1/views.py new file mode 100644 index 0000000000..a5bf939e1e --- /dev/null +++ b/openedx/core/djangoapps/programs/rest_api/v1/views.py @@ -0,0 +1,337 @@ +"""Views for the Programs REST API v1.""" + +from typing import Any, TYPE_CHECKING +import logging + +from django.db.models.query import EmptyQuerySet +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response +from rest_framework.views import APIView + +from common.djangoapps.student.api import get_course_enrollments +from openedx.core.djangoapps.programs.utils import ( + ProgramProgressMeter, + get_certificates, + get_industry_and_credit_pathways, + get_program_and_course_data, + get_program_urls, +) +from openedx.features.enterprise_support.api import get_enterprise_course_enrollments, enterprise_is_enabled + +if TYPE_CHECKING: + from django.http import HttpRequest, HttpResponse + from django.contrib.auth.models import AnonymousUser, User # pylint: disable=imported-auth-user + from django.contrib.sites.models import Site + from django.db.models.query import QuerySet + from common.djangoapps.student.models import CourseEnrollment + +logger = logging.getLogger(__name__) + + +class Programs(APIView): + """Program endpoints""" + + permission_classes = (IsAuthenticated,) + + def get(self, request: "HttpRequest", enterprise_uuid: str) -> "HttpResponse": + """For an enterprise learner, get list of enrolled programs with progress. + + **Example Request** + + GET /api/dashboard/v1/programs/{enterprise_uuid}/ + + **Parameters** + + * `enterprise_uuid`: UUID of an enterprise customer. + + **Example Response** + + [ + { + "uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c", + "title": "Demonstration Program", + "type": "MicroMasters", + "banner_image": { + "large": { + "url": "http://example.com/images/foo.large.jpg", + "width": 1440, + "height": 480 + }, + "medium": { + "url": "http://example.com/images/foo.medium.jpg", + "width": 726, + "height": 242 + }, + "small": { + "url": "http://example.com/images/foo.small.jpg", + "width": 435, + "height": 145 + }, + "x-small": { + "url": "http://example.com/images/foo.x-small.jpg", + "width": 348, + "height": 116 + } + }, + "authoring_organizations": [ + { + "key": "example" + } + ], + "progress": { + "uuid": "ff41a5eb-2a73-4933-8e80-a1c66068ed2c", + "completed": 0, + "in_progress": 0, + "not_started": 2 + } + } + ] + """ + user: "AnonymousUser | User" = request.user + + enrollments = list(self._get_enterprise_course_enrollments(enterprise_uuid, user)) + # return empty reponse if no enterprise enrollments exists for a user + if not enrollments: + return Response([]) + + meter = ProgramProgressMeter( + request.site, + user, + enrollments=enrollments, + mobile_only=False, + include_course_entitlements=False, + ) + engaged_programs = meter.engaged_programs + progress = meter.progress(programs=engaged_programs) + programs = self._extract_minimal_required_programs_data(engaged_programs) + programs = self._combine_programs_data_and_progress(programs, progress) + + return Response(programs) + + def _combine_programs_data_and_progress( + self, + programs_data: list[dict | None], + programs_progress: list[dict | None], + ) -> list[dict | None]: + """ + Return the combined program and progress data so that api clinet can easily process the data. + """ + for program_data in programs_data: + program_progress = next( + (item for item in programs_progress if item["uuid"] == program_data["uuid"]), # type: ignore[index] + None, + ) + program_data["progress"] = program_progress # type: ignore[index] + + return programs_data + + def _extract_minimal_required_programs_data(self, programs_data: list[dict | None]) -> list[dict[str, Any] | None]: + """ + Return only the minimal required program data need for program listing page. + """ + + def transform(key, value): + transformers = {"authoring_organizations": transform_authoring_organizations} + + if key in transformers: + return transformers[key](value) + + return value + + def transform_authoring_organizations(authoring_organizations) -> list[dict[str, Any]]: + """ + Extract only the required data for `authoring_organizations` for a program + """ + transformed_authoring_organizations = [] + for authoring_organization in authoring_organizations: + transformed_authoring_organizations.append( + { + "key": authoring_organization["key"], + "logo_image_url": authoring_organization["logo_image_url"], + } + ) + + return transformed_authoring_organizations + + program_data_keys = [ + "uuid", + "title", + "type", + "banner_image", + "authoring_organizations", + ] + programs: list[dict[str, Any] | None] = [] + for program_data in programs_data: + program = {} + for program_data_key in program_data_keys: + program[program_data_key] = transform( + program_data_key, + program_data[program_data_key], # type: ignore[index] + ) + + programs.append(program) + + return programs + + @enterprise_is_enabled(otherwise=EmptyQuerySet) + def _get_enterprise_course_enrollments( + self, enterprise_uuid: str, user: "AnonymousUser | User" + ) -> "QuerySet[CourseEnrollment]": + """ + Return only enterprise enrollments for a user. + """ + enterprise_enrollment_course_ids = ( + get_enterprise_course_enrollments(user) + .filter(enterprise_customer_user__enterprise_customer__uuid=enterprise_uuid) + .values_list("course_id", flat=True) + ) + + course_enrollments = get_course_enrollments(user, True, list(enterprise_enrollment_course_ids)) + + return course_enrollments + + +class ProgramProgressDetailView(APIView): + """Endpoints For Program Progress Meter""" + + permission_classes = (IsAuthenticated,) + + def get(self, request: "HttpRequest", program_uuid: str) -> "HttpResponse": + """Retrieves progress details of a learner in a specified program. + + **Example Request** + + GET api/dashboard/v1/programs/{program_uuid}/progress_details/ + + **Parameters** + + * `program_uuid`: A string representation of the uuid of the program. + + **Response Values** + + If the request for information about the program is successful, an HTTP 200 "OK" response + is returned. + + The HTTP 200 response has the following values. + + * `urls`: Urls to enroll/purchase a course or view program record. + + * `program_data`: Holds meta information about the program. + + * `course_data`: Learner's progress details for all courses in the program (in-progress/remaining/completed). + + * `certificate_data`: Details about learner's certificates status for all courses in the program and the + program itself. + + * `industry_pathways`: Industry pathways for the program, comes under additional credit opportunities. + + * `credit_pathways`: Credit pathways for the program, comes under additional credit opportunities. + + **Example Response** + + { + "urls": { + "program_listing_url": "/dashboard/programs/", + "track_selection_url": "/course_modes/choose/", + "commerce_api_url": "/api/commerce/v1/baskets/", + "buy_button_url": "http://example.com/basket/add/?", + "program_record_url": "https://example.com/records/programs/8675309" + }, + "program_data": { + "uuid": "a156a6e2-de91-4ce7-947a-888943e6b12a", + "title": "Demonstration Program", + "subtitle": "", + "type": "MicroMasters", + "status": "active", + "marketing_slug": "demo-program", + "marketing_url": "micromasters/demo-program", + "authoring_organizations": [], + "card_image_url": "http://example.com/asset-v1:DemoX+Demo_Course.jpg", + "is_program_eligible_for_one_click_purchase": false, + "pathway_ids": [ + 1, + 2 + ], + "is_learner_eligible_for_one_click_purchase": false, + "skus": ["AUD90210"], + }, + "course_data": { + "uuid": "a156a6e2-de91-4ce7-947a-888943e6b12a", + "completed": [], + "in_progress": [], + "not_started": [ + { + "key": "example+DemoX", + "uuid": "fe1a9ad4-a452-45cd-80e5-9babd3d43f96", + "title": "Demonstration Course", + "course_runs": [], + "entitlements": [], + "owners": [], + "image": "", + "short_description": "", + "type": "457f07ec-a78f-45b4-ba09-5fb176520d8a", + } + ], + }, + "certificate_data": [{ + "type": "course", + "title": "Demo Course", + 'url': "/certificates/8675309", + }], + "industry_pathways": [ + { + "id": 2, + "uuid": "1b8fadf1-f6aa-4282-94e3-325b922a027f", + "name": "Demo Industry Pathway", + "org_name": "example", + "email": "example@example.com", + "description": "Sample demo industry pathway", + "destination_url": "http://example.edu/online/pathways/example-methods", + "pathway_type": "industry", + "program_uuids": [ + "a156a6e2-de91-4ce7-947a-888943e6b12a" + ] + } + ], + "credit_pathways": [ + { + "id": 1, + "uuid": "86b9701a-61e6-48a2-92eb-70a824521c1f", + "name": "Demo Credit Pathway", + "org_name": "example", + "email": "example@example.com", + "description": "Sample demo credit pathway!", + "destination_url": "http://example.edu/online/pathways/example-thinking", + "pathway_type": "credit", + "program_uuids": [ + "a156a6e2-de91-4ce7-947a-888943e6b12a" + ] + } + ] + } + """ + user: "AnonymousUser | User" = request.user + site: "Site" = request.site + program_data, course_data = get_program_and_course_data(site, user, program_uuid) + if not program_data: + return Response(status=404, data={"error_code": "No program data available."}) + + certificate_data = get_certificates(user, program_data) + program_data.pop("courses") + + urls = get_program_urls(program_data) + if not certificate_data: + urls["program_record_url"] = None + + industry_pathways, credit_pathways = get_industry_and_credit_pathways(program_data, site) + + return Response( + { + "urls": urls, + "program_data": program_data, + "course_data": course_data, + "certificate_data": certificate_data, + "industry_pathways": industry_pathways, + "credit_pathways": credit_pathways, + } + ) diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index 95044a6fae..76263c4b40 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -9,7 +9,6 @@ from urllib.parse import urljoin, urlparse, urlunparse from dateutil.parser import parse from django.conf import settings -from django.contrib.auth import get_user_model from django.contrib.sites.models import Site from django.core.cache import cache from django.urls import reverse @@ -27,7 +26,7 @@ from common.djangoapps.util.date_utils import strftime_localized from lms.djangoapps.certificates import api as certificate_api from lms.djangoapps.certificates.data import CertificateStatuses from lms.djangoapps.certificates.models import GeneratedCertificate -from lms.djangoapps.commerce.utils import EcommerceService +from lms.djangoapps.commerce.utils import EcommerceService, get_program_price_info from openedx.core.djangoapps.catalog.api import get_programs_by_type from openedx.core.djangoapps.catalog.constants import PathwayType from openedx.core.djangoapps.catalog.utils import ( @@ -35,7 +34,6 @@ from openedx.core.djangoapps.catalog.utils import ( get_pathways, get_programs, ) -from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.credentials.utils import get_credentials, get_credentials_records_url from openedx.core.djangoapps.enrollments.api import get_enrollments @@ -213,7 +211,7 @@ class ProgramProgressMeter: return inverted_programs @cached_property - def engaged_programs(self): + def engaged_programs(self) -> list[dict | None]: """Derive a list of programs in which the given user is engaged. Returns: @@ -273,7 +271,7 @@ class ProgramProgressMeter: # An upgrade deadline of None means the course is always upgradeable. return any(not deadline or deadline and parse(deadline) > now for deadline in upgrade_deadlines) - def progress(self, programs=None, count_only=True): + def progress(self, programs: list[dict | None] | None = None, count_only: bool = True) -> list[dict | None]: """Gauge a user's progress towards program completion. Keyword Arguments: @@ -703,6 +701,7 @@ class ProgramDataExtender: is_learner_eligible_for_one_click_purchase = self.data["is_program_eligible_for_one_click_purchase"] bundle_uuid = self.data.get("uuid") skus = [] + course_keys = [] bundle_variant = "full" if is_learner_eligible_for_one_click_purchase: # lint-amnesty, pylint: disable=too-many-nested-blocks @@ -721,6 +720,7 @@ class ProgramDataExtender: # we are assuming that, for any given course, there is at most one paid entitlement available. if entitlement["mode"] in applicable_seat_types: skus.append(entitlement["sku"]) + course_keys.append(course.get("key")) entitlement_product = True break if not entitlement_product: @@ -730,6 +730,7 @@ class ProgramDataExtender: for seat in published_course_runs[0]["seats"]: if seat["type"] in applicable_seat_types and seat["sku"]: skus.append(seat["sku"]) + course_keys.append(course.get("key")) break else: # If a course in the program has more than 1 published course run @@ -739,31 +740,23 @@ class ProgramDataExtender: if skus: try: - api_user = self.user - is_anonymous = False - if not self.user.is_authenticated: - user = get_user_model() - service_user = user.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME) - api_user = service_user - is_anonymous = True - - api_client = get_ecommerce_api_client(api_user) - api_url = urljoin(f"{get_ecommerce_api_base_url()}/", "baskets/calculate/") + is_anonymous = not self.user.is_authenticated # The user specific program price is slow to calculate, so use switch to force the # anonymous price for all users. See LEARNER-5555 for more details. if is_anonymous or ALWAYS_CALCULATE_PROGRAM_PRICE_AS_ANONYMOUS_USER.is_enabled(): # The bundle uuid is necessary to see the program's discounted price if bundle_uuid: - params = dict(sku=skus, is_anonymous=True, bundle=bundle_uuid) + params = dict(sku=skus, is_anonymous=True, bundle=bundle_uuid, course_key=course_keys) else: - params = dict(sku=skus, is_anonymous=True) + params = dict(sku=skus, is_anonymous=True, course_key=course_keys) else: if bundle_uuid: - params = dict(sku=skus, username=self.user.username, bundle=bundle_uuid) + params = dict(sku=skus, username=self.user.username, bundle=bundle_uuid, course_key=course_keys) else: - params = dict(sku=skus, username=self.user.username) - response = api_client.get(api_url, params=params) + params = dict(sku=skus, username=self.user.username, course_key=course_keys) + + response = get_program_price_info(self.user, params) response.raise_for_status() discount_data = response.json() program_discounted_price = discount_data["total_incl_tax"] diff --git a/openedx/core/djangoapps/safe_sessions/middleware.py b/openedx/core/djangoapps/safe_sessions/middleware.py index f3948217ef..950a3e08c5 100644 --- a/openedx/core/djangoapps/safe_sessions/middleware.py +++ b/openedx/core/djangoapps/safe_sessions/middleware.py @@ -244,14 +244,13 @@ class SafeCookieData: 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/schedules/docs/README.rst b/openedx/core/djangoapps/schedules/docs/README.rst index 4e9f02ca39..3004704c9c 100644 --- a/openedx/core/djangoapps/schedules/docs/README.rst +++ b/openedx/core/djangoapps/schedules/docs/README.rst @@ -66,7 +66,7 @@ Glossary plan on removing this term from this app's code to avoid confusion. - **Section**: From our - `documentation `__, + `documentation `__, “A section is the topmost category in your course. A section can represent a time period in your course, a chapter, or another organizing principle. A section contains one or more subsections.” @@ -130,21 +130,21 @@ can use: :: - ./manage.py lms --settings devstack_docker send_recurring_nudge example.com + ./manage.py lms --settings devstack send_recurring_nudge example.com You can override the “current date” when running a command. The app will run, using the date you specify as its "today": :: - ./manage.py lms --settings devstack_docker send_recurring_nudge example.com --date 2017-11-13 + ./manage.py lms --settings devstack send_recurring_nudge example.com --date 2017-11-13 If the app is paired with Sailthru, you can override which email addresses the app sends to. The app will send all emails to the address you specify: :: - ./manage.py lms --settings devstack_docker send_recurring_nudge example.com --override-recipient-email developer@example.com + ./manage.py lms --settings devstack send_recurring_nudge example.com --override-recipient-email developer@example.com These management commands are meant to be run daily. We schedule them to run automatically in a Jenkins job. You can use a similar automation @@ -155,7 +155,7 @@ Configuring A.C.E. These instructions assume you have already setup an Open edX instance or are running devstack. See the `Open edX Developer’s -Guide `__ +Guide `__ for information on setting them up. The Schedule app relies on ACE. When live, ACE sends emails to users @@ -419,7 +419,7 @@ To begin using Litmus, follow these steps: :: - ./manage.py lms --settings devstack_docker send_recurring_nudge example.com --override-recipient-email PUT-LITMUS-ADDRESS-HERE + ./manage.py lms --settings devstack send_recurring_nudge example.com --override-recipient-email PUT-LITMUS-ADDRESS-HERE Using the Litmus Browser Extenstion to test emails saved as local files ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/openedx/core/djangoapps/schedules/resolvers.py b/openedx/core/djangoapps/schedules/resolvers.py index 5f769e6af2..b3dce6f054 100644 --- a/openedx/core/djangoapps/schedules/resolvers.py +++ b/openedx/core/djangoapps/schedules/resolvers.py @@ -14,6 +14,7 @@ from django.urls import reverse from edx_ace.recipient import Recipient from edx_ace.recipient_resolver import RecipientResolver from edx_django_utils.monitoring import function_trace, set_custom_attribute +from openedx_filters.learning.filters import ScheduleQuerySetRequested from lms.djangoapps.courseware.utils import verified_upgrade_deadline_link, can_show_verified_upgrade from lms.djangoapps.discussion.notification_prefs.views import UsernameCipher @@ -154,6 +155,10 @@ class BinnedSchedulesBaseResolver(PrefixedDebugLoggerMixin, RecipientResolver): schedules = self.filter_by_org(schedules) + # .. filter_implemented_name: ScheduleQuerySetRequested + # .. filter_type: org.openedx.learning.schedule.queryset.requested.v1 + schedules = ScheduleQuerySetRequested.run_filter(schedules) + if "read_replica" in settings.DATABASES: schedules = schedules.using("read_replica") @@ -377,6 +382,13 @@ class CourseUpdateResolver(BinnedSchedulesBaseResolver): language, context, ) + LOG.info( + 'Sending email to user: {} for Instructor-paced course with course-key: {} and language: {}'.format( + user.username, + self.course_id, + language + ) + ) with function_trace('enqueue_send_task'): self.async_send_task.apply_async((self.site.id, str(msg)), retry=False) # pylint: disable=no-member @@ -464,6 +476,13 @@ class CourseNextSectionUpdate(PrefixedDebugLoggerMixin, RecipientResolver): self.course_id ) ) + LOG.info( + 'Sending email to user: {} for Self-paced course with course-key: {} and language: {}'.format( + user.username, + self.course_id, + language + ) + ) with function_trace('enqueue_send_task'): self.async_send_task.apply_async((self.site.id, str(msg)), retry=False) @@ -529,6 +548,8 @@ class CourseNextSectionUpdate(PrefixedDebugLoggerMixin, RecipientResolver): 'course_id': str(course.id), 'course_ids': [str(course.id)], 'unsubscribe_url': unsubscribe_url, + 'self_paced_banner_url': settings.SELF_PACED_BANNER_URL, + 'self_paced_cloud_url': settings.SELF_PACED_CLOUD_URL, }) template_context.update(_get_upsell_information_for_schedule(user, schedule)) diff --git a/openedx/core/djangoapps/schedules/tasks.py b/openedx/core/djangoapps/schedules/tasks.py index 55288d63f1..628276dc22 100644 --- a/openedx/core/djangoapps/schedules/tasks.py +++ b/openedx/core/djangoapps/schedules/tasks.py @@ -274,8 +274,12 @@ def _schedule_send(msg_str, site_id, delivery_config_var, log_prefix): # lint-a site = Site.objects.select_related('configuration').get(pk=site_id) if _is_delivery_enabled(site, delivery_config_var, log_prefix): msg = Message.from_string(msg_str) + msg.options['skip_disable_user_policy'] = True user = User.objects.get(id=msg.recipient.lms_user_id) + if not user.has_usable_password(): + LOG.info(f'{delivery_config_var} Scheduled email User is disabled {user.username}') + return with emulate_http_request(site=site, user=user): _annonate_send_task_for_monitoring(msg) LOG.debug('%s: Sending message = %s', log_prefix, msg_str) diff --git a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/base_body.html b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/base_body.html new file mode 100644 index 0000000000..80021696a4 --- /dev/null +++ b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/base_body.html @@ -0,0 +1,91 @@ +{% load django_markup %} +{% load i18n %} + +{% load ace %} + +{% load acetags %} + +{% get_current_language as LANGUAGE_CODE %} +{% get_current_language_bidi as LANGUAGE_BIDI %} + +{# This is preview text that is visible in the inbox view of many email clients but not visible in the actual #} +{# email itself. #} + +
    +{% block preview_text %}{% endblock %} +
    + +{% for image_src in channel.tracker_image_sources %} + +{% endfor %} + +{% google_analytics_tracking_pixel %} + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + +
    diff --git a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/body.html b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/body.html index fd43f9933b..47b732b1d1 100644 --- a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/body.html +++ b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/body.html @@ -1,6 +1,8 @@ -{% extends 'ace_common/edx_ace/common/base_body.html' %} +{% extends 'schedules/edx_ace/courseupdate/email/base_body.html' %} + {% load i18n %} {% load django_markup %} +{% load static %} {% block preview_text %} {% filter force_escape %} @@ -11,38 +13,105 @@ {% endblock %} {% block content %} - +
    +{% if route_enabled %} +{% endif %} + + + + + + + + + + + + + + + +
    -

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

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

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

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

    + {% with tmsg=course_name %} + We hope you're enjoying {{ tmsg }}! + {% endwith %} +

    +

    + {% filter force_escape %} + We want to let you know what you can look forward to in week {{ week_num }}: + {% endfilter %} +

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

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

    +
    + Message Icon +
    +
    {% endblock %} + +{% block footer%} +{%include 'schedules/edx_ace/courseupdate/email/footer.html'%} +{% endblock%} diff --git a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/footer.html b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/footer.html new file mode 100644 index 0000000000..a7081d0bd3 --- /dev/null +++ b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/footer.html @@ -0,0 +1,195 @@ +{% load django_markup %} +{% load i18n %} +{% load ace %} +{% load acetags %} +{% load static %} + + + + {% if confirm_activation_link %} + {% endif %} + + + +
    + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + +
    + + + + + + +
    + + + + + + +
    + + + + + + + +
    + + {% filter force_escape %}{% blocktrans %}Go to {{ platform_name }} Home Page{% endblocktrans %}{% endfilter %} +
    + + + + + + +
    + + + + {% if social_media_urls.facebook %} + + {% endif %} + {% if social_media_urls.instagram %} + + {% endif %} + {% if social_media_urls.linkedin %} + + {% endif %} + {% if social_media_urls.twitter %} + + {% endif %} + {% if social_media_urls.reddit %} + + {% endif %} + + +
    + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on Facebook{% endblocktrans %}{% endfilter %} + + + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on Facebook{% endblocktrans %}{% endfilter %} + + + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on LinkedIn{% endblocktrans %}{% endfilter %} + + + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on Twitter{% endblocktrans %}{% endfilter %} + + + + {% filter force_escape %}{% blocktrans %}{{ platform_name }} on Reddit{% endblocktrans %}{% endfilter %} + +
    +
    +
    +
    +
    + + + + + + +
    + + + + + + +
    + + + + {% if mobile_store_urls.apple %} + + {% endif %} + + {% if mobile_store_urls.google %} + + {% endif %} + + +
    + + {% trans + + + + {% trans + +
    +
    +
    +
    + {% if disclaimer %} + {{ disclaimer }}
    + {% endif %} + {% trans "edX is the trusted platform for education and learning" as tmsg %}{{ tmsg | force_escape }}.
    +
    + © {% now "Y" %} {{ platform_name }} LLC. {% trans "All rights reserved" as tmsg %}{{ tmsg | force_escape }}.
    +
    + {% if unsubscribe_link %} + + {%if unsubscribe_text%} {{unsubscribe_text}} {%else%} {% trans "Unsubscribe from these emails." as tmsg %}{{ tmsg | force_escape }} {%endif%} +
    +
    + {% endif %} + {{ contact_mailing_address }} +
    +
    +
    \ No newline at end of file diff --git a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/head.html b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/head.html index 366ada7ad9..602b11e4ae 100644 --- a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/head.html +++ b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/head.html @@ -1 +1,40 @@ -{% extends 'ace_common/edx_ace/common/base_head.html' %} +{% load django_markup %} +{% load i18n %} +{% load ace %} +{% load acetags %} +{% load static %} + + + + + +
    + + + + +
    + + + + + + +
     
    + + + + +
    + + + + +
    + + {% filter force_escape %}{% blocktrans %}Go to {{ platform_name }} Home Page{% endblocktrans %}{% endfilter %} +
    +
    +
     
    +
    +
    \ No newline at end of file diff --git a/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/return_to_course_cta.html b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/return_to_course_cta.html new file mode 100644 index 0000000000..0f2b49e09f --- /dev/null +++ b/openedx/core/djangoapps/schedules/templates/schedules/edx_ace/courseupdate/email/return_to_course_cta.html @@ -0,0 +1,37 @@ +{% load i18n %} +{% load ace %} + + {# email client support for style sheets is pretty spotty, so we have to inline all of these styles #} + 1 %} + href="{% with_link_tracking dashboard_url %}" + {% else %} + href="{% with_link_tracking course_url %}" + {% endif %} + {% endif %} + style=" + text-decoration: none; + color: white; + background-color: #ED5C13; + text-align: center; + vertical-align: middle; + user-select: none; + font-weight: 500; + font-size: 12px; + text-decoration-style: solid; + display: inline-flex; + flex-direction: row; + border-radius: 30.22px; + padding-top: 6px; + padding-bottom: 6px; + padding-left: 11px; + padding-right: 11px; + "> + {# old email clients require the use of the font tag :( #} + {{ course_cta_text }} + diff --git a/openedx/core/djangoapps/schedules/tests/test_filters.py b/openedx/core/djangoapps/schedules/tests/test_filters.py new file mode 100644 index 0000000000..9f7efa0504 --- /dev/null +++ b/openedx/core/djangoapps/schedules/tests/test_filters.py @@ -0,0 +1,71 @@ +""" +Test cases for the Open edX Filters associated with the schedule app. +""" + +import datetime +from unittest.mock import Mock + +from django.db.models.query import QuerySet +from django.test import override_settings +from openedx_filters import PipelineStep + +from openedx.core.djangoapps.schedules.resolvers import BinnedSchedulesBaseResolver +from openedx.core.djangoapps.schedules.tests.test_resolvers import SchedulesResolverTestMixin +from openedx.core.djangolib.testing.utils import skip_unless_lms +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + + +class TestScheduleQuerySetRequestedPipelineStep(PipelineStep): + """Pipeline step class to test a configured pipeline step""" + + filtered_schedules = Mock(spec=QuerySet, __len__=Mock(return_value=0)) + + def run_filter(self, schedules: QuerySet): # pylint: disable=arguments-differ + """Pipeline step to filter the schedules""" + return { + "schedules": self.filtered_schedules, + } + + +@skip_unless_lms +class ScheduleQuerySetRequestedFiltersTest(SchedulesResolverTestMixin, ModuleStoreTestCase): + """ + Tests for the Open edX Filters associated with the schedule queryset requested. + + The following filters are tested: + - ScheduleQuerySetRequested + """ + + def setUp(self): + super().setUp() + self.resolver = BinnedSchedulesBaseResolver( + async_send_task=Mock(name="async_send_task"), + site=self.site, + target_datetime=datetime.datetime.now(), + day_offset=3, + bin_num=2, + ) + self.resolver.schedule_date_field = "created" + + @override_settings( + OPEN_EDX_FILTERS_CONFIG={ + "org.openedx.learning.schedule.queryset.requested.v1": { + "pipeline": [ + "openedx.core.djangoapps.schedules.tests.test_filters.TestScheduleQuerySetRequestedPipelineStep", + ], + "fail_silently": False, + }, + }, + ) + def test_schedule_with_queryset_requested_filter_enabled(self) -> None: + """Test to verify the schedule queryset was modified by the pipeline step.""" + schedules = self.resolver.get_schedules_with_target_date_by_bin_and_orgs() + + self.assertEqual(TestScheduleQuerySetRequestedPipelineStep.filtered_schedules, schedules) + + @override_settings(OPEN_EDX_FILTERS_CONFIG={}) + def test_schedule_with_queryset_requested_filter_disabled(self) -> None: + """Test to verify the schedule queryset was not modified when the pipeline step is not configured.""" + schedules = self.resolver.get_schedules_with_target_date_by_bin_and_orgs() + + self.assertNotEqual(TestScheduleQuerySetRequestedPipelineStep.filtered_schedules, schedules) diff --git a/openedx/core/djangoapps/schedules/tests/test_resolvers.py b/openedx/core/djangoapps/schedules/tests/test_resolvers.py index 5c2b592b8e..2c37608e5c 100644 --- a/openedx/core/djangoapps/schedules/tests/test_resolvers.py +++ b/openedx/core/djangoapps/schedules/tests/test_resolvers.py @@ -271,10 +271,12 @@ class TestCourseNextSectionUpdateResolver(SchedulesResolverTestMixin, ModuleStor @override_settings(CONTACT_MAILING_ADDRESS='123 Sesame Street') @override_settings(LOGO_URL_PNG='https://www.logo.png') + @override_settings(SELF_PACED_BANNER_URL='') + @override_settings(SELF_PACED_CLOUD_URL='') def test_schedule_context(self): resolver = self.create_resolver() # using this to make sure the select_related stays intact - with self.assertNumQueries(30): + with self.assertNumQueries(26): sc = resolver.get_schedules() schedules = list(sc) apple_logo_url = 'http://email-media.s3.amazonaws.com/edX/2021/store_apple_229x78.jpg' @@ -316,6 +318,8 @@ class TestCourseNextSectionUpdateResolver(SchedulesResolverTestMixin, ModuleStor 'twitter': twitter_url}, 'template_revision': 'release', 'unsubscribe_url': None, + 'self_paced_banner_url': '', + 'self_paced_cloud_url': '', 'week_highlights': ['good stuff 2'], 'week_num': 2, } diff --git a/openedx/core/djangoapps/schedules/tests/test_tasks.py b/openedx/core/djangoapps/schedules/tests/test_tasks.py index 412a625217..99f790e35b 100644 --- a/openedx/core/djangoapps/schedules/tests/test_tasks.py +++ b/openedx/core/djangoapps/schedules/tests/test_tasks.py @@ -9,9 +9,13 @@ from unittest.mock import DEFAULT, Mock, patch import ddt from django.conf import settings +from django.test import TestCase +from edx_ace.recipient import Recipient +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.schedules.message_types import InstructorLedCourseUpdate from openedx.core.djangoapps.schedules.resolvers import DEFAULT_NUM_BINS -from openedx.core.djangoapps.schedules.tasks import BinnedScheduleMessageBaseTask +from openedx.core.djangoapps.schedules.tasks import BinnedScheduleMessageBaseTask, _schedule_send from openedx.core.djangoapps.schedules.tests.factories import ScheduleConfigFactory from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms @@ -73,3 +77,44 @@ class TestBinnedScheduleMessageBaseTask(CacheIsolationTestCase): # lint-amnesty self.schedule_config.enqueue_recurring_nudge = enabled self.schedule_config.save() assert self.basetask.is_enqueue_enabled(self.site) == enabled + + +@ddt.ddt +@skip_unless_lms +class TestScheduleSendForDisabledUser(TestCase): + """ + Tests email send for disabled users + """ + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.site = SiteFactory.create() + ScheduleConfigFactory.create( + site=self.site, + enqueue_recurring_nudge=True, deliver_recurring_nudge=True, + enqueue_upgrade_reminder=True, deliver_upgrade_reminder=True, + enqueue_course_update=True, deliver_course_update=True, + ) + + @ddt.data(True, False) + @patch('openedx.core.djangoapps.schedules.tasks.ace.send') + def test_email_not_sent_to_disable_users(self, user_enabled, mock_send): + """ + Tests email not send for disabled users + """ + if user_enabled: + self.user.set_password("12345678") + else: + self.user.set_unusable_password() + self.user.save() + msg = InstructorLedCourseUpdate().personalize( + Recipient( + self.user.id, + self.user.email, + ), + "en", + {}, + ) + _schedule_send(str(msg), self.site.id, "deliver_course_update", "Course Update") + assert mock_send.called is user_enabled diff --git a/openedx/core/djangoapps/session_inactivity_timeout/middleware.py b/openedx/core/djangoapps/session_inactivity_timeout/middleware.py index eb5633ff12..08e9d0e0e8 100644 --- a/openedx/core/djangoapps/session_inactivity_timeout/middleware.py +++ b/openedx/core/djangoapps/session_inactivity_timeout/middleware.py @@ -7,6 +7,9 @@ To enable this feature, set in a settings.py: SESSION_INACTIVITY_TIMEOUT_IN_SECS = 300 This was taken from StackOverflow (http://stackoverflow.com/questions/14830669/how-to-expire-django-session-in-5minutes) + +If left unset, session expiration will be handled by Django's SESSION_COOKIE_AGE, +which defaults to 1209600 (2 weeks, in seconds). """ diff --git a/openedx/core/djangoapps/site_configuration/admin.py b/openedx/core/djangoapps/site_configuration/admin.py index 6414069942..851a70286a 100644 --- a/openedx/core/djangoapps/site_configuration/admin.py +++ b/openedx/core/djangoapps/site_configuration/admin.py @@ -1,8 +1,6 @@ """ Django admin page for Site Configuration models """ - - from django.contrib import admin from .models import SiteConfiguration, SiteConfigurationHistory diff --git a/openedx/core/djangoapps/theming/management/commands/compile_sass.py b/openedx/core/djangoapps/theming/management/commands/compile_sass.py deleted file mode 100644 index fbfdd2f222..0000000000 --- a/openedx/core/djangoapps/theming/management/commands/compile_sass.py +++ /dev/null @@ -1,112 +0,0 @@ -""" -Management command for compiling sass. - -DEPRECATED in favor of `npm run compile-sass`. -""" -import shlex - -from django.core.management import BaseCommand -from django.conf import settings - -from pavelib.assets import run_deprecated_command_wrapper - - -class Command(BaseCommand): - """ - Compile theme sass and collect theme assets. - """ - - help = "DEPRECATED. Use 'npm run compile-sass' instead." - - # NOTE (CCB): This allows us to compile static assets in Docker containers without database access. - requires_system_checks = [] - - def add_arguments(self, parser): - """ - Add arguments for compile_sass command. - - Args: - parser (django.core.management.base.CommandParser): parsed for parsing command line arguments. - """ - parser.add_argument( - 'system', type=str, nargs='*', default=["lms", "cms"], - help="lms or studio", - ) - - # Named (optional) arguments - parser.add_argument( - '--theme-dirs', - dest='theme_dirs', - type=str, - nargs='+', - default=None, - help="List of dirs where given themes would be looked.", - ) - - parser.add_argument( - '--themes', - type=str, - nargs='+', - default=["all"], - help="List of themes whose sass need to compiled. Or 'no'/'all' to compile for no/all themes.", - ) - - # Named (optional) arguments - parser.add_argument( - '--force', - action='store_true', - default=False, - help="DEPRECATED. Full recompilation is now always forced.", - ) - parser.add_argument( - '--debug', - action='store_true', - default=False, - help="Disable Sass compression", - ) - - def handle(self, *args, **options): - """ - Handle compile_sass command. - """ - systems = set( - {"lms": "lms", "cms": "cms", "studio": "cms"}[sys] - for sys in options.get("system", ["lms", "cms"]) - ) - theme_dirs = options.get("theme_dirs") or settings.COMPREHENSIVE_THEME_DIRS or [] - themes_option = options.get("themes") or [] # '[]' means 'all' - if not settings.ENABLE_COMPREHENSIVE_THEMING: - compile_themes = False - themes = [] - elif "no" in themes_option: - compile_themes = False - themes = [] - elif "all" in themes_option: - compile_themes = True - themes = [] - else: - compile_themes = True - themes = themes_option - run_deprecated_command_wrapper( - old_command="./manage.py [lms|cms] compile_sass", - ignored_old_flags=list(set(["force"]) & set(options)), - new_command=shlex.join([ - "npm", - "run", - ("compile-sass-dev" if options.get("debug") else "compile-sass"), - "--", - *(["--skip-lms"] if "lms" not in systems else []), - *(["--skip-cms"] if "cms" not in systems else []), - *(["--skip-themes"] if not compile_themes else []), - *( - arg - for theme_dir in theme_dirs - for arg in ["--theme-dir", str(theme_dir)] - ), - *( - arg - for theme in themes - for arg in ["--theme", theme] - ), - ]), - ) diff --git a/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py b/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py index d7578f25eb..41f91c7d1e 100644 --- a/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py +++ b/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py @@ -44,21 +44,6 @@ class TestComprehensiveThemeLMS(TestCase): # This string comes from header.html of test-theme self.assertContains(resp, "This is a footer for test-theme.") - @with_comprehensive_theme("edx.org") - def test_account_settings_hide_nav(self): - """ - Test that theme header doesn't show marketing site links for Account Settings page. - """ - self._login() - - account_settings_url = reverse('account_settings') - response = self.client.get(account_settings_url) - - # Verify that the header navigation links are hidden for the edx.org version - self.assertNotContains(response, "How it Works") - self.assertNotContains(response, "Find courses") - self.assertNotContains(response, "Schools & Partners") - @with_comprehensive_theme("test-theme") def test_logo_image(self): """ diff --git a/openedx/core/djangoapps/user_api/README.rst b/openedx/core/djangoapps/user_api/README.rst index 9e5508ff14..fc508c5f5e 100644 --- a/openedx/core/djangoapps/user_api/README.rst +++ b/openedx/core/djangoapps/user_api/README.rst @@ -33,7 +33,8 @@ This request data must include a key named **extended_profile** that contains a An example request using *curl*, storing information in a field named ``occupation``: -.. code:: +.. code-block:: bash + curl --request PATCH '{{lms_host}}/api/user/v1/accounts/{{lms_username}}' \ -- header 'Authorization: JWT {{jwt_token}}' \ -- header 'Content-Type: application/merge-patch+json' \ @@ -49,6 +50,6 @@ An example request using *curl*, storing information in a field named ``occupati ] }' -It is important to note that this data will not be returned as part of the User API until the system's Site Configuration has been updated. Details on how to update the Site Configuration can be found `here`_. +It is important to note that this data will not be returned as part of the User API until the system's Site Configuration has been updated. Details on how to update the Site Configuration can be found at `Retrieving Extended Profile Metadata`_. -.. _here: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/retrieve_extended_profile_metadata.html +.. _Retrieving Extended Profile Metadata: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/retrieve_extended_profile_metadata.html diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index 6cc466ba00..6970ea6f85 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -9,10 +9,11 @@ import re from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.core.validators import ValidationError, validate_email -from django.utils.translation import override as override_language from django.utils.translation import gettext as _ +from django.utils.translation import override as override_language from eventtracking import tracker from pytz import UTC + from common.djangoapps.student import views as student_views from common.djangoapps.student.models import ( AccountRecovery, @@ -25,7 +26,7 @@ from common.djangoapps.util.model_utils import emit_settings_changed_event from common.djangoapps.util.password_policy_validators import validate_password from lms.djangoapps.certificates.api import get_certificates_for_user from lms.djangoapps.certificates.data import CertificateStatuses - +from openedx.core.djangoapps.embargo.models import GlobalRestrictedCountry from openedx.core.djangoapps.enrollments.api import get_verified_enrollments from openedx.core.djangoapps.user_api import accounts, errors, helpers from openedx.core.djangoapps.user_api.errors import ( @@ -39,6 +40,7 @@ from openedx.core.djangoapps.user_authn.views.registration_form import validate_ from openedx.core.lib.api.view_utils import add_serializer_errors from openedx.features.enterprise_support.utils import get_enterprise_readonly_account_fields from openedx.features.name_affirmation_api.utils import is_name_affirmation_installed + from .serializers import AccountLegacyProfileSerializer, AccountUserSerializer, UserReadOnlySerializer, _visible_fields name_affirmation_installed = is_name_affirmation_installed() @@ -151,7 +153,10 @@ def update_account_settings(requesting_user, update, username=None): _validate_email_change(user, update, field_errors) _validate_secondary_email(user, update, field_errors) - if update.get('country', '') in settings.DISABLED_COUNTRIES: + if ( + settings.FEATURES.get('EMBARGO', False) and + GlobalRestrictedCountry.is_country_restricted(update.get('country', '')) + ): field_errors['country'] = { 'developer_message': 'Country is disabled for registration', 'user_message': 'This country cannot be selected for user registration' diff --git a/openedx/core/djangoapps/user_api/accounts/image_helpers.py b/openedx/core/djangoapps/user_api/accounts/image_helpers.py index 43aa4a60ae..eff2ad272b 100644 --- a/openedx/core/djangoapps/user_api/accounts/image_helpers.py +++ b/openedx/core/djangoapps/user_api/accounts/image_helpers.py @@ -8,10 +8,11 @@ import hashlib from django.conf import settings from django.contrib.staticfiles.storage import staticfiles_storage from django.core.exceptions import ObjectDoesNotExist -from django.core.files.storage import get_storage_class +from django.core.files.storage import default_storage, storages +from django.utils.module_loading import import_string -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from common.djangoapps.student.models import UserProfile +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from ..errors import UserNotFound @@ -22,12 +23,42 @@ _PROFILE_IMAGE_SIZES = list(settings.PROFILE_IMAGE_SIZES_MAP.values()) def get_profile_image_storage(): """ - Configures and returns a django Storage instance that can be used - to physically locate, read and write profile images. + Returns an instance of the configured storage backend for profile images. + + This function prioritizes different settings in the following order to determine + which storage class to use: + + 1. Use 'profile_image' storage from Django's STORAGES if defined (Django 4.2+). + 2. If not available, check the legacy PROFILE_IMAGE_BACKEND setting. + 3. If still undefined, fall back to Django's default_storage. + + Note: + - Starting in Django 5+, `DEFAULT_FILE_STORAGE` and the `STORAGES` setting + are mutually exclusive. Only one of them should be used to avoid + `ImproperlyConfigured` errors. + + Returns: + An instance of the configured storage backend for handling profile images. + + Raises: + ImportError: If the specified storage class cannot be imported. """ - config = settings.PROFILE_IMAGE_BACKEND - storage_class = get_storage_class(config['class']) - return storage_class(**config['options']) + # Prefer new-style Django 4.2+ STORAGES + storages_config = getattr(settings, 'STORAGES', {}) + + if 'profile_image' in storages_config: + return storages['profile_image'] + + # Legacy fallback: PROFILE_IMAGE_BACKEND + config = getattr(settings, 'PROFILE_IMAGE_BACKEND', {}) + storage_class_path = config.get('class') + options = config.get('options', {}) + + if not storage_class_path: + return default_storage + + storage_class = import_string(storage_class_path) + return storage_class(**options) def _make_profile_image_name(username): diff --git a/openedx/core/djangoapps/user_api/accounts/settings_views.py b/openedx/core/djangoapps/user_api/accounts/settings_views.py deleted file mode 100644 index 79e01e0bf0..0000000000 --- a/openedx/core/djangoapps/user_api/accounts/settings_views.py +++ /dev/null @@ -1,300 +0,0 @@ -""" Views related to Account Settings. """ - - -import logging -import urllib -from datetime import datetime - -from django.conf import settings -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.http import HttpResponseRedirect -from django.shortcuts import redirect -from django.urls import reverse -from django.utils.translation import gettext as _ -from django.views.decorators.http import require_http_methods -from django_countries import countries - -from openedx_filters.learning.filters import AccountSettingsRenderStarted -from common.djangoapps import third_party_auth -from common.djangoapps.edxmako.shortcuts import render_to_response -from common.djangoapps.student.models import UserProfile -from common.djangoapps.third_party_auth import pipeline -from common.djangoapps.util.date_utils import strftime_localized -from lms.djangoapps.commerce.models import CommerceConfiguration -from lms.djangoapps.commerce.utils import EcommerceService -from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client -from openedx.core.djangoapps.dark_lang.models import DarkLangConfig -from openedx.core.djangoapps.lang_pref.api import all_languages, released_languages -from openedx.core.djangoapps.programs.models import ProgramsApiConfig -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.user_api.accounts.toggles import ( - should_redirect_to_account_microfrontend, - should_redirect_to_order_history_microfrontend -) -from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences -from openedx.core.lib.edx_api_utils import get_api_data -from openedx.core.lib.time_zone_utils import TIME_ZONE_CHOICES -from openedx.features.enterprise_support.api import enterprise_customer_for_request -from openedx.features.enterprise_support.utils import update_account_settings_context_for_enterprise - -log = logging.getLogger(__name__) - - -@login_required -@require_http_methods(['GET']) -def account_settings(request): - """Render the current user's account settings page. - - Args: - request (HttpRequest) - - Returns: - HttpResponse: 200 if the page was sent successfully - HttpResponse: 302 if not logged in (redirect to login page) - HttpResponse: 405 if using an unsupported HTTP method - - Example usage: - - GET /account/settings - - """ - if should_redirect_to_account_microfrontend(): - url = settings.ACCOUNT_MICROFRONTEND_URL - - duplicate_provider = pipeline.get_duplicate_provider(messages.get_messages(request)) - if duplicate_provider: - url = '{url}?{params}'.format( - url=url, - params=urllib.parse.urlencode({ - 'duplicate_provider': duplicate_provider, - }), - ) - - return redirect(url) - - context = account_settings_context(request) - - account_settings_template = 'student_account/account_settings.html' - - try: - # .. filter_implemented_name: AccountSettingsRenderStarted - # .. filter_type: org.openedx.learning.student.settings.render.started.v1 - context, account_settings_template = AccountSettingsRenderStarted.run_filter( - context=context, template_name=account_settings_template, - ) - except AccountSettingsRenderStarted.RenderInvalidAccountSettings as exc: - response = render_to_response(exc.account_settings_template, exc.template_context) - except AccountSettingsRenderStarted.RedirectToPage as exc: - response = HttpResponseRedirect(exc.redirect_to or reverse('dashboard')) - except AccountSettingsRenderStarted.RenderCustomResponse as exc: - response = exc.response - else: - response = render_to_response(account_settings_template, context) - - return response - - -def account_settings_context(request): - """ Context for the account settings page. - - Args: - request: The request object. - - Returns: - dict - - """ - user = request.user - - year_of_birth_options = [(str(year), str(year)) for year in UserProfile.VALID_YEARS] - try: - user_orders = get_user_orders(user) - except: # pylint: disable=bare-except - log.exception('Error fetching order history from Otto.') - # Return empty order list as account settings page expect a list and - # it will be broken if exception raised - user_orders = [] - - beta_language = {} - dark_lang_config = DarkLangConfig.current() - if dark_lang_config.enable_beta_languages: - user_preferences = get_user_preferences(user) - pref_language = user_preferences.get('pref-lang') - if pref_language in dark_lang_config.beta_languages_list: - beta_language['code'] = pref_language - beta_language['name'] = settings.LANGUAGE_DICT.get(pref_language) - - context = { - 'auth': {}, - 'duplicate_provider': None, - 'nav_hidden': True, - 'fields': { - 'country': { - 'options': list(countries), - }, 'gender': { - 'options': [(choice[0], _(choice[1])) for choice in UserProfile.GENDER_CHOICES], # lint-amnesty, pylint: disable=translation-of-non-string - }, 'language': { - 'options': released_languages(), - }, 'level_of_education': { - 'options': [(choice[0], _(choice[1])) for choice in UserProfile.LEVEL_OF_EDUCATION_CHOICES], # lint-amnesty, pylint: disable=translation-of-non-string - }, 'password': { - 'url': reverse('password_reset'), - }, 'year_of_birth': { - 'options': year_of_birth_options, - }, 'preferred_language': { - 'options': all_languages(), - }, 'time_zone': { - 'options': TIME_ZONE_CHOICES, - } - }, - 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), - 'password_reset_support_link': configuration_helpers.get_value( - 'PASSWORD_RESET_SUPPORT_LINK', settings.PASSWORD_RESET_SUPPORT_LINK - ) or settings.SUPPORT_SITE_LINK, - 'user_accounts_api_url': reverse("accounts_api", kwargs={'username': user.username}), - 'user_preferences_api_url': reverse('preferences_api', kwargs={'username': user.username}), - 'disable_courseware_js': True, - 'show_program_listing': ProgramsApiConfig.is_enabled(), - 'show_dashboard_tabs': True, - 'order_history': user_orders, - 'disable_order_history_tab': should_redirect_to_order_history_microfrontend(), - 'enable_account_deletion': configuration_helpers.get_value( - 'ENABLE_ACCOUNT_DELETION', settings.FEATURES.get('ENABLE_ACCOUNT_DELETION', False) - ), - 'extended_profile_fields': _get_extended_profile_fields(), - 'beta_language': beta_language, - 'enable_coppa_compliance': settings.ENABLE_COPPA_COMPLIANCE, - } - - enterprise_customer = enterprise_customer_for_request(request) - update_account_settings_context_for_enterprise(context, enterprise_customer, user) - - if third_party_auth.is_enabled(): - # If the account on the third party provider is already connected with another edX account, - # we display a message to the user. - context['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request)) - - auth_states = pipeline.get_provider_user_states(user) - - context['auth']['providers'] = [{ - 'id': state.provider.provider_id, - 'name': state.provider.name, # The name of the provider e.g. Facebook - 'connected': state.has_account, # Whether the user's edX account is connected with the provider. - # If the user is not connected, they should be directed to this page to authenticate - # with the particular provider, as long as the provider supports initiating a login. - 'connect_url': pipeline.get_login_url( - state.provider.provider_id, - pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS, - # The url the user should be directed to after the auth process has completed. - redirect_url=reverse('account_settings'), - ), - 'accepts_logins': state.provider.accepts_logins, - # If the user is connected, sending a POST request to this url removes the connection - # information for this provider from their edX account. - 'disconnect_url': pipeline.get_disconnect_url(state.provider.provider_id, state.association_id), - # We only want to include providers if they are either currently available to be logged - # in with, or if the user is already authenticated with them. - } for state in auth_states if state.provider.display_for_login or state.has_account] - - return context - - -def get_user_orders(user): - """Given a user, get the detail of all the orders from the Ecommerce service. - - Args: - user (User): The user to authenticate as when requesting ecommerce. - - Returns: - list of dict, representing orders returned by the Ecommerce service. - """ - user_orders = [] - commerce_configuration = CommerceConfiguration.current() - user_query = {'username': user.username} - - use_cache = commerce_configuration.is_cache_enabled - cache_key = commerce_configuration.CACHE_KEY + '.' + str(user.id) if use_cache else None - commerce_user_orders = get_api_data( - commerce_configuration, - 'orders', - api_client=get_ecommerce_api_client(user), - base_api_url=get_ecommerce_api_base_url(), - querystring=user_query, - cache_key=cache_key - ) - - for order in commerce_user_orders: - if order['status'].lower() == 'complete': - date_placed = datetime.strptime(order['date_placed'], "%Y-%m-%dT%H:%M:%SZ") - order_data = { - 'number': order['number'], - 'price': order['total_excl_tax'], - 'order_date': strftime_localized(date_placed, 'SHORT_DATE'), - 'receipt_url': EcommerceService().get_receipt_page_url(order['number']), - 'lines': order['lines'], - } - user_orders.append(order_data) - - return user_orders - - -def _get_extended_profile_fields(): - """Retrieve the extended profile fields from site configuration to be shown on the - Account Settings page - - Returns: - A list of dicts. Each dict corresponds to a single field. The keys per field are: - "field_name" : name of the field stored in user_profile.meta - "field_label" : The label of the field. - "field_type" : TextField or ListField - "field_options": a list of tuples for options in the dropdown in case of ListField - """ - - extended_profile_fields = [] - fields_already_showing = ['username', 'name', 'email', 'pref-lang', 'country', 'time_zone', 'level_of_education', - 'gender', 'year_of_birth', 'language_proficiencies', 'social_links'] - - field_labels_map = { - "first_name": _("First Name"), - "last_name": _("Last Name"), - "city": _("City"), - "state": _("State/Province/Region"), - "company": _("Company"), - "title": _("Title"), - "job_title": _("Job Title"), - "mailing_address": _("Mailing address"), - "goals": _("Tell us why you're interested in {platform_name}").format( - platform_name=configuration_helpers.get_value("PLATFORM_NAME", settings.PLATFORM_NAME) - ), - "profession": _("Profession"), - "specialty": _("Specialty"), - "work_experience": _("Work experience") - } - - extended_profile_field_names = configuration_helpers.get_value('extended_profile_fields', []) - for field_to_exclude in fields_already_showing: - if field_to_exclude in extended_profile_field_names: - extended_profile_field_names.remove(field_to_exclude) - - extended_profile_field_options = configuration_helpers.get_value('EXTRA_FIELD_OPTIONS', []) - extended_profile_field_option_tuples = {} - for field in extended_profile_field_options.keys(): - field_options = extended_profile_field_options[field] - extended_profile_field_option_tuples[field] = [(option.lower(), option) for option in field_options] - - for field in extended_profile_field_names: - field_dict = { - "field_name": field, - "field_label": field_labels_map.get(field, field), - } - - field_options = extended_profile_field_option_tuples.get(field) - if field_options: - field_dict["field_type"] = "ListField" - field_dict["field_options"] = field_options - else: - field_dict["field_type"] = "TextField" - extended_profile_fields.append(field_dict) - - return extended_profile_fields diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py index 5123c4cf41..f9071c06a5 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py @@ -7,18 +7,19 @@ import datetime import itertools import unicodedata from unittest.mock import Mock, patch -import pytest + import ddt +import pytest from django.conf import settings from django.contrib.auth.hashers import make_password from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.http import HttpResponse from django.test import TestCase from django.test.client import RequestFactory -from django.test.utils import override_settings from django.urls import reverse from pytz import UTC from social_django.models import UserSocialAuth + from common.djangoapps.student.models import ( AccountRecovery, PendingEmailChange, @@ -28,14 +29,14 @@ from common.djangoapps.student.models import ( from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.student.tests.tests import UserSettingsEventTestMixin from common.djangoapps.student.views.management import activate_secondary_email - from lms.djangoapps.certificates.data import CertificateStatuses from openedx.core.djangoapps.ace_common.tests.mixins import EmailTemplateTagMixin +from openedx.core.djangoapps.embargo.models import Country, GlobalRestrictedCountry from openedx.core.djangoapps.user_api.accounts import PRIVATE_VISIBILITY from openedx.core.djangoapps.user_api.accounts.api import ( get_account_settings, - update_account_settings, - get_name_validation_error + get_name_validation_error, + update_account_settings ) from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import ( # pylint: disable=unused-import RetirementTestCase, @@ -574,12 +575,14 @@ class TestAccountApi(UserSettingsEventTestMixin, EmailTemplateTagMixin, CreateAc assert account_settings['country'] is None assert account_settings['state'] is None - @override_settings(DISABLED_COUNTRIES=['KP']) def test_change_to_disabled_country(self): """ Test that changing the country to a disabled country is not allowed """ # First set the country and state + country = Country.objects.create(country="KP") + GlobalRestrictedCountry.objects.create(country=country) + update_account_settings(self.user, {"country": UserProfile.COUNTRY_WITH_STATES, "state": "MA"}) account_settings = get_account_settings(self.default_request)[0] assert account_settings['country'] == UserProfile.COUNTRY_WITH_STATES diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_filters.py b/openedx/core/djangoapps/user_api/accounts/tests/test_filters.py deleted file mode 100644 index 782549aea0..0000000000 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_filters.py +++ /dev/null @@ -1,241 +0,0 @@ -""" -Test that various filters are fired for views in the certificates app. -""" -from django.http import HttpResponse -from django.test import override_settings -from django.urls import reverse -from openedx_filters import PipelineStep -from openedx_filters.learning.filters import AccountSettingsRenderStarted -from rest_framework import status -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase - -from openedx.core.djangolib.testing.utils import skip_unless_lms -from common.djangoapps.student.tests.factories import UserFactory - - -class TestRenderInvalidAccountSettings(PipelineStep): - """ - Utility class used when getting steps for pipeline. - """ - - def run_filter(self, context, template_name): # pylint: disable=arguments-differ - """ - Pipeline step that stops the course about render process. - """ - raise AccountSettingsRenderStarted.RenderInvalidAccountSettings( - "You can't access the account settings page.", - account_settings_template="static_templates/server-error.html", - ) - - -class TestRedirectToPage(PipelineStep): - """ - Utility class used when getting steps for pipeline. - """ - - def run_filter(self, context, template_name): # pylint: disable=arguments-differ - """ - Pipeline step that redirects to dashboard before rendering the account settings page. - - When raising RedirectToPage, this filter uses a redirect_to field handled by - the course about view that redirects to that URL. - """ - raise AccountSettingsRenderStarted.RedirectToPage( - "You can't access this page, redirecting to dashboard.", - redirect_to="/courses", - ) - - -class TestRedirectToDefaultPage(PipelineStep): - """ - Utility class used when getting steps for pipeline. - """ - - def run_filter(self, context, template_name): # pylint: disable=arguments-differ - """ - Pipeline step that redirects to dashboard before rendering the account settings page. - - When raising RedirectToPage, this filter uses a redirect_to field handled by - the course about view that redirects to that URL. - """ - raise AccountSettingsRenderStarted.RedirectToPage( - "You can't access this page, redirecting to dashboard." - ) - - -class TestRenderCustomResponse(PipelineStep): - """ - Utility class used when getting steps for pipeline. - """ - - def run_filter(self, context, template_name): # pylint: disable=arguments-differ - """Pipeline step that returns a custom response when rendering the account settings page.""" - response = HttpResponse("Here's the text of the web page.") - raise AccountSettingsRenderStarted.RenderCustomResponse( - "You can't access this page.", - response=response, - ) - - -class TestAccountSettingsRender(PipelineStep): - """ - Utility class used when getting steps for pipeline. - """ - - def run_filter(self, context, template_name): # pylint: disable=arguments-differ - """Pipeline step that returns a custom response when rendering the account settings page.""" - template_name = 'static_templates/about.html' - return { - "context": context, "template_name": template_name, - } - - -@skip_unless_lms -class TestAccountSettingsFilters(SharedModuleStoreTestCase): - """ - Tests for the Open edX Filters associated with the account settings proccess. - - This class guarantees that the following filters are triggered during the user's account settings rendering: - - - AccountSettingsRenderStarted - """ - def setUp(self): # pylint: disable=arguments-differ - super().setUp() - self.user = UserFactory.create( - username="somestudent", - first_name="Student", - last_name="Person", - email="robot@robot.org", - is_active=True, - password="password", - ) - self.client.login(username=self.user.username, password="password") - self.account_settings_url = '/account/settings' - - @override_settings( - OPEN_EDX_FILTERS_CONFIG={ - "org.openedx.learning.student.settings.render.started.v1": { - "pipeline": [ - "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestAccountSettingsRender", - ], - "fail_silently": False, - }, - }, - ) - def test_account_settings_render_filter_executed(self): - """ - Test whether the account settings filter is triggered before the user's - account settings page is rendered. - - Expected result: - - AccountSettingsRenderStarted is triggered and executes TestAccountSettingsRender - """ - response = self.client.get(self.account_settings_url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertContains(response, "This page left intentionally blank. Feel free to add your own content.") - - @override_settings( - OPEN_EDX_FILTERS_CONFIG={ - "org.openedx.learning.student.settings.render.started.v1": { - "pipeline": [ - "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestRenderInvalidAccountSettings", # pylint: disable=line-too-long - ], - "fail_silently": False, - }, - }, - PLATFORM_NAME="My site", - ) - def test_account_settings_render_alternative(self): - """ - Test whether the account settings filter is triggered before the user's - account settings page is rendered. - - Expected result: - - AccountSettingsRenderStarted is triggered and executes TestRenderInvalidAccountSettings # pylint: disable=line-too-long - """ - response = self.client.get(self.account_settings_url) - - self.assertContains(response, "There has been a 500 error on the My site servers") - - @override_settings( - OPEN_EDX_FILTERS_CONFIG={ - "org.openedx.learning.student.settings.render.started.v1": { - "pipeline": [ - "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestRenderCustomResponse", - ], - "fail_silently": False, - }, - }, - ) - def test_account_settings_render_custom_response(self): - """ - Test whether the account settings filter is triggered before the user's - account settings page is rendered. - - Expected result: - - AccountSettingsRenderStarted is triggered and executes TestRenderCustomResponse - """ - response = self.client.get(self.account_settings_url) - - self.assertEqual(response.content, b"Here's the text of the web page.") - - @override_settings( - OPEN_EDX_FILTERS_CONFIG={ - "org.openedx.learning.student.settings.render.started.v1": { - "pipeline": [ - "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestRedirectToPage", - ], - "fail_silently": False, - }, - }, - ) - def test_account_settings_redirect_to_page(self): - """ - Test whether the account settings filter is triggered before the user's - account settings page is rendered. - - Expected result: - - AccountSettingsRenderStarted is triggered and executes TestRedirectToPage - """ - response = self.client.get(self.account_settings_url) - - self.assertEqual(response.status_code, status.HTTP_302_FOUND) - self.assertEqual('/courses', response.url) - - @override_settings( - OPEN_EDX_FILTERS_CONFIG={ - "org.openedx.learning.student.settings.render.started.v1": { - "pipeline": [ - "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestRedirectToDefaultPage", - ], - "fail_silently": False, - }, - }, - ) - def test_account_settings_redirect_default(self): - """ - Test whether the account settings filter is triggered before the user's - account settings page is rendered. - - Expected result: - - AccountSettingsRenderStarted is triggered and executes TestRedirectToDefaultPage - """ - response = self.client.get(self.account_settings_url) - - self.assertEqual(response.status_code, status.HTTP_302_FOUND) - self.assertEqual(f"{reverse('dashboard')}", response.url) - - @override_settings(OPEN_EDX_FILTERS_CONFIG={}) - def test_account_settings_render_without_filter_config(self): - """ - Test whether the course about filter is triggered before the course about - render without affecting its execution flow. - - Expected result: - - AccountSettingsRenderStarted executes a noop (empty pipeline). Without any - modification comparing it with the effects of TestAccountSettingsRender. - - The view response is HTTP_200_OK. - """ - response = self.client.get(self.account_settings_url) - - self.assertNotContains(response, "This page left intentionally blank. Feel free to add your own content.") diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py deleted file mode 100644 index badee6e875..0000000000 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py +++ /dev/null @@ -1,287 +0,0 @@ -""" Tests for views related to account settings. """ - - -from unittest import mock -from django.conf import settings -from django.contrib import messages -from django.contrib.messages.middleware import MessageMiddleware -from django.http import HttpRequest -from django.test import TestCase -from django.test.utils import override_settings -from django.urls import reverse -from requests import exceptions - -from edx_toggles.toggles.testutils import override_waffle_flag -from lms.djangoapps.commerce.models import CommerceConfiguration -from lms.djangoapps.commerce.tests import factories -from lms.djangoapps.commerce.tests.mocks import mock_get_orders -from openedx.core.djangoapps.dark_lang.models import DarkLangConfig -from openedx.core.djangoapps.lang_pref.tests.test_api import EN, LT_LT -from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin -from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory -from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin -from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration -from openedx.core.djangoapps.user_api.accounts.settings_views import account_settings_context, get_user_orders -from openedx.core.djangoapps.user_api.accounts.toggles import REDIRECT_TO_ACCOUNT_MICROFRONTEND -from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory -from openedx.core.djangolib.testing.utils import skip_unless_lms -from openedx.features.enterprise_support.utils import get_enterprise_readonly_account_fields -from common.djangoapps.student.tests.factories import UserFactory -from common.djangoapps.third_party_auth.tests.testutil import ThirdPartyAuthTestMixin - - -@skip_unless_lms -class AccountSettingsViewTest(ThirdPartyAuthTestMixin, SiteMixin, ProgramsApiConfigMixin, TestCase): - """ Tests for the account settings view. """ - - USERNAME = 'student' - PASSWORD = 'password' - FIELDS = [ - 'country', - 'gender', - 'language', - 'level_of_education', - 'password', - 'year_of_birth', - 'preferred_language', - 'time_zone', - ] - - @mock.patch("django.conf.settings.MESSAGE_STORAGE", 'django.contrib.messages.storage.cookie.CookieStorage') - def setUp(self): # pylint: disable=arguments-differ - super().setUp() - self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD) - CommerceConfiguration.objects.create(cache_ttl=10, enabled=True) - self.client.login(username=self.USERNAME, password=self.PASSWORD) - - self.request = HttpRequest() - self.request.user = self.user - - # For these tests, two third party auth providers are enabled by default: - self.configure_google_provider(enabled=True, visible=True) - self.configure_facebook_provider(enabled=True, visible=True) - - # Python-social saves auth failure notifcations in Django messages. - # See pipeline.get_duplicate_provider() for details. - self.request.COOKIES = {} - MessageMiddleware(get_response=lambda request: None).process_request(self.request) - messages.error(self.request, 'Facebook is already in use.', extra_tags='Auth facebook') - - @mock.patch('openedx.features.enterprise_support.api.enterprise_customer_for_request') - def test_context(self, mock_enterprise_customer_for_request): - self.request.site = SiteFactory.create() - UserPreferenceFactory(user=self.user, key='pref-lang', value='lt-lt') - DarkLangConfig( - released_languages='en', - changed_by=self.user, - enabled=True, - beta_languages='lt-lt', - enable_beta_languages=True - ).save() - mock_enterprise_customer_for_request.return_value = {} - - with override_settings(LANGUAGES=[EN, LT_LT], LANGUAGE_CODE='en'): - context = account_settings_context(self.request) - - user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username}) - assert context['user_accounts_api_url'] == user_accounts_api_url - - user_preferences_api_url = reverse('preferences_api', kwargs={'username': self.user.username}) - assert context['user_preferences_api_url'] == user_preferences_api_url - - for attribute in self.FIELDS: - assert attribute in context['fields'] - - assert context['user_accounts_api_url'] == reverse('accounts_api', kwargs={'username': self.user.username}) - assert context['user_preferences_api_url'] ==\ - reverse('preferences_api', kwargs={'username': self.user.username}) - - assert context['duplicate_provider'] == 'facebook' - assert context['auth']['providers'][0]['name'] == 'Facebook' - assert context['auth']['providers'][1]['name'] == 'Google' - - assert context['sync_learner_profile_data'] is False - assert context['edx_support_url'] == settings.SUPPORT_SITE_LINK - assert context['enterprise_name'] is None - assert context['enterprise_readonly_account_fields'] ==\ - {'fields': list(get_enterprise_readonly_account_fields(self.user))} - - expected_beta_language = {'code': 'lt-lt', 'name': settings.LANGUAGE_DICT.get('lt-lt')} - assert context['beta_language'] == expected_beta_language - - @with_site_configuration( - configuration={ - 'extended_profile_fields': ['work_experience'] - } - ) - def test_context_extended_profile(self): - """ - Test that if the field is available in extended_profile configuration then the field - will be sent in response. - """ - context = account_settings_context(self.request) - extended_pofile_field = context['extended_profile_fields'][0] - assert extended_pofile_field['field_name'] == 'work_experience' - assert extended_pofile_field['field_label'] == 'Work experience' - - @mock.patch('openedx.core.djangoapps.user_api.accounts.settings_views.enterprise_customer_for_request') - @mock.patch('openedx.features.enterprise_support.utils.third_party_auth.provider.Registry.get') - def test_context_for_enterprise_learner( - self, mock_get_auth_provider, mock_enterprise_customer_for_request - ): - dummy_enterprise_customer = { - 'uuid': 'real-ent-uuid', - 'name': 'Dummy Enterprise', - 'identity_provider': 'saml-ubc' - } - mock_enterprise_customer_for_request.return_value = dummy_enterprise_customer - self.request.site = SiteFactory.create() - mock_get_auth_provider.return_value.sync_learner_profile_data = True - context = account_settings_context(self.request) - - user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username}) - assert context['user_accounts_api_url'] == user_accounts_api_url - - user_preferences_api_url = reverse('preferences_api', kwargs={'username': self.user.username}) - assert context['user_preferences_api_url'] == user_preferences_api_url - - for attribute in self.FIELDS: - assert attribute in context['fields'] - - assert context['user_accounts_api_url'] == reverse('accounts_api', kwargs={'username': self.user.username}) - assert context['user_preferences_api_url'] ==\ - reverse('preferences_api', kwargs={'username': self.user.username}) - - assert context['duplicate_provider'] == 'facebook' - assert context['auth']['providers'][0]['name'] == 'Facebook' - assert context['auth']['providers'][1]['name'] == 'Google' - - assert context['sync_learner_profile_data'] == mock_get_auth_provider.return_value.sync_learner_profile_data - assert context['edx_support_url'] == settings.SUPPORT_SITE_LINK - assert context['enterprise_name'] == dummy_enterprise_customer['name'] - assert context['enterprise_readonly_account_fields'] ==\ - {'fields': list(get_enterprise_readonly_account_fields(self.user))} - - def test_view(self): - """ - Test that all fields are visible - """ - view_path = reverse('account_settings') - response = self.client.get(path=view_path) - - for attribute in self.FIELDS: - self.assertContains(response, attribute) - - def test_header_with_programs_listing_enabled(self): - """ - Verify that tabs header will be shown while program listing is enabled. - """ - self.create_programs_config() - view_path = reverse('account_settings') - response = self.client.get(path=view_path) - - self.assertContains(response, 'global-header') - - def test_header_with_programs_listing_disabled(self): - """ - Verify that nav header will be shown while program listing is disabled. - """ - self.create_programs_config(enabled=False) - view_path = reverse('account_settings') - response = self.client.get(path=view_path) - - self.assertContains(response, 'global-header') - - def test_commerce_order_detail(self): - """ - Verify that get_user_orders returns the correct order data. - """ - with mock_get_orders(): - order_detail = get_user_orders(self.user) - - for i, order in enumerate(mock_get_orders.default_response['results']): - expected = { - 'number': order['number'], - 'price': order['total_excl_tax'], - 'order_date': 'Jan 01, 2016', - 'receipt_url': '/checkout/receipt/?order_number=' + order['number'], - 'lines': order['lines'], - } - assert order_detail[i] == expected - - def test_commerce_order_detail_exception(self): - with mock_get_orders(exception=exceptions.HTTPError): - order_detail = get_user_orders(self.user) - - assert not order_detail - - def test_incomplete_order_detail(self): - response = { - 'results': [ - factories.OrderFactory( - status='Incomplete', - lines=[ - factories.OrderLineFactory( - product=factories.ProductFactory(attribute_values=[factories.ProductAttributeFactory()]) - ) - ] - ) - ] - } - with mock_get_orders(response=response): - order_detail = get_user_orders(self.user) - - assert not order_detail - - def test_order_history_with_no_product(self): - response = { - 'results': [ - factories.OrderFactory( - lines=[ - factories.OrderLineFactory( - product=None - ), - factories.OrderLineFactory( - product=factories.ProductFactory(attribute_values=[factories.ProductAttributeFactory( - name='certificate_type', - value='verified' - )]) - ) - ] - ) - ] - } - with mock_get_orders(response=response): - order_detail = get_user_orders(self.user) - - assert len(order_detail) == 1 - - def test_redirect_view(self): - old_url_path = reverse('account_settings') - with override_waffle_flag(REDIRECT_TO_ACCOUNT_MICROFRONTEND, active=True): - # Test with waffle flag active and none site setting, redirects to microfrontend - response = self.client.get(path=old_url_path) - self.assertRedirects(response, settings.ACCOUNT_MICROFRONTEND_URL, fetch_redirect_response=False) - - # Test with waffle flag disabled and site setting disabled, does not redirect - response = self.client.get(path=old_url_path) - for attribute in self.FIELDS: - self.assertContains(response, attribute) - - # Test with site setting disabled, does not redirect - site_domain = 'othersite.example.com' - site = self.set_up_site(site_domain, { - 'SITE_NAME': site_domain, - 'ENABLE_ACCOUNT_MICROFRONTEND': False - }) - self.client.login(username=self.USERNAME, password=self.PASSWORD) - response = self.client.get(path=old_url_path) - for attribute in self.FIELDS: - self.assertContains(response, attribute) - - # Test with site setting enabled, redirects to microfrontend - site.configuration.site_values['ENABLE_ACCOUNT_MICROFRONTEND'] = True - site.configuration.save() - site.__class__.objects.clear_cache() - response = self.client.get(path=old_url_path) - self.assertRedirects(response, settings.ACCOUNT_MICROFRONTEND_URL, fetch_redirect_response=False) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index ff0fb7abe4..466e1e278a 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -12,11 +12,13 @@ from urllib.parse import quote import ddt import pytz from django.conf import settings +from django.core.files.storage import FileSystemStorage from django.test.testcases import TransactionTestCase from django.test.utils import override_settings from django.urls import reverse from rest_framework import status from rest_framework.test import APIClient, APITestCase +from storages.backends.s3boto3 import S3Boto3Storage from common.djangoapps.student.models import PendingEmailChange, UserProfile from common.djangoapps.student.models_api import do_name_change_request, get_pending_name_change @@ -33,6 +35,7 @@ from openedx.core.djangoapps.user_api.accounts.tests.factories import ( RetirementStateFactory, UserRetirementStatusFactory ) +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_storage from openedx.core.djangoapps.user_api.models import UserPreference, UserRetirementStatus from openedx.core.djangoapps.user_api.preferences.api import set_user_preference from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES @@ -400,17 +403,18 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe assert data['social_links'] is not None assert data['time_zone'] is None - def _verify_private_account_response(self, response, requires_parental_consent=False): + def _verify_private_account_response(self, response, requires_parental_consent=False, has_profile_image=True): """ Verify that only the public fields are returned if a user does not want to share account fields """ data = response.data assert 3 == len(data) assert PRIVATE_VISIBILITY == data['account_privacy'] - self._verify_profile_image_data(data, not requires_parental_consent) + self._verify_profile_image_data(data, has_profile_image) assert self.user.username == data['username'] - def _verify_full_account_response(self, response, requires_parental_consent=False, year_of_birth=2000): + def _verify_full_account_response(self, response, requires_parental_consent=False, + has_profile_image=True, year_of_birth=2000): """ Verify that all account fields are returned (even those that are not shareable). """ @@ -423,7 +427,7 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe UserPreference.get_value(self.user, 'account_privacy') ) assert expected_account_privacy == data['account_privacy'] - self._verify_profile_image_data(data, not requires_parental_consent) + self._verify_profile_image_data(data, has_profile_image) assert self.user.username == data['username'] # additional shareable fields (8) @@ -1156,6 +1160,37 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe assert "Error thrown when saving account updates: 'bummer'" == error_response.data['developer_message'] assert error_response.data['user_message'] is None + def test_profile_image_backend(self): + # settings file contains the `VIDEO_IMAGE_SETTINGS` but dont'have STORAGE_CLASS + # so it returns the default storage. + storage = get_profile_image_storage() + storage_class = storage.__class__ + self.assertEqual( + settings.PROFILE_IMAGE_BACKEND['class'], + f"{storage_class.__module__}.{storage_class.__name__}", + ) + self.assertEqual(storage.base_url, settings.PROFILE_IMAGE_BACKEND['options']['base_url']) + + @override_settings(PROFILE_IMAGE_BACKEND={ + 'class': 'storages.backends.s3boto3.S3Boto3Storage', + 'options': { + 'bucket_name': 'test', + 'default_acl': 'public', + 'location': 'abc/def' + } + }) + def test_profile_backend_with_params(self): + storage = get_profile_image_storage() + self.assertIsInstance(storage, S3Boto3Storage) + self.assertEqual(storage.bucket_name, "test") + self.assertEqual(storage.default_acl, 'public') + self.assertEqual(storage.location, "abc/def") + + @override_settings(PROFILE_IMAGE_BACKEND={'class': None, 'options': {}}) + def test_profile_backend_without_backend(self): + storage = get_profile_image_storage() + self.assertIsInstance(storage, FileSystemStorage) + @override_settings(PROFILE_IMAGE_BACKEND=TEST_PROFILE_IMAGE_BACKEND) def test_convert_relative_profile_url(self): """ @@ -1170,6 +1205,37 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe 'image_url_full': 'http://testserver/static/default_50.png', 'image_url_small': 'http://testserver/static/default_10.png'} + @override_settings( + PROFILE_IMAGE_BACKEND={}, + STORAGES={ + 'profile_image': { + 'BACKEND': 'storages.backends.s3boto3.S3Boto3Storage', + 'OPTIONS': { + 'bucket_name': 'profiles', + 'default_acl': 'public', + 'location': 'profile/images', + } + } + } + ) + def test_profile_backend_with_profile_image_settings(self): + """ It will use the storages dict with profile_images backend""" + storage = get_profile_image_storage() + self.assertIsInstance(storage, S3Boto3Storage) + self.assertEqual(storage.bucket_name, "profiles") + self.assertEqual(storage.default_acl, 'public') + self.assertEqual(storage.location, "profile/images") + + @override_settings( + PROFILE_IMAGE_BACKEND={}, + ) + def test_profile_backend_with_default_hardcoded_backend(self): + """ In case of empty storages scenario uses the hardcoded backend.""" + del settings.DEFAULT_FILE_STORAGE + del settings.STORAGES + storage = get_profile_image_storage() + self.assertIsInstance(storage, FileSystemStorage) + @ddt.data( ("client", "user", True), ("different_client", "different_user", False), @@ -1206,11 +1272,11 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe assert data['requires_parental_consent'] assert PRIVATE_VISIBILITY == data['account_privacy'] else: - self._verify_private_account_response(response, requires_parental_consent=True) + self._verify_private_account_response(response, requires_parental_consent=True, has_profile_image=False) # Verify that the shared view is still private response = self.send_get(client, query_parameters='view=shared') - self._verify_private_account_response(response, requires_parental_consent=True) + self._verify_private_account_response(response, requires_parental_consent=True, has_profile_image=False) @skip_unless_lms diff --git a/openedx/core/djangoapps/user_api/accounts/toggles.py b/openedx/core/djangoapps/user_api/accounts/toggles.py deleted file mode 100644 index 80de4fa756..0000000000 --- a/openedx/core/djangoapps/user_api/accounts/toggles.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -Toggles for accounts related code. -""" - -from edx_toggles.toggles import WaffleFlag - -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers - -# .. toggle_name: order_history.redirect_to_microfrontend -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Supports staged rollout of a new micro-frontend-based implementation of the order history page. -# .. toggle_use_cases: temporary, open_edx -# .. toggle_creation_date: 2019-04-11 -# .. toggle_target_removal_date: 2020-12-31 -# .. toggle_warning: Also set settings.ORDER_HISTORY_MICROFRONTEND_URL and site's -# ENABLE_ORDER_HISTORY_MICROFRONTEND. -# .. toggle_tickets: DEPR-17 -REDIRECT_TO_ORDER_HISTORY_MICROFRONTEND = WaffleFlag('order_history.redirect_to_microfrontend', __name__) - - -def should_redirect_to_order_history_microfrontend(): - return ( - configuration_helpers.get_value('ENABLE_ORDER_HISTORY_MICROFRONTEND') and - REDIRECT_TO_ORDER_HISTORY_MICROFRONTEND.is_enabled() - ) - - -# .. toggle_name: account.redirect_to_microfrontend -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Supports staged rollout of a new micro-frontend-based implementation of the account page. -# Its action can be overridden using site's ENABLE_ACCOUNT_MICROFRONTEND setting. -# .. toggle_use_cases: temporary, open_edx -# .. toggle_creation_date: 2019-04-30 -# .. toggle_target_removal_date: 2021-12-31 -# .. toggle_warning: Also set settings.ACCOUNT_MICROFRONTEND_URL. -# .. toggle_tickets: DEPR-17 -REDIRECT_TO_ACCOUNT_MICROFRONTEND = WaffleFlag('account.redirect_to_microfrontend', __name__) - - -def should_redirect_to_account_microfrontend(): - return configuration_helpers.get_value('ENABLE_ACCOUNT_MICROFRONTEND', - REDIRECT_TO_ACCOUNT_MICROFRONTEND.is_enabled()) diff --git a/openedx/core/djangoapps/user_api/accounts/utils.py b/openedx/core/djangoapps/user_api/accounts/utils.py index 3cc03c02ed..826dbd42cd 100644 --- a/openedx/core/djangoapps/user_api/accounts/utils.py +++ b/openedx/core/djangoapps/user_api/accounts/utils.py @@ -51,11 +51,11 @@ def format_social_link(platform_name, new_social_link): """ Given a user's social link, returns a safe absolute url for the social link. - Returns the following based on the provided new_social_link: - 1) Given an empty string, returns '' - 1) Given a valid username, return 'https://www.[platform_name_base][username]' - 2) Given a valid URL, return 'https://www.[platform_name_base][username]' - 3) Given anything unparseable, returns None + Returns: + - An empty string if `new_social_link` is empty. + - A formatted URL if `new_social_link` is a username. + - Returns `new_social_link` if it is a valid URL. + - None for unparseable inputs. """ # Blank social links should return '' or None as was passed in. if not new_social_link: diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 103c5bf24f..55b90aa67f 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -18,6 +18,8 @@ from django.contrib.sites.models import Site from django.core.cache import cache from django.db import transaction from django.utils.translation import gettext as _ +from drf_yasg import openapi +from drf_yasg.utils import swagger_auto_schema from edx_ace import ace from edx_ace.recipient import Recipient from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication @@ -36,6 +38,7 @@ from rest_framework.viewsets import ViewSet from wiki.models import ArticleRevision from wiki.models.pluginbase import RevisionPluginRevision +from common.djangoapps.track import segment from common.djangoapps.entitlements.models import CourseEntitlement from common.djangoapps.student.models import ( # lint-amnesty, pylint: disable=unused-import CourseEnrollmentAllowed, @@ -125,168 +128,17 @@ def request_requires_username(function): return wrapper +account_get_me_return_schema = openapi.Schema( + type=openapi.TYPE_OBJECT, + properties={ + "username": openapi.Schema(type=openapi.TYPE_STRING), + }, +) + + +# pylint: disable=line-too-long class AccountViewSet(ViewSet): - """ - **Use Cases** - - Get or update a user's account information. Updates are supported - only through merge patch. - - **Example Requests** - - GET /api/user/v1/me[?view=shared] - GET /api/user/v1/accounts?usernames={username1,username2}[?view=shared] - GET /api/user/v1/accounts?email={user_email} - GET /api/user/v1/accounts/{username}/[?view=shared] - - PATCH /api/user/v1/accounts/{username}/{"key":"value"} "application/merge-patch+json" - - POST /api/user/v1/accounts/search_emails "application/json" - - **Notes for PATCH requests to /accounts endpoints** - * Requested updates to social_links are automatically merged with - previously set links. That is, any newly introduced platforms are - add to the previous list. Updated links to pre-existing platforms - replace their values in the previous list. Pre-existing platforms - can be removed by setting the value of the social_link to an - empty string (""). - - **Response Values for GET requests to the /me endpoint** - If the user is not logged in, an HTTP 401 "Not Authorized" response - is returned. - - Otherwise, an HTTP 200 "OK" response is returned. The response - contains the following value: - - * username: The username associated with the account. - - **Response Values for GET requests to /accounts endpoints** - - If no user exists with the specified username, or email, an HTTP 404 "Not - Found" response is returned. - - If the user makes the request for her own account, or makes a - request for another account and has "is_staff" access, an HTTP 200 - "OK" response is returned. The response contains the following - values. - - * id: numerical lms user id in db - * activation_key: auto-genrated activation key when signed up via email - * bio: null or textual representation of user biographical - information ("about me"). - * country: An ISO 3166 country code or null. - * date_joined: The date the account was created, in the string - format provided by datetime. For example, "2014-08-26T17:52:11Z". - * last_login: The latest date the user logged in, in the string datetime format. - * email: Email address for the user. New email addresses must be confirmed - via a confirmation email, so GET does not reflect the change until - the address has been confirmed. - * secondary_email: A secondary email address for the user. Unlike - the email field, GET will reflect the latest update to this field - even if changes have yet to be confirmed. - * verified_name: Approved verified name of the learner present in name affirmation plugin - * gender: One of the following values: - - * null - * "f" - * "m" - * "o" - - * goals: The textual representation of the user's goals, or null. - * is_active: Boolean representation of whether a user is active. - * language: The user's preferred language, or null. - * language_proficiencies: Array of language preferences. Each - preference is a JSON object with the following keys: - - * "code": string ISO 639-1 language code e.g. "en". - - * level_of_education: One of the following values: - - * "p": PhD or Doctorate - * "m": Master's or professional degree - * "b": Bachelor's degree - * "a": Associate's degree - * "hs": Secondary/high school - * "jhs": Junior secondary/junior high/middle school - * "el": Elementary/primary school - * "none": None - * "o": Other - * null: The user did not enter a value - - * mailing_address: The textual representation of the user's mailing - address, or null. - * name: The full name of the user. - * profile_image: A JSON representation of a user's profile image - information. This representation has the following keys. - - * "has_image": Boolean indicating whether the user has a profile - image. - * "image_url_*": Absolute URL to various sizes of a user's - profile image, where '*' matches a representation of the - corresponding image size, such as 'small', 'medium', 'large', - and 'full'. These are configurable via PROFILE_IMAGE_SIZES_MAP. - - * requires_parental_consent: True if the user is a minor - requiring parental consent. - * social_links: Array of social links, sorted alphabetically by - "platform". Each preference is a JSON object with the following keys: - - * "platform": A particular social platform, ex: 'facebook' - * "social_link": The link to the user's profile on the particular platform - - * username: The username associated with the account. - * year_of_birth: The year the user was born, as an integer, or null. - - * account_privacy: The user's setting for sharing her personal - profile. Possible values are "all_users", "private", or "custom". - If "custom", the user has selectively chosen a subset of shareable - fields to make visible to others via the User Preferences API. - - * phone_number: The phone number for the user. String of numbers with - an optional `+` sign at the start. - - * pending_name_change: If the user has an active name change request, returns the - requested name. - - For all text fields, plain text instead of HTML is supported. The - data is stored exactly as specified. Clients must HTML escape - rendered values to avoid script injections. - - If a user who does not have "is_staff" access requests account - information for a different user, only a subset of these fields is - returned. The returned fields depend on the - ACCOUNT_VISIBILITY_CONFIGURATION configuration setting and the - visibility preference of the user for whom data is requested. - - Note that a user can view which account fields they have shared - with other users by requesting their own username and providing - the "view=shared" URL parameter. - - **Response Values for PATCH** - - Users can only modify their own account information. If the - requesting user does not have the specified username and has staff - access, the request returns an HTTP 403 "Forbidden" response. If - the requesting user does not have staff access, the request - returns an HTTP 404 "Not Found" response to avoid revealing the - existence of the account. - - If no user exists with the specified username, an HTTP 404 "Not - Found" response is returned. - - If "application/merge-patch+json" is not the specified content - type, a 415 "Unsupported Media Type" response is returned. - - If validation errors prevent the update, this method returns a 400 - "Bad Request" response that includes a "field_errors" field that - lists all error messages. - - If a failure at the time of the update prevents the update, a 400 - "Bad Request" error is returned. The JSON collection contains - specific errors. - - If the update is successful, updated user account data is returned. - """ + """View or update a user's account information.""" authentication_classes = ( JwtAuthentication, @@ -298,18 +150,41 @@ class AccountViewSet(ViewSet): JSONParser, MergePatchParser, ) + account_user_get_responses = { + status.HTTP_200_OK: account_get_me_return_schema, + status.HTTP_401_UNAUTHORIZED: "", + } + @swagger_auto_schema( + responses=account_user_get_responses, + ) def get(self, request): - """ - GET /api/user/v1/me + """Return an authenticated user's username + + **Example Requests** + + GET /api/user/v1/me[?view=shared] """ return Response({"username": request.user.username}) def list(self, request): - """ - GET /api/user/v1/accounts?username={username1,username2} - GET /api/user/v1/accounts?email={user_email} (Staff Only) - GET /api/user/v1/accounts?lms_user_id={lms_user_id} (Staff Only) + """Return a list of user details objects + + **Example Requests** + + GET /api/user/v1/accounts?usernames={username1,username2}[?view=shared] + + GET /api/user/v1/accounts?email={user_email} (staff only) + + GET /api/user/v1/accounts?lms_user_id={user_email} (staff only) + + **Responses** + + If no user exists with the specified username, or email, an HTTP 404 "Not Found" response is returned. + + If the user makes the request for her own account, or makes a request for another account and has "is_staff" access, an HTTP 200 "OK" response is returned. + + The response consists of a list of one or more user objects, in the same format as is returned for `GET /user/v1/accounts/{username}`. """ usernames = request.GET.get("username") user_email = request.GET.get("email") @@ -362,25 +237,34 @@ class AccountViewSet(ViewSet): return Response(account_settings) def search_emails(self, request): - """ + """Return information about users associated with a list of email addresses + + **Example Requests** + POST /api/user/v1/accounts/search_emails - Content Type: "application/json" - { - "emails": ["edx@example.com", "staff@example.com"] - } - Response: - [ + { - "username": "edx", - "email": "edx@example.com", - "id": 3, - }, - { - "username": "staff", - "email": "staff@example.com", - "id": 8, + "emails": ["edx@example.com", "staff@example.com"] } - ] + + **Response** + + If no `emails` key is present in the request, or the user does not have "is_staff" access, an HTTP 404 "Not Found" response is returned. + + If the has "is_staff" access, an HTTP 200 "OK" response is returned. The response contains the following values. + + [ + { + "username": "edx", + "email": "edx@example.com", + "id": 3, + }, + { + "username": "staff", + "email": "staff@example.com", + "id": 8, + } + ] """ if not request.user.is_staff: return Response( @@ -399,8 +283,69 @@ class AccountViewSet(ViewSet): return Response(data) def retrieve(self, request, username): - """ - GET /api/user/v1/accounts/{username}/ + """Retrieve a single detailed user object + + **Example Requests** + + GET /api/user/v1/accounts/{username}/ + + **Response** + + If no user exists with the specified username, or email, an HTTP 404 "Not Found" response is returned. + + If the user makes the request for her own account, or makes a request for another account and has "is_staff" access, an HTTP 200 "OK" response is returned. The response contains the following values. + + * `id`: numerical lms user id in db + * `activation_key`: auto-genrated activation key when signed up via email + * `bio`: null or textual representation of user biographical information ("about me"). + * `country`: An ISO 3166 country code or null. + * `date_joined`: The date the account was created, in the string format provided by datetime. For example, "2014-08-26T17:52:11Z". + * `last_login`: The latest date the user logged in, in the string datetime format. + * `email`: Email address for the user. New email addresses must be confirmed via a confirmation email, so GET does not reflect the change until the address has been confirmed. + * `secondary_email`: A secondary email address for the user. Unlike the email field, GET will reflect the latest update to this field even if changes have yet to be confirmed. + * `verified_name`: Approved verified name of the learner present in name affirmation plugin + * `extended_profile`: A list of objects with the keys `field_name` and `field_value`, returning any populated `extended_profile_fields` configured in the **Site Configuration** + * `gender`: One of the following values: + * null + * "f" + * "m" + * "o" + * `goals`: The textual representation of the user's goals, or null. + * `is_active`: Boolean representation of whether a user is active. + * `language`: The user's preferred language, or null. + * `language_proficiencies`: Array of language preferences. Each preference is a JSON object with the following keys: + * "code": string ISO 639-1 language code e.g. "en". + * `level_of_education`: One of the following values: + * "p": PhD or Doctorate + * "m": Master's or professional degree + * "b": Bachelor's degree + * "a": Associate's degree + * "hs": Secondary/high school + * "jhs": Junior secondary/junior high/middle school + * "el": Elementary/primary school + * "none": None + * "o": Other + * null: The user did not enter a value + * `mailing_address`: The textual representation of the user's mailing address, or null. + * `name`: The full name of the user. + * `profile_image`: A JSON representation of a user's profile image information. This representation has the following keys. + * "has_image": Boolean indicating whether the user has a profile image. + * "image_url_*": Absolute URL to various sizes of a user's profile image, where '*' matches a representation of the corresponding image size, such as 'small', 'medium', 'large', and 'full'. These are configurable via PROFILE_IMAGE_SIZES_MAP. + * `requires_parental_consent`: True if the user is a minor requiring parental consent. + * `social_links`: Array of social links, sorted alphabetically by "platform". Each preference is a JSON object with the following keys: + * "platform": A particular social platform, ex: 'facebook' + * "social_link": The link to the user's profile on the particular platform + * `username`: The username associated with the account. + * `year_of_birth`: The year the user was born, as an integer, or null. + * `account_privacy`: The user's setting for sharing her personal profile. Possible values are "all_users", "private", or "custom". If "custom", the user has selectively chosen a subset of shareable fields to make visible to others via the User Preferences API. + * `phone_number`: The phone number for the user. String of numbers with an optional `+` sign at the start. + * `pending_name_change`: If the user has an active name change request, returns the requested name. + + For all text fields, plain text instead of HTML is supported. The data is stored exactly as specified. Clients must HTML escape rendered values to avoid script injections. + + If a user who does not have "is_staff" access requests account information for a different user, only a subset of these fields is returned. The returned fields depend on the `ACCOUNT_VISIBILITY_CONFIGURATION` configuration setting and the visibility preference of the user for whom data is requested. + + A user can view which account fields they have shared with other users by requesting their own username and providing the "view=shared" URL parameter. """ try: account_settings = get_account_settings(request, [username], view=request.query_params.get("view")) @@ -410,12 +355,43 @@ class AccountViewSet(ViewSet): return Response(account_settings[0]) def partial_update(self, request, username): - """ - PATCH /api/user/v1/accounts/{username}/ + """Update user account or profile information - Note that this implementation is the "merge patch" implementation proposed in - https://tools.ietf.org/html/rfc7396. The content_type must be "application/merge-patch+json" or - else an error response with status code 415 will be returned. + **Example Requests** + + + Content-Type: application/merge-patch+json + + PATCH /api/user/v1/accounts/{username} + + **Request Body + + { + "level_of_education": "m", + "extended_profile": + [ + {"field_name": "favorite_beatle", "field_value": {"name": "ringo"}}, + {"field_name": "conlangs_spoken", "field_value":["Láadan", "Rikchik", "Lojban"]} + ] + } + + **Notes regarding `social_links`** + + Requested updates to social_links are automatically merged with previously set links. That is, any newly introduced platforms are add to the previous list. Updated links to pre-existing platforms replace their values in the previous list. Pre-existing platforms can be removed by setting the value of the social_link to an empty string (""). + + **Response Values for PATCH** + + Users can only modify their own account information. If the requesting user does not have the specified username and has staff access, the request returns an HTTP 403 "Forbidden" response. If the requesting user does not have staff access, the request returns an HTTP 404 "Not Found" response to avoid revealing the existence of the account. + + If no user exists with the specified username, an HTTP 404 "Not Found" response is returned. + + If "application/merge-patch+json" is not the specified content type, a 415 "Unsupported Media Type" response is returned. + + If validation errors prevent the update, this method returns a 400 "Bad Request" response that includes a "field_errors" field that lists all error messages. This will happen if an attempt is made to edit any read-only fields. + + If a failure at the time of the update prevents the update, a 400 "Bad Request" error is returned. The JSON collection contains specific errors. + + If the update is successful, updated user account data is returned. """ if request.content_type != MergePatchParser.media_type: raise UnsupportedMediaType(request.content_type) @@ -439,6 +415,9 @@ class AccountViewSet(ViewSet): return Response(account_settings) +# pylint: enable=line-too-long + + class NameChangeView(ViewSet): """ Viewset to manage profile name change requests. @@ -510,7 +489,9 @@ class AccountDeactivationView(APIView): Marks the user as having no password set for deactivation purposes. """ - _set_unusable_password(User.objects.get(username=username)) + user = User.objects.get(username=username) + segment.identify(user.id, {"is_disabled": "true"}) + _set_unusable_password(user) return Response(get_account_settings(request, [username])[0]) diff --git a/openedx/core/djangoapps/user_api/legacy_urls.py b/openedx/core/djangoapps/user_api/legacy_urls.py index b3f707f64b..3c8da9bd83 100644 --- a/openedx/core/djangoapps/user_api/legacy_urls.py +++ b/openedx/core/djangoapps/user_api/legacy_urls.py @@ -1,11 +1,12 @@ """ Defines the URL routes for this app. """ +from django.conf import settings from django.urls import path, re_path, include +from django.views.generic import RedirectView from rest_framework import routers from . import views as user_api_views -from .accounts.settings_views import account_settings from .models import UserPreference USER_API_ROUTER = routers.DefaultRouter() @@ -13,7 +14,10 @@ USER_API_ROUTER.register(r'users', user_api_views.UserViewSet) USER_API_ROUTER.register(r'user_prefs', user_api_views.UserPreferenceViewSet) urlpatterns = [ - path('account/settings', account_settings, name='account_settings'), + # This redirect is needed for backward compatibility with the old URL structure for the authentication + # workflows using third-party authentication providers until the authentication workflows fully support + # the URL structure with MFEs. + re_path(r'^account(?:/settings)?/?$', RedirectView.as_view(url=settings.ACCOUNT_MICROFRONTEND_URL)), path('user_api/v1/', include(USER_API_ROUTER.urls)), re_path( fr'^user_api/v1/preferences/(?P{UserPreference.KEY_REGEX})/users/$', 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 e465ff5610..d194e58ee8 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 @@ class Command(BaseCommand): 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_api/models.py b/openedx/core/djangoapps/user_api/models.py index d776dac8fe..2086ebecf3 100644 --- a/openedx/core/djangoapps/user_api/models.py +++ b/openedx/core/djangoapps/user_api/models.py @@ -105,6 +105,13 @@ class UserPreference(models.Model): """ return cls.objects.filter(user=user, key=preference_key).exists() + @classmethod + def get_preference_for_users(cls, user_ids, preference_key): + """ + Returns preference for list of users + """ + return cls.objects.filter(user__in=user_ids, key=preference_key) + @receiver(pre_save, sender=UserPreference) def pre_save_callback(sender, **kwargs): diff --git a/openedx/core/djangoapps/user_api/tests/test_middleware.py b/openedx/core/djangoapps/user_api/tests/test_middleware.py index 836e6d9465..04b5b41049 100644 --- a/openedx/core/djangoapps/user_api/tests/test_middleware.py +++ b/openedx/core/djangoapps/user_api/tests/test_middleware.py @@ -5,13 +5,18 @@ from unittest.mock import Mock, patch from django.http import HttpResponse from django.test import TestCase from django.test.client import RequestFactory +from django.urls import reverse from common.djangoapps.student.tests.factories import AnonymousUserFactory, UserFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms from ..middleware import UserTagsEventContextMiddleware from ..tests.factories import UserCourseTagFactory +# This middleware only gets installed in the LMS so no need to test +# it in the CMS context. +@skip_unless_lms class TagsMiddlewareTest(TestCase): """ Test the UserTagsEventContextMiddleware @@ -25,9 +30,7 @@ class TagsMiddlewareTest(TestCase): self.course_id = 'mock/course/id' self.request_factory = RequestFactory() - # TODO: Make it so we can use reverse. Appears to fail depending on the order in which tests are run - #self.request = RequestFactory().get(reverse('courseware', kwargs={'course_id': self.course_id})) - self.request = RequestFactory().get(f'/courses/{self.course_id}/courseware') + self.request = RequestFactory().get(reverse('progress', kwargs={'course_id': self.course_id})) self.request.user = self.user self.response = Mock(spec=HttpResponse) diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index 75740cf5d2..446ade442c 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -5,7 +5,7 @@ import ddt from django.test.utils import override_settings from django.urls import reverse from opaque_keys.edx.keys import CourseKey -from pytz import common_timezones_set +from pytz import common_timezones_set, common_timezones, country_timezones from openedx.core.djangoapps.django_comment_common import models from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms @@ -634,13 +634,16 @@ class CountryTimeZoneListViewTest(UserApiTestCase): assert time_zone_name in common_timezones_set assert time_zone_info['description'] == get_display_time_zone(time_zone_name) - # The time zones count may need to change each time we upgrade pytz - @ddt.data((ALL_TIME_ZONES_URI, 432), - (COUNTRY_TIME_ZONES_URI, 23)) - @ddt.unpack - def test_get_basic(self, country_uri, expected_count): + def test_get_country_timezones(self): """ Verify that correct time zone info is returned """ - results = self.get_json(country_uri) - assert len(results) == expected_count + results = self.get_json(self.COUNTRY_TIME_ZONES_URI) + assert len(results) == len(country_timezones['cA']) + for time_zone_info in results: + self._assert_time_zone_is_valid(time_zone_info) + + def test_get_all_common_timezones(self): + """ Verify that correct time zone info is returned """ + results = self.get_json(self.ALL_TIME_ZONES_URI) + assert len(results) == len(common_timezones) for time_zone_info in results: self._assert_time_zone_is_valid(time_zone_info) diff --git a/openedx/core/djangoapps/user_authn/cookies.py b/openedx/core/djangoapps/user_authn/cookies.py index 24f929698f..036baf2125 100644 --- a/openedx/core/djangoapps/user_authn/cookies.py +++ b/openedx/core/djangoapps/user_authn/cookies.py @@ -6,6 +6,7 @@ Utility functions for setting "logged in" cookies used by subdomains. import json import logging import time +from urllib.parse import urljoin from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user @@ -244,8 +245,8 @@ def _get_user_info_cookie_data(request, user): # External sites will need to have fallback mechanisms to handle this case # (most likely just hiding the links). try: - header_urls['account_settings'] = reverse('account_settings') - header_urls['learner_profile'] = reverse('learner_profile', kwargs={'username': user.username}) + header_urls['account_settings'] = settings.ACCOUNT_MICROFRONTEND_URL + header_urls['learner_profile'] = urljoin(settings.PROFILE_MICROFRONTEND_URL, f'/u/{user.username}') except NoReverseMatch: pass diff --git a/openedx/core/djangoapps/user_authn/message_types.py b/openedx/core/djangoapps/user_authn/message_types.py index 83391374a1..51af68e00c 100644 --- a/openedx/core/djangoapps/user_authn/message_types.py +++ b/openedx/core/djangoapps/user_authn/message_types.py @@ -14,6 +14,7 @@ class PasswordReset(BaseMessageType): # pylint: disable=unsupported-assignment-operation self.options['transactional'] = True + self.options['skip_disable_user_policy'] = True class PasswordResetSuccess(BaseMessageType): diff --git a/openedx/core/djangoapps/user_authn/tests/test_cookies.py b/openedx/core/djangoapps/user_authn/tests/test_cookies.py index 8a7841b3b9..aa2e687102 100644 --- a/openedx/core/djangoapps/user_authn/tests/test_cookies.py +++ b/openedx/core/djangoapps/user_authn/tests/test_cookies.py @@ -1,9 +1,11 @@ # pylint: disable=missing-docstring -from datetime import date +from datetime import date, datetime import json +from pytz import UTC from unittest.mock import MagicMock, patch +from urllib.parse import urljoin from django.conf import settings from django.http import HttpResponse from django.test import RequestFactory, TestCase @@ -19,6 +21,10 @@ from common.djangoapps.student.tests.factories import AnonymousUserFactory, User from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file from openedx.core.djangoapps.profile_images.images import create_profile_images from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names +from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user + + +TEST_PROFILE_IMAGE_UPLOAD_DT = datetime(2002, 1, 9, 15, 43, 1, tzinfo=UTC) class CookieTests(TestCase): @@ -26,6 +32,8 @@ class CookieTests(TestCase): super().setUp() self.user = UserFactory.create() self.user.profile = UserProfileFactory.create(user=self.user) + self.user.profile.profile_image_uploaded_at = TEST_PROFILE_IMAGE_UPLOAD_DT + self.user.profile.save() # lint-amnesty, pylint: disable=no-member self.request = RequestFactory().get('/') self.request.user = self.user self.request.session = self._get_stub_session() @@ -42,23 +50,11 @@ class CookieTests(TestCase): return urls_obj - def _get_expected_image_urls(self): - expected_image_urls = { - 'full': '/static/default_500.png', - 'large': '/static/default_120.png', - 'medium': '/static/default_50.png', - 'small': '/static/default_30.png' - } - - expected_image_urls = self._convert_to_absolute_uris(self.request, expected_image_urls) - - return expected_image_urls - def _get_expected_header_urls(self): expected_header_urls = { 'logout': reverse('logout'), - 'account_settings': reverse('account_settings'), - 'learner_profile': reverse('learner_profile', kwargs={'username': self.user.username}), + 'account_settings': settings.ACCOUNT_MICROFRONTEND_URL, + 'learner_profile': urljoin(settings.PROFILE_MICROFRONTEND_URL, f'/u/{self.user.username}'), } block_url = retrieve_last_sitewide_block_completed(self.user) if block_url: @@ -111,7 +107,7 @@ class CookieTests(TestCase): 'username': self.user.username, 'email': self.user.email, 'header_urls': self._get_expected_header_urls(), - 'user_image_urls': self._get_expected_image_urls(), + 'user_image_urls': get_profile_image_urls_for_user(self.user), } self.assertDictEqual(actual, expected) diff --git a/openedx/core/djangoapps/user_authn/tests/test_utils.py b/openedx/core/djangoapps/user_authn/tests/test_utils.py index 72eaaf8265..34578a661d 100644 --- a/openedx/core/djangoapps/user_authn/tests/test_utils.py +++ b/openedx/core/djangoapps/user_authn/tests/test_utils.py @@ -9,7 +9,11 @@ from django.test.client import RequestFactory from django.test.utils import override_settings from openedx.core.djangoapps.oauth_dispatch.tests.factories import ApplicationFactory -from openedx.core.djangoapps.user_authn.utils import is_safe_login_or_logout_redirect +from openedx.core.djangoapps.user_authn.utils import ( + is_safe_login_or_logout_redirect, + generate_username_suggestions, + remove_special_characters_from_name, +) @ddt.ddt @@ -68,3 +72,73 @@ class TestRedirectUtils(TestCase): req = self.request.get(f'/logout?{urlencode(params)}', HTTP_HOST=host) actual_is_safe = self._is_safe_redirect(req, redirect_url) assert actual_is_safe == expected_is_safe + + +@ddt.ddt +class TestUsernameGeneration(TestCase): + """Test username generation utility methods.""" + + def test_remove_special_characters(self): + """Test the removal of special characters from a name.""" + test_cases = [ + ('John Doe', 'JohnDoe'), + ('John@Doe', 'JohnDoe'), + ('John.Doe', 'JohnDoe'), + ('John_Doe', 'John_Doe'), # Underscore is allowed + ('John-Doe', 'John-Doe'), # Hyphen is allowed + ('John$#@!Doe', 'JohnDoe'), + ] + for input_name, expected in test_cases: + assert remove_special_characters_from_name(input_name) == expected + + @ddt.data( + # Test normal ASCII name + ('John Doe', True), # Should return suggestions + ('Jane Smith', True), # Should return suggestions + # Test non-ASCII names + ('José García', False), # Contains non-ASCII characters + ('مریم میرزاخانی', False), # Persian name + ('明美 田中', False), # Japanese name + ('Σωκράτης', False), # Greek name + ('Владимир', False), # Cyrillic characters + # Test edge cases + ('A B', True), # Minimal valid name + ('', True), # Empty string + (' ', True), # Just spaces + ) + @ddt.unpack + def test_username_suggestions_ascii_check(self, name, should_generate): + """Test username suggestion generation for ASCII and non-ASCII names.""" + suggestions = generate_username_suggestions(name) + + if should_generate: + if name.strip(): # If name is not empty or just spaces + # Should generate up to 3 suggestions for valid ASCII names + assert len(suggestions) <= 3 + # Verify all suggestions are ASCII + for suggestion in suggestions: + assert suggestion.isascii() + assert suggestion.replace('_', '').replace('-', '').isalnum() + else: + # Empty or whitespace-only names should return empty list + assert not suggestions + else: + # Should return empty list for non-ASCII names + assert not suggestions + + def test_unique_suggestions(self): + """Test that generated suggestions are unique.""" + name = "John Doe" + suggestions = generate_username_suggestions(name) + assert len(suggestions) == len(set(suggestions)), "All suggestions should be unique" + + def test_suggestion_length(self): + """Test that generated suggestions respect the maximum length.""" + from openedx.core.djangoapps.user_api.accounts import USERNAME_MAX_LENGTH + + # Test with a very long name + long_name = "John" * 50 + suggestions = generate_username_suggestions(long_name) + + for suggestion in suggestions: + assert len(suggestion) <= USERNAME_MAX_LENGTH diff --git a/openedx/core/djangoapps/user_authn/utils.py b/openedx/core/djangoapps/user_authn/utils.py index 07eb5b491a..1ed91efd71 100644 --- a/openedx/core/djangoapps/user_authn/utils.py +++ b/openedx/core/djangoapps/user_authn/utils.py @@ -72,12 +72,35 @@ def is_registration_api_v1(request): return 'v1' in request.get_full_path() and 'register' not in request.get_full_path() -def remove_special_characters_from_name(name): +def remove_special_characters_from_name(name: str) -> str: return "".join(re.findall(r"[\w-]+", name)) -def generate_username_suggestions(name): - """ Generate 3 available username suggestions """ +def generate_username_suggestions(name: str) -> list[str]: + """ + Generate 3 available username suggestions based on the provided name. + + Args: + name (str): The full name to generate username suggestions from. + Must contain only ASCII characters. + + Returns: + list[str]: A list of up to 3 available username suggestions, + or an empty list if name contains non-ASCII characters or if no valid + suggestions could be generated. + + Note: + Generated usernames will be combinations of: + - firstname + lastname + - first initial + lastname + - firstname + random number + """ + # Check if name contains non-ASCII characters + try: + name.encode('ascii') + except UnicodeEncodeError: + return [] + username_suggestions = [] max_length = USERNAME_MAX_LENGTH names = name.split(' ') diff --git a/openedx/core/djangoapps/user_authn/views/login.py b/openedx/core/djangoapps/user_authn/views/login.py index 6c7390406b..0dd9a4819d 100644 --- a/openedx/core/djangoapps/user_authn/views/login.py +++ b/openedx/core/djangoapps/user_authn/views/login.py @@ -195,7 +195,7 @@ def _enforce_password_policy_compliance(request, user): # lint-amnesty, pylint: # Allow login, but warn the user that they will be required to reset their password soon. PageLevelMessages.register_warning_message(request, HTML(str(e))) except password_policy_compliance.NonCompliantPasswordException as e: - # Increment the lockout counter to safguard from further brute force requests + # Increment the lockout counter to safeguard from further brute force requests # if user's password has been compromised. if LoginFailures.is_feature_enabled(): LoginFailures.increment_lockout_counter(user) @@ -329,6 +329,7 @@ def _handle_successful_authentication_and_login(user, request): log.debug("Setting user session expiry to 4 weeks") # .. event_implemented_name: SESSION_LOGIN_COMPLETED + # .. event_type: org.openedx.learning.auth.session.login.completed.v1 SESSION_LOGIN_COMPLETED.send_event( user=UserData( pii=UserPersonalData( @@ -354,6 +355,11 @@ def _track_user_login(user, request): # .. pii: Username and email are sent to Segment here. Retired directly through Segment API call in Tubular. # .. pii_types: email_address, username # .. pii_retirement: third_party + anonymous_id = "" + try: + anonymous_id = request.COOKIES.get('ajs_anonymous_id', "") + except: # pylint: disable=bare-except + pass segment.identify( user.id, {"email": user.email, "username": user.username}, @@ -367,7 +373,12 @@ def _track_user_login(user, request): segment.track( user.id, "edx.bi.user.account.authenticated", - {"category": "conversion", "label": request.POST.get("course_id"), "provider": None}, + { + "category": "conversion", + "label": request.POST.get("course_id"), + "provider": None, + "anonymous_id": anonymous_id, + }, ) @@ -410,7 +421,7 @@ def _check_user_auth_flow(site, user): # we don't record their e-mail in case there is sensitive info accidentally # in there. set_custom_attribute("login_tpa_domain_shortcircuit_user_id", user.id) - log.warning("User %s has nonstandard e-mail. Shortcircuiting THIRD_PART_AUTH_ONLY_DOMAIN check.", user.id) + log.warning("User %s has nonstandard e-mail. Shortcircuiting THIRD_PARTY_AUTH_ONLY_DOMAIN check.", user.id) return user_domain = email_parts[1].strip().lower() @@ -584,6 +595,8 @@ def login_user(request, api_version="v1"): # pylint: disable=too-many-statement possibly_authenticated_user = user try: + # .. filter_implemented_name: StudentLoginRequested + # .. filter_type: org.openedx.learning.student.login.requested.v1 possibly_authenticated_user = StudentLoginRequested.run_filter(user=possibly_authenticated_user) except StudentLoginRequested.PreventLogin as exc: raise AuthFailedError( diff --git a/openedx/core/djangoapps/user_authn/views/register.py b/openedx/core/djangoapps/user_authn/views/register.py index ab57687d2c..e959420577 100644 --- a/openedx/core/djangoapps/user_authn/views/register.py +++ b/openedx/core/djangoapps/user_authn/views/register.py @@ -231,7 +231,7 @@ def create_account_with_params(request, params): # pylint: disable=too-many-sta log.exception('Error while setting is_marketable attribute.') is_marketable = None - _track_user_registration(user, profile, params, third_party_provider, registration, is_marketable) + _track_user_registration(user, profile, params, third_party_provider, registration, is_marketable, request=request) # Sites using multiple languages need to record the language used during registration. # If not, compose_and_send_activation_email will be sent in site's default language only. @@ -263,6 +263,7 @@ def create_account_with_params(request, params): # pylint: disable=too-many-sta REGISTER_USER.send(sender=None, user=user, registration=registration) # .. event_implemented_name: STUDENT_REGISTRATION_COMPLETED + # .. event_type: org.openedx.learning.student.registration.completed.v1 STUDENT_REGISTRATION_COMPLETED.send_event( user=UserData( pii=UserPersonalData( @@ -356,9 +357,14 @@ def _link_user_to_third_party_provider( return third_party_provider, running_pipeline -def _track_user_registration(user, profile, params, third_party_provider, registration, is_marketable): +def _track_user_registration(user, profile, params, third_party_provider, registration, is_marketable, request=None): """ Track the user's registration. """ if hasattr(settings, 'LMS_SEGMENT_KEY') and settings.LMS_SEGMENT_KEY: + anonymous_id = "" + try: + anonymous_id = request.COOKIES.get('ajs_anonymous_id', "") + except: # pylint: disable=bare-except + pass traits = { 'email': user.email, 'username': user.username, @@ -370,7 +376,8 @@ def _track_user_registration(user, profile, params, third_party_provider, regist 'address': profile.mailing_address, 'gender': profile.gender_display, 'country': str(profile.country), - 'is_marketable': is_marketable + 'is_marketable': is_marketable, + 'anonymous_id': anonymous_id } if settings.MARKETING_EMAILS_OPT_IN and params.get('marketing_emails_opt_in'): email_subscribe = 'subscribed' if is_marketable else 'unsubscribed' @@ -397,6 +404,7 @@ def _track_user_registration(user, profile, params, third_party_provider, regist 'host': params.get('host', ''), 'app_name': params.get('app_name', ''), 'utm_campaign': params.get('utm_campaign', ''), + 'anonymous_id': anonymous_id } # VAN-738 - added below properties to experiment marketing emails opt in/out events on Braze. if params.get('marketing_emails_opt_in') and settings.MARKETING_EMAILS_OPT_IN: @@ -585,6 +593,8 @@ class RegistrationView(APIView): data['username'] = get_auto_generated_username(data) try: + # .. filter_implemented_name: StudentRegistrationRequested + # .. filter_type: org.openedx.learning.student.registration.requested.v1 data = StudentRegistrationRequested.run_filter(form_data=data) except StudentRegistrationRequested.PreventRegistration as exc: errors = { diff --git a/openedx/core/djangoapps/user_authn/views/registration_form.py b/openedx/core/djangoapps/user_authn/views/registration_form.py index 978d303c9a..efee92e700 100644 --- a/openedx/core/djangoapps/user_authn/views/registration_form.py +++ b/openedx/core/djangoapps/user_authn/views/registration_form.py @@ -3,9 +3,8 @@ Objects and utilities used to construct registration forms. """ import copy -from importlib import import_module -from eventtracking import tracker import re +from importlib import import_module from django import forms from django.conf import settings @@ -16,26 +15,25 @@ from django.forms import widgets from django.urls import reverse from django.utils.translation import gettext as _ from django_countries import countries +from eventtracking import tracker from common.djangoapps import third_party_auth from common.djangoapps.edxmako.shortcuts import marketing_link -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.user_api import accounts -from openedx.core.djangoapps.user_api.helpers import FormDescription -from openedx.core.djangoapps.user_authn.utils import check_pwned_password, is_registration_api_v1 as is_api_v1 -from openedx.core.djangoapps.user_authn.views.utils import remove_disabled_country_from_list -from openedx.core.djangolib.markup import HTML, Text -from openedx.features.enterprise_support.api import enterprise_customer_for_request -from common.djangoapps.student.models import ( - CourseEnrollmentAllowed, - UserProfile, - email_exists_or_retired, -) +from common.djangoapps.student.models import CourseEnrollmentAllowed, UserProfile, email_exists_or_retired from common.djangoapps.util.password_policy_validators import ( password_validators_instruction_texts, password_validators_restrictions, - validate_password, + validate_password ) +from openedx.core.djangoapps.embargo.models import GlobalRestrictedCountry +from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +from openedx.core.djangoapps.user_api import accounts +from openedx.core.djangoapps.user_api.helpers import FormDescription +from openedx.core.djangoapps.user_authn.utils import check_pwned_password +from openedx.core.djangoapps.user_authn.utils import is_registration_api_v1 as is_api_v1 +from openedx.core.djangoapps.user_authn.views.utils import remove_disabled_country_from_list +from openedx.core.djangolib.markup import HTML, Text +from openedx.features.enterprise_support.api import enterprise_customer_for_request class TrueCheckbox(widgets.CheckboxInput): @@ -306,7 +304,10 @@ class AccountCreationForm(forms.Form): Check if the user's country is in the embargoed countries list. """ country = self.cleaned_data.get("country") - if country in settings.DISABLED_COUNTRIES: + if ( + settings.FEATURES.get('EMBARGO', False) and + country in GlobalRestrictedCountry.get_countries() + ): raise ValidationError(_("Registration from this country is not allowed due to restrictions.")) return self.cleaned_data.get("country") @@ -981,7 +982,6 @@ class RegistrationFormFactory: 'country', default=default_country.upper() ) - form_desc.add_field( "country", label=country_label, diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_login.py b/openedx/core/djangoapps/user_authn/views/tests/test_login.py index 1e8a4c3ed5..7509bbff95 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_login.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_login.py @@ -496,7 +496,7 @@ class LoginTest(SiteMixin, CacheIsolationTestCase, OpenEdxEventsTestMixin): # Check that the URLs are absolute for url in user_info["header_urls"].values(): - assert 'http://testserver/' in url + assert 'http://' in url def test_logout_deletes_mktg_cookies(self): response, _ = self._login_response(self.user_email, self.password) @@ -1027,8 +1027,7 @@ class LoginTest(SiteMixin, CacheIsolationTestCase, OpenEdxEventsTestMixin): with self.assertLogs(level='WARN') as log: _check_user_auth_flow(site, invalid_email_user) - assert len(log.output) == 1 - assert "Shortcircuiting THIRD_PART_AUTH_ONLY_DOMAIN check." in log.output[0] + assert any("Shortcircuiting THIRD_PARTY_AUTH_ONLY_DOMAIN check." in warning for warning in log.output) @ddt.ddt @@ -1145,7 +1144,7 @@ class LoginSessionViewTest(ApiTestCase, OpenEdxEventsTestMixin): mock_segment.track.assert_called_once_with( expected_user_id, 'edx.bi.user.account.authenticated', - {'category': 'conversion', 'provider': None, 'label': track_label} + {'category': 'conversion', 'provider': None, 'label': track_label, 'anonymous_id': ''} ) def test_login_with_username(self): diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py index 77b5d074fb..136adc01f7 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -2,8 +2,8 @@ import json from datetime import datetime -from unittest import skipIf, skipUnless -from unittest import mock +from unittest import mock, skipIf, skipUnless +from unittest.mock import patch import ddt import httpretty @@ -15,11 +15,26 @@ from django.test import TransactionTestCase from django.test.client import RequestFactory from django.test.utils import override_settings from django.urls import reverse +from openedx_events.tests.utils import OpenEdxEventsTestMixin from pytz import UTC from social_django.models import Partial, UserSocialAuth from testfixtures import LogCapture -from openedx_events.tests.utils import OpenEdxEventsTestMixin +from common.djangoapps.student.helpers import authenticate_new_user +from common.djangoapps.student.tests.factories import AccountRecoveryFactory, UserFactory +from common.djangoapps.third_party_auth.tests.testutil import ThirdPartyAuthTestMixin, simulate_running_pipeline +from common.djangoapps.third_party_auth.tests.utils import ( + ThirdPartyOAuthTestMixin, + ThirdPartyOAuthTestMixinFacebook, + ThirdPartyOAuthTestMixinGoogle +) +from common.djangoapps.util.password_policy_validators import ( + DEFAULT_MAX_PASSWORD_LENGTH, + create_validator_config, + password_validators_instruction_texts, + password_validators_restrictions +) +from openedx.core.djangoapps.embargo.models import Country, GlobalRestrictedCountry from openedx.core.djangoapps.site_configuration.helpers import get_value from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration from openedx.core.djangoapps.user_api.accounts import ( @@ -51,20 +66,6 @@ from openedx.core.djangoapps.user_api.tests.test_helpers import TestCaseForm from openedx.core.djangoapps.user_api.tests.test_views import UserAPITestCase from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms from openedx.core.lib.api import test_utils -from common.djangoapps.student.helpers import authenticate_new_user -from common.djangoapps.student.tests.factories import AccountRecoveryFactory, UserFactory -from common.djangoapps.third_party_auth.tests.testutil import ThirdPartyAuthTestMixin, simulate_running_pipeline -from common.djangoapps.third_party_auth.tests.utils import ( - ThirdPartyOAuthTestMixin, - ThirdPartyOAuthTestMixinFacebook, - ThirdPartyOAuthTestMixinGoogle -) -from common.djangoapps.util.password_policy_validators import ( - DEFAULT_MAX_PASSWORD_LENGTH, - create_validator_config, - password_validators_instruction_texts, - password_validators_restrictions -) ENABLE_AUTO_GENERATED_USERNAME = settings.FEATURES.copy() ENABLE_AUTO_GENERATED_USERNAME['ENABLE_AUTO_GENERATED_USERNAME'] = True @@ -1856,38 +1857,6 @@ class RegistrationViewTestV1( response = self.client.post(self.url, {"email": self.EMAIL, "username": self.USERNAME}) assert response.status_code == 403 - @override_settings( - CACHES={ - 'default': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': 'registration_proxy', - } - } - ) - def test_rate_limiting_registration_view(self): - """ - Confirm rate limits work as expected for registration - end point. - Note that drf's rate limiting makes use of the default cache - to enforce limits; that's why this test needs a "real" - default cache (as opposed to the usual-for-tests DummyCache) - """ - payload = { - "email": 'email', - "name": self.NAME, - "username": self.USERNAME, - "password": self.PASSWORD, - "honor_code": "true", - } - - for _ in range(int(settings.REGISTRATION_RATELIMIT.split('/')[0])): - response = self.client.post(self.url, payload) - assert response.status_code != 403 - - response = self.client.post(self.url, payload) - assert response.status_code == 403 - cache.clear() - @override_settings(FEATURES=ENABLE_AUTO_GENERATED_USERNAME) def test_register_with_auto_generated_username(self): """ @@ -2493,11 +2462,13 @@ class RegistrationViewTestV2(RegistrationViewTestV1): }) assert response.status_code == 400 - @override_settings(DISABLED_COUNTRIES=['KP']) + @patch.dict(settings.FEATURES, {'EMBARGO': True}) def test_register_with_disabled_country(self): """ Test case to check user registration is forbidden when registration is disabled for a country """ + country = Country.objects.create(country="KP") + GlobalRestrictedCountry.objects.create(country=country) response = self.client.post(self.url, { "email": self.EMAIL, "name": self.NAME, @@ -2518,6 +2489,26 @@ class RegistrationViewTestV2(RegistrationViewTestV1): ], 'error_code': 'validation-error'} ) + @patch.dict(settings.FEATURES, {'EMBARGO': False}) + def test_registration_allowed_when_embargo_disabled(self): + """ + Ensures that user registration proceeds normally even for restricted countries + when the EMBARGO feature flag is disabled. + """ + country = Country.objects.create(country="KP") + GlobalRestrictedCountry.objects.create(country=country) + + response = self.client.post(self.url, { + "email": self.EMAIL, + "name": self.NAME, + "username": self.USERNAME, + "password": self.PASSWORD, + "honor_code": "true", + "country": "KP", + }) + + self.assertEqual(response.status_code, 200) + @httpretty.activate @ddt.ddt @@ -2625,13 +2616,15 @@ class ThirdPartyRegistrationTestMixin( self._verify_user_existence(user_exists=True, social_link_exists=True, user_is_active=False) - @override_settings(DISABLED_COUNTRIES=['US']) + @patch.dict(settings.FEATURES, {'EMBARGO': True}) def test_with_disabled_country(self): """ - Test case to check user registration is forbidden when registration is disabled for a country + Test case to check user registration is forbidden when registration is restricted for a country """ self._verify_user_existence(user_exists=False, social_link_exists=False) self._setup_provider_response(success=True) + country_obj = Country.objects.create(country="US") + GlobalRestrictedCountry.objects.create(country=country_obj) response = self.client.post(self.url, self.data()) assert response.status_code == 400 assert response.json() == { @@ -2643,6 +2636,20 @@ class ThirdPartyRegistrationTestMixin( } self._verify_user_existence(user_exists=False, social_link_exists=False, user_is_active=False) + @patch.dict(settings.FEATURES, {'EMBARGO': False}) + def test_with_disabled_country_when_embargo_disabled(self): + """ + Ensures that user registration proceeds normally even for restricted countries + when the EMBARGO feature flag is disabled. + """ + self._verify_user_existence(user_exists=False, social_link_exists=False) + self._setup_provider_response(success=True) + country_obj = Country.objects.create(country="US") + GlobalRestrictedCountry.objects.create(country=country_obj) + response = self.client.post(self.url, self.data()) + assert response.status_code == 200 + self._verify_user_existence(user_exists=True, social_link_exists=True, user_is_active=False) + def test_unlinked_active_user(self): user = UserFactory() response = self.client.post(self.url, self.data(user)) @@ -2955,7 +2962,7 @@ class RegistrationValidationViewTests(test_utils.ApiTestCase, OpenEdxEventsTestM ) ]) def test_password_empty_validation_decision(self): - # 2 is the default setting for minimum length found in lms/envs/common.py + # 2 is the default setting for minimum length found in openedx/envs/common.py # under AUTH_PASSWORD_VALIDATORS.MinimumLengthValidator msg = 'This password is too short. It must contain at least 4 characters.' self.assertValidationDecision( @@ -2970,7 +2977,7 @@ class RegistrationValidationViewTests(test_utils.ApiTestCase, OpenEdxEventsTestM ]) def test_password_bad_min_length_validation_decision(self): password = 'p' - # 2 is the default setting for minimum length found in lms/envs/common.py + # 2 is the default setting for minimum length found in openedx/envs/common.py # under AUTH_PASSWORD_VALIDATORS.MinimumLengthValidator msg = 'This password is too short. It must contain at least 4 characters.' self.assertValidationDecision( @@ -2980,7 +2987,7 @@ class RegistrationValidationViewTests(test_utils.ApiTestCase, OpenEdxEventsTestM def test_password_bad_max_length_validation_decision(self): password = 'p' * DEFAULT_MAX_PASSWORD_LENGTH - # 75 is the default setting for maximum length found in lms/envs/common.py + # 75 is the default setting for maximum length found in openedx/envs/common.py # under AUTH_PASSWORD_VALIDATORS.MaximumLengthValidator msg = 'This password is too long. It must contain no more than 75 characters.' self.assertValidationDecision( diff --git a/openedx/core/djangoapps/user_authn/views/utils.py b/openedx/core/djangoapps/user_authn/views/utils.py index b9fb096621..14eb91d38e 100644 --- a/openedx/core/djangoapps/user_authn/views/utils.py +++ b/openedx/core/djangoapps/user_authn/views/utils.py @@ -14,6 +14,7 @@ from text_unidecode import unidecode from common.djangoapps import third_party_auth from common.djangoapps.third_party_auth import pipeline from common.djangoapps.third_party_auth.models import clean_username +from openedx.core.djangoapps.embargo.models import GlobalRestrictedCountry from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.geoinfo.api import country_code_from_ip import random @@ -191,6 +192,9 @@ def remove_disabled_country_from_list(countries: Dict) -> Dict: Returns: - dict: Dict of countries with disabled countries removed. """ - for country_code in settings.DISABLED_COUNTRIES: + if not settings.FEATURES.get("EMBARGO", False): + return countries + + for country_code in GlobalRestrictedCountry.get_countries(): del countries[country_code] return countries diff --git a/openedx/core/djangoapps/util/checks.py b/openedx/core/djangoapps/util/checks.py index bcde2fe620..9d06dd58b9 100644 --- a/openedx/core/djangoapps/util/checks.py +++ b/openedx/core/djangoapps/util/checks.py @@ -7,13 +7,7 @@ from django.core import checks _DEVSTACK_SETTINGS_MODULES = [ "lms.envs.devstack", - "lms.envs.devstack_docker", - "lms.envs.devstack_optimized", - "lms.envs.devstack_with_worker", "cms.envs.devstack", - "cms.envs.devstack_docker", - "cms.envs.devstack_optimized", - "cms.envs.devstack_with_worker", ] @@ -26,7 +20,7 @@ def warn_if_devstack_settings(**kwargs): return [ checks.Warning( "Open edX Devstack is deprecated, so the Django settings module you are using " - f"({settings.SETTINGS_MODULE}) will be removed from openedx/edx-platform by October 2024. " + f"({settings.SETTINGS_MODULE}) will be removed from openedx/edx-platform in 2025. " "Please either migrate off of Devstack, or modify your Devstack fork to work with an externally-" "managed Django settings file. " "For details and discussion, see: https://github.com/openedx/public-engineering/issues/247.", 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 0000000000..965ea16c6d --- /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/management/commands/print_setting.py b/openedx/core/djangoapps/util/management/commands/print_setting.py index d90a17b9eb..c53f49d23a 100644 --- a/openedx/core/djangoapps/util/management/commands/print_setting.py +++ b/openedx/core/djangoapps/util/management/commands/print_setting.py @@ -3,7 +3,11 @@ print_setting ============= Django command to output a single Django setting. -Useful when paver or a shell script needs such a value. +Originally used by "paver" scripts before we removed them. +Still useful when a shell script needs such a value. +Keep in mind that the LMS/CMS startup time is slow, so if you invoke this +Django management multiple times in a command that gets run often, you are +going to be sad. This handles the one specific use case of the "print_settings" command from django-extensions that we were actually using. 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 0000000000..90171eb48c --- /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/api.py b/openedx/core/djangoapps/xblock/api.py index 00bb8bc356..c806fefc87 100644 --- a/openedx/core/djangoapps/xblock/api.py +++ b/openedx/core/djangoapps/xblock/api.py @@ -19,7 +19,7 @@ from django.utils.translation import gettext as _ from openedx_learning.api import authoring as authoring_api from openedx_learning.api.authoring_models import Component, ComponentVersion from opaque_keys.edx.keys import UsageKeyV2 -from opaque_keys.edx.locator import BundleDefinitionLocator, LibraryUsageLocatorV2 +from opaque_keys.edx.locator import LibraryUsageLocatorV2 from rest_framework.exceptions import NotFound from xblock.core import XBlock from xblock.exceptions import NoSuchUsage, NoSuchViewError @@ -33,7 +33,12 @@ from openedx.core.djangoapps.xblock.runtime.learning_core_runtime import ( LearningCoreXBlockRuntime, ) from .data import CheckPerm, LatestVersion -from .utils import get_secure_token_for_xblock_handler, get_xblock_id_for_anonymous_user +from .rest_api.url_converters import VersionConverter +from .utils import ( + get_secure_token_for_xblock_handler, + get_xblock_id_for_anonymous_user, + get_auto_latest_version, +) from .runtime.learning_core_runtime import LearningCoreXBlockRuntime @@ -208,13 +213,26 @@ def get_component_from_usage_key(usage_key: UsageKeyV2) -> Component: ) -def get_block_draft_olx(usage_key: UsageKeyV2) -> str: +def get_block_olx( + usage_key: UsageKeyV2, + *, + version: int | LatestVersion = LatestVersion.AUTO +) -> str: """ - Get the OLX source of the draft version of the given Learning-Core-backed XBlock. + Get the OLX source of the of the given Learning-Core-backed XBlock and a version. """ - # Inefficient but simple approach. Optimize later if needed. component = get_component_from_usage_key(usage_key) - component_version = component.versioning.draft + version = get_auto_latest_version(version) + + if version == LatestVersion.DRAFT: + component_version = component.versioning.draft + elif version == LatestVersion.PUBLISHED: + component_version = component.versioning.published + else: + assert isinstance(version, int) + component_version = component.versioning.version_num(version) + if component_version is None: + raise NoSuchUsage(usage_key) # TODO: we should probably make a method on ComponentVersion that returns # a content based on the name. Accessing by componentversioncontent__key is @@ -224,6 +242,11 @@ def get_block_draft_olx(usage_key: UsageKeyV2) -> str: return content.text +def get_block_draft_olx(usage_key: UsageKeyV2) -> str: + """ DEPRECATED. Use get_block_olx(). Can be removed post-Teak. """ + return get_block_olx(usage_key, version=LatestVersion.DRAFT) + + def render_block_view(block, view_name, user): # pylint: disable=unused-argument """ Get the HTML, JS, and CSS needed to render the given XBlock view. diff --git a/openedx/core/djangoapps/xblock/learning_context/learning_context.py b/openedx/core/djangoapps/xblock/learning_context/learning_context.py index b535e84ca7..dc7a21f1c3 100644 --- a/openedx/core/djangoapps/xblock/learning_context/learning_context.py +++ b/openedx/core/djangoapps/xblock/learning_context/learning_context.py @@ -76,3 +76,11 @@ class LearningContext: usage_key: the UsageKeyV2 subclass used for this learning context """ + + def send_container_updated_events(self, usage_key): + """ + Send "container updated" events for containers that contains the block with + the given usage_key in this context. + + usage_key: the UsageKeyV2 subclass used for this learning context + """ diff --git a/openedx/core/djangoapps/xblock/rest_api/serializers.py b/openedx/core/djangoapps/xblock/rest_api/serializers.py new file mode 100644 index 0000000000..bb4dd1da22 --- /dev/null +++ b/openedx/core/djangoapps/xblock/rest_api/serializers.py @@ -0,0 +1,11 @@ +""" +Serializers for the xblock REST API +""" +from rest_framework import serializers + + +class XBlockOlxSerializer(serializers.Serializer): + """ + Serializer for representing an XBlock's OLX + """ + olx = serializers.CharField() diff --git a/openedx/core/djangoapps/xblock/rest_api/urls.py b/openedx/core/djangoapps/xblock/rest_api/urls.py index ee41b43f5b..a83d5104e5 100644 --- a/openedx/core/djangoapps/xblock/rest_api/urls.py +++ b/openedx/core/djangoapps/xblock/rest_api/urls.py @@ -19,6 +19,8 @@ block_endpoints = [ path('', views.block_metadata), # get/post full json fields of an XBlock: path('fields/', views.BlockFieldsView.as_view()), + # Get the OLX source code of the specified block + path('olx/', views.get_block_olx_view), # render one of this XBlock's views (e.g. student_view) path('view//', views.render_block_view), # get the URL needed to call this XBlock's handlers diff --git a/openedx/core/djangoapps/xblock/rest_api/views.py b/openedx/core/djangoapps/xblock/rest_api/views.py index d69edcbfd5..a71fb6cfd5 100644 --- a/openedx/core/djangoapps/xblock/rest_api/views.py +++ b/openedx/core/djangoapps/xblock/rest_api/views.py @@ -2,6 +2,7 @@ Views that implement a RESTful API for interacting with XBlocks. """ import json +from pathlib import Path from common.djangoapps.util.json_request import JsonResponse from corsheaders.signals import check_request_enabled @@ -21,6 +22,7 @@ from rest_framework.views import APIView from xblock.django.request import DjangoWebobRequest, webob_to_django_response from xblock.exceptions import NoSuchUsage from xblock.fields import Scope +import openassessment import openedx.core.djangoapps.site_configuration.helpers as configuration_helpers from openedx.core.djangoapps.xblock.learning_context.manager import get_learning_context_impl @@ -33,9 +35,11 @@ from ..api import ( get_handler_url as _get_handler_url, load_block, render_block_view as _render_block_view, + get_block_olx, ) from ..utils import validate_secure_token_for_xblock_handler from .url_converters import VersionConverter +from .serializers import XBlockOlxSerializer User = get_user_model() @@ -116,11 +120,33 @@ 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) + openassessment_path = Path(openassessment.__path__[0]) + oa_manifest_path = openassessment_path / "xblock" / "static" / "dist" / "manifest.json" + + new_oa_manifest = {} + if oa_manifest_path.exists(): + with open(oa_manifest_path, "r") as f: + oa_manifest = json.load(f) + new_oa_manifest = { + # When we add the RTL style, it automatically applies that style (right-to-left reading) regardless + # of the language. + # We weren't sure of where to place that conditional code, so we just defaulted to the LTR style for + # now, until we are more clear on how to handle rtl/ltr conditionally. + 'oa_ltr_css': oa_manifest.get("openassessment-ltr.css", ""), + 'oa_ltr_js': oa_manifest.get("openassessment-ltr.js", ""), + 'oa_editor_textarea_js': oa_manifest.get("openassessment-editor-textarea.js", ""), + 'oa_editor_tinymce_js': oa_manifest.get("openassessment-editor-tinymce.js", ""), + } + 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, + 'oa_manifest': new_oa_manifest, } response = render(request, 'xblock_v2/xblock_iframe.html', context, content_type='text/html') @@ -173,7 +199,7 @@ def xblock_handler( """ # To support sandboxed XBlocks, custom frontends, and other use cases, we # authenticate requests using a secure token in the URL. see - # openedx.core.djangoapps.xblock.utils.get_secure_hash_for_xblock_handler + # openedx.core.djangoapps.xblock.utils.get_secure_token_for_xblock_handler # for details and rationale. if not validate_secure_token_for_xblock_handler(user_id, str(usage_key), secure_token): raise PermissionDenied("Invalid/expired auth token.") @@ -213,6 +239,23 @@ def xblock_handler( return response +@api_view(['GET']) +@view_auth_classes(is_authenticated=False) +def get_block_olx_view( + request, + usage_key: UsageKeyV2, + version: LatestVersion | int = LatestVersion.AUTO, +): + """ + Get the OLX (XML serialization) of the specified XBlock + """ + context_impl = get_learning_context_impl(usage_key) + if not context_impl.can_view_block_for_editing(request.user, usage_key): + raise PermissionDenied(f"You don't have permission to access the OLX of component '{usage_key}'.") + olx = get_block_olx(usage_key, version=version) + return Response(XBlockOlxSerializer({"olx": olx}).data) + + def cors_allow_xblock_handler(sender, request, **kwargs): # lint-amnesty, pylint: disable=unused-argument """ Sandboxed XBlocks need to be able to call XBlock handlers via POST, @@ -303,10 +346,6 @@ class BlockFieldsView(APIView): # Save after the callback so any changes made in the callback will get persisted. block.save() - # Signal that we've modified this block - context_impl = get_learning_context_impl(usage_key) - context_impl.send_block_updated_event(usage_key) - block_dict = { "id": str(block.usage_key), "display_name": get_block_display_name(block), # note this is also present in metadata diff --git a/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py b/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py index fd2e867a3a..5f9cba6c3a 100644 --- a/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py +++ b/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py @@ -6,6 +6,7 @@ from __future__ import annotations import logging from collections import defaultdict from datetime import datetime, timezone +from urllib.parse import unquote from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db.transaction import atomic @@ -24,6 +25,7 @@ from openedx.core.djangoapps.xblock.api import get_xblock_app_config from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_for_learning_core from openedx.core.lib.xblock_serializer.data import StaticFile from ..data import AuthoredDataMode, LatestVersion +from ..utils import get_auto_latest_version from ..learning_context.manager import get_learning_context_impl from .runtime import XBlockRuntime @@ -178,11 +180,7 @@ class LearningCoreXBlockRuntime(XBlockRuntime): # just get it the easy way. component = self._get_component_from_usage_key(usage_key) - if version == LatestVersion.AUTO: - if self.authored_data_mode == AuthoredDataMode.DEFAULT_DRAFT: - version = LatestVersion.DRAFT - else: - version = LatestVersion.PUBLISHED + version = get_auto_latest_version(version) if self.authored_data_mode == AuthoredDataMode.STRICTLY_PUBLISHED and version != LatestVersion.PUBLISHED: raise ValidationError("This runtime only allows accessing the published version of components") if version == LatestVersion.DRAFT: @@ -313,9 +311,15 @@ class LearningCoreXBlockRuntime(XBlockRuntime): "block.xml": content.id, }, created=now, + created_by=self.user.id if self.user else None ) self.authored_data_store.mark_unchanged(block) + # Signal that we've modified this block + learning_context = get_learning_context_impl(usage_key) + learning_context.send_block_updated_event(usage_key) + learning_context.send_container_updated_events(usage_key) + def _get_component_from_usage_key(self, usage_key): """ Note that Components aren't ever really truly deleted, so this will @@ -449,9 +453,20 @@ class LearningCoreXBlockRuntime(XBlockRuntime): .get(key=f"static/{asset_path}") ) except ObjectDoesNotExist: - # This means we see a path that _looks_ like it should be a static - # asset for this Component, but that static asset doesn't really - # exist. - return None + try: + # Retry with unquoted path. We don't always unquote because it would not + # be backwards-compatible, but we need to try both. + asset_path = unquote(asset_path) + content = ( + component_version + .componentversioncontent_set + .filter(content__has_file=True) + .get(key=f"static/{asset_path}") + ) + except ObjectDoesNotExist: + # This means we see a path that _looks_ like it should be a static + # asset for this Component, but that static asset doesn't really + # exist. + return None return self._absolute_url_for_asset(component_version, asset_path) diff --git a/openedx/core/djangoapps/xblock/runtime/runtime.py b/openedx/core/djangoapps/xblock/runtime/runtime.py index 2bf84e9954..8829cedac7 100644 --- a/openedx/core/djangoapps/xblock/runtime/runtime.py +++ b/openedx/core/djangoapps/xblock/runtime/runtime.py @@ -18,7 +18,7 @@ from opaque_keys.edx.keys import UsageKeyV2, LearningContextKey from web_fragments.fragment import Fragment from xblock.core import XBlock from xblock.exceptions import NoSuchServiceError -from xblock.field_data import FieldData, SplitFieldData +from xblock.field_data import DictFieldData, FieldData, SplitFieldData from xblock.fields import Scope, ScopeIds from xblock.runtime import IdReader, KvsFieldData, MemoryIdManager, Runtime @@ -351,8 +351,10 @@ class XBlockRuntime(RuntimeShim, Runtime): Initialize the FieldData implementation for the specified XBlock """ if self.user is None: - # No user is specified, so we want to throw an error if anything attempts to read/write user-specific fields - student_data_store = None + # No user is specified, so we want to ignore any user-specific data. We cannot throw an + # error here because the XBlock loading process will write to the user_state if we have + # mutable fields. + student_data_store = DictFieldData({}) elif self.user.is_anonymous: # This is an anonymous (non-registered) user: assert isinstance(self.user_id, str) and self.user_id.startswith("anon") diff --git a/openedx/core/djangoapps/xblock/utils.py b/openedx/core/djangoapps/xblock/utils.py index 375bb9d214..b4ae054cf4 100644 --- a/openedx/core/djangoapps/xblock/utils.py +++ b/openedx/core/djangoapps/xblock/utils.py @@ -11,6 +11,10 @@ from uuid import uuid4 import crum from django.conf import settings +from openedx.core.djangoapps.xblock.apps import get_xblock_app_config + +from .data import AuthoredDataMode, LatestVersion + def get_secure_token_for_xblock_handler(user_id, block_key_str, time_idx=0): """ @@ -167,3 +171,18 @@ def get_xblock_id_for_anonymous_user(user): return current_request.session["xblock_id_for_anonymous_user"] else: raise RuntimeError("Cannot get a user ID for an anonymous user outside of an HTTP request context.") + + +def get_auto_latest_version(version: int | LatestVersion) -> int | LatestVersion: + """ + Gets the actual LatestVersion if is `LatestVersion.AUTO`; + otherwise, returns the same value. + """ + if version == LatestVersion.AUTO: + authored_data_mode = get_xblock_app_config().get_runtime_params()["authored_data_mode"] + version = ( + LatestVersion.DRAFT + if authored_data_mode == AuthoredDataMode.DEFAULT_DRAFT + else LatestVersion.PUBLISHED + ) + return version diff --git a/openedx/core/lib/api/view_utils.py b/openedx/core/lib/api/view_utils.py index 054755ae3c..d876e49ae5 100644 --- a/openedx/core/lib/api/view_utils.py +++ b/openedx/core/lib/api/view_utils.py @@ -265,8 +265,7 @@ class LazySequence(Sequence): 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 738f074be6..9a54f1b3a5 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/courses.py b/openedx/core/lib/courses.py index ea18ec0347..98321abfef 100644 --- a/openedx/core/lib/courses.py +++ b/openedx/core/lib/courses.py @@ -8,6 +8,7 @@ from django.http import Http404 from opaque_keys import InvalidKeyError from opaque_keys.edx.locator import CourseKey +from organizations.models import Organization from xmodule.assetstore.assetmgr import AssetManager from xmodule.contentstore.content import StaticContent from xmodule.contentstore.django import contentstore @@ -38,6 +39,12 @@ def course_image_url(course, image_key='course_image'): return url +def course_organization_image_url(course): + """Return the course organization image URL or the default image URL.""" + org = Organization.objects.filter(short_name=course.id.org).first() + return org.logo.url if org and org.logo else settings.DEFAULT_ORG_LOGO_URL + + def create_course_image_thumbnail(course, dimensions): """Create a course image thumbnail and return the URL. diff --git a/openedx/core/lib/derived.py b/openedx/core/lib/derived.py index a62731ef54..745d6f1262 100644 --- a/openedx/core/lib/derived.py +++ b/openedx/core/lib/derived.py @@ -4,71 +4,121 @@ via callable methods/lambdas. The derivation time can be controlled to happen af other settings have been set. The derived setting can also be overridden by setting the derived setting to an actual value. """ +from __future__ import annotations +import re import sys - -# Global list holding all settings which will be derived. -__DERIVED = [] +import types +import typing as t -def derived(*settings): +Settings: t.TypeAlias = types.ModuleType + + +T = t.TypeVar('T') + + +class Derived(t.Generic[T]): """ - Registers settings which are derived from other settings. - Can be called multiple times to add more derived settings. + A temporary Django setting value, defined with a function which generates the setting's eventual value. - Args: - settings (str): Setting names to register. + Said function (`calculate_value`) should accept a Django settings module, and return a calculated value. + + To ensure that application code does not encounter an instance of this class in your settings, be sure to call + `derive_settings` somewhere in your terminal settings file. """ - __DERIVED.extend(settings) + def __init__(self, calculate_value: t.Callable[[Settings], T]): + self.calculate_value = calculate_value -def derived_collection_entry(collection_name, *accessors): +def derive_settings(module_name: str) -> None: """ - Registers a setting which is a dictionary or list and needs a derived value for a particular entry. - Can be called multiple times to add more derived settings. + In the Django settings module at `module_name`, replace `Derived` values with their cacluated values. - Args: - collection_name (str): Name of setting which contains a dictionary or list. - accessors (int|str): Sequence of dictionary keys and list indices in the collection (and - collections within it) leading to the value which will be derived. - For example: 0, 'DIRS'. - """ - __DERIVED.append((collection_name, accessors)) - - -def derive_settings(module_name): - """ - Derives all registered settings and sets them onto a particular module. - Skips deriving settings that are set to a value. - - Args: - module_name (str): Name of module to which the derived settings will be added. + The replacement happens recursively for any values or containers defined by a Django setting name (which is: an + uppercase top-level variable name which is not prefixed by an underscore). Within containers, """ module = sys.modules[module_name] - for derived in __DERIVED: # lint-amnesty, pylint: disable=redefined-outer-name - if isinstance(derived, str): - setting = getattr(module, derived) - if callable(setting): - setting_val = setting(module) - setattr(module, derived, setting_val) - elif isinstance(derived, tuple): - # If a tuple, two elements are expected - else ignore. - if len(derived) == 2: - # The first element is the name of the attribute which is expected to be a dictionary or list. - # The second element is a list of string keys in that dictionary leading to a derived setting. - collection = getattr(module, derived[0]) - accessors = derived[1] - for accessor in accessors[:-1]: - collection = collection[accessor] - setting = collection[accessors[-1]] - if callable(setting): - setting_val = setting(module) - collection[accessors[-1]] = setting_val + _derive_dict(module, vars(module), key_filter=_key_is_a_setting_name) -def clear_for_tests(): +_SETTING_NAME_REGEX = re.compile(r'^[A-Z][A-Z0-9_]*$') + + +def _key_is_a_setting_name(key: str) -> bool: + return bool(_SETTING_NAME_REGEX.match(key)) + + +def _match_every_key(_key: str) -> bool: + return True + + +def _derive_recursively(settings: Settings, value: t.Any) -> t.Any: """ - Clears all settings to be derived. For tests only. + Recursively evaluate `Derived` objects` in `value` and any child containers. Return evaluated version of `value`. + + * If `value` is a `Derived` object, then use `settings` to calculate and return its value. + * If `value` is a mutable container, then recursively evaluate it in-place. + * If `value` is an immutable container, then recursively evalute a shallow copy of it. + Keep in mind that immutable containers (particularly: tuples) can contain mutable containers. In such a case, the + original and shallow-copied mutable containers will both reference the same child mutable container object. """ - global __DERIVED - __DERIVED = [] + if isinstance(value, Derived): + return value.calculate_value(settings) + elif isinstance(value, dict): + return _derive_dict(settings, value) + elif isinstance(value, list): + return _derive_list(settings, value) + elif isinstance(value, tuple): + return _derive_tuple(settings, value) + elif isinstance(value, frozenset): + return _derive_frozenset(settings, value) + else: + return value + + +def _derive_dict(settings: Settings, the_dict: dict, key_filter: t.Callable[[str], bool] = _match_every_key) -> dict: + """ + Recursively evaluate `Derived` objects in `the_dict` and any child containers. Modifies `the_dict` in place. + + Optionally takes a `key_filter`. Items that do not match the provided `key_filter` will be left alone. + """ + for key, value in the_dict.items(): + if key_filter(key): + the_dict[key] = _derive_recursively(settings, value) + return the_dict + + +def _derive_list(settings: Settings, the_list: list) -> list: + """ + Recursively evaluate `Derived` objects in `the_list` and any child containers. Modifies `the_list` in place. + """ + for ix in range(len(the_list)): + the_list[ix] = _derive_recursively(settings, the_list[ix]) + return the_list + + +def _derive_tuple(settings: Settings, tup: tuple) -> tuple: + """ + Recursively evaluate `Derived` objects in `tup` and any child containers. Returns a shallow copy of `tup`. + """ + return tuple(_derive_recursively(settings, item) for item in tup) + + +def _derive_set(settings: Settings, the_set: set) -> set: + """ + Recursively evaluate `Derived` objects in `the_set` and any child containers. Modifies `the_set` in-place. + """ + for original in the_set: + derived = _derive_recursively(settings, original) + if derived != original: + the_set.remove(original) + the_set.add(derived) + return the_set + + +def _derive_frozenset(settings: Settings, the_set: frozenset) -> frozenset: + """ + Recursively evaluate `Derived` objects in `the_set` and any child containers. Returns a shallow copy of `the_set`. + """ + return frozenset(_derive_recursively(settings, item) for item in the_set) diff --git a/openedx/core/lib/docs/how_tos/logging-and-monitoring-ignored-errors.rst b/openedx/core/lib/docs/how_tos/logging-and-monitoring-ignored-errors.rst index 8305af4617..28e357e9e4 100644 --- a/openedx/core/lib/docs/how_tos/logging-and-monitoring-ignored-errors.rst +++ b/openedx/core/lib/docs/how_tos/logging-and-monitoring-ignored-errors.rst @@ -20,7 +20,7 @@ Additionally, a subset of ignored errors that are configured as ignored will als * Using New Relic terminology, this extra error class and message data will live on the Transaction and not the TransactionError, because ignored errors won't have a TransactionError. * Use these additional custom attributes to help diagnose unexpected issues with ignored errors. -.. _IGNORED_ERRORS settings and toggles on Readthedocs: https://edx.readthedocs.io/projects/edx-platform-technical/en/latest/search.html?q=IGNORED_ERRORS&check_keywords=yes&area=default +.. _IGNORED_ERRORS settings and toggles on Readthedocs: https://docs.openedx.org/projects/edx-platform/en/latest/search.html?q=IGNORED_ERRORS Logging ignored errors ----------------------- diff --git a/openedx/core/lib/hash_utils.py b/openedx/core/lib/hash_utils.py index 3e146408ad..2c79563458 100644 --- a/openedx/core/lib/hash_utils.py +++ b/openedx/core/lib/hash_utils.py @@ -14,7 +14,7 @@ from django.conf import settings def create_hash256(max_length=None): """ Generate a hash that can be used as an application secret - Warning: this is not sufficiently secure for tasks like encription + Warning: this is not sufficiently secure for tasks like encryption Currently, this is just meant to create sufficiently random tokens """ hash_object = hashlib.sha256(force_bytes(get_random_string(32))) diff --git a/openedx/core/lib/jwt.py b/openedx/core/lib/jwt.py new file mode 100644 index 0000000000..199c6fead8 --- /dev/null +++ b/openedx/core/lib/jwt.py @@ -0,0 +1,98 @@ +""" +JWT Token handling and signing functions. +""" + +import jwt +from time import time + +from django.conf import settings +from jwt.api_jwk import PyJWK, PyJWKSet +from jwt.exceptions import ExpiredSignatureError, InvalidSignatureError, MissingRequiredClaimError + + +def create_jwt(lms_user_id, expires_in_seconds, additional_token_claims, now=None): + """ + 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. + """ + private_key = PyJWK.from_json(settings.TOKEN_SIGNING['JWT_PRIVATE_SIGNING_JWK']) + algorithm = settings.TOKEN_SIGNING['JWT_SIGNING_ALGORITHM'] + return jwt.encode(payload, key=private_key.key, algorithm=algorithm) + + +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 MissingRequiredClaimError("LMS user id is missing") + if "exp" not in payload: + raise MissingRequiredClaimError("Expiration is missing") + if payload["lms_user_id"] != lms_user_id: + raise InvalidSignatureError("User does not match") + if payload["exp"] < now: + raise ExpiredSignatureError("Token is expired") + + return payload + + +def unpack_and_verify(token): # pylint: disable=inconsistent-return-statements + """ + Unpack and verify the provided token. + + The signing key and algorithm are pulled from settings. + """ + key_set = [] + key_set.extend( + PyJWKSet.from_json(settings.TOKEN_SIGNING["JWT_PUBLIC_SIGNING_JWK_SET"]).keys + ) + + for i in range(len(key_set)): # pylint: disable=consider-using-enumerate + try: + decoded = jwt.decode( + token, + key=key_set[i].key, + algorithms=["RS256", "RS512"], + options={"verify_signature": True, "verify_aud": False}, + ) + return decoded + except Exception: # pylint: disable=broad-exception-caught + if i == len(key_set) - 1: + raise diff --git a/openedx/core/lib/logsettings.py b/openedx/core/lib/logsettings.py index f813be5c8f..ee78b26623 100644 --- a/openedx/core/lib/logsettings.py +++ b/openedx/core/lib/logsettings.py @@ -144,16 +144,6 @@ def log_python_warnings(): category=DeprecationWarning, module="sass", ) - warnings.filterwarnings( - 'ignore', - 'Deprecated call to `pkg_resources.declare_namespace.*', - category=DeprecationWarning, - ) - warnings.filterwarnings( - 'ignore', - '.*pkg_resources is deprecated as an API.*', - category=DeprecationWarning, - ) warnings.filterwarnings( 'ignore', "'etree' is deprecated. Use 'xml.etree.ElementTree' instead.", category=DeprecationWarning, module='wiki' diff --git a/openedx/core/lib/tests/test_derived.py b/openedx/core/lib/tests/test_derived.py index ef3f980424..7d3f70fa6a 100644 --- a/openedx/core/lib/tests/test_derived.py +++ b/openedx/core/lib/tests/test_derived.py @@ -5,7 +5,7 @@ Tests for derived.py import sys from unittest import TestCase -from openedx.core.lib.derived import derived, derived_collection_entry, derive_settings, clear_for_tests +from openedx.core.lib.derived import Derived, derive_settings class TestDerivedSettings(TestCase): @@ -14,18 +14,14 @@ class TestDerivedSettings(TestCase): """ def setUp(self): super().setUp() - clear_for_tests() self.module = sys.modules[__name__] self.module.SIMPLE_VALUE = 'paneer' - self.module.DERIVED_VALUE = lambda settings: 'mutter ' + settings.SIMPLE_VALUE - self.module.ANOTHER_DERIVED_VALUE = lambda settings: settings.DERIVED_VALUE + ' with naan' + self.module.DERIVED_VALUE = Derived(lambda settings: 'mutter ' + settings.SIMPLE_VALUE) + self.module.ANOTHER_DERIVED_VALUE = Derived(lambda settings: settings.DERIVED_VALUE + ' with naan') self.module.UNREGISTERED_DERIVED_VALUE = lambda settings: settings.SIMPLE_VALUE + ' is cheese' - derived('DERIVED_VALUE', 'ANOTHER_DERIVED_VALUE') self.module.DICT_VALUE = {} - self.module.DICT_VALUE['test_key'] = lambda settings: settings.DERIVED_VALUE * 3 - derived_collection_entry('DICT_VALUE', 'test_key') - self.module.DICT_VALUE['list_key'] = ['not derived', lambda settings: settings.DERIVED_VALUE] - derived_collection_entry('DICT_VALUE', 'list_key', 1) + self.module.DICT_VALUE['test_key'] = Derived(lambda settings: settings.DERIVED_VALUE * 3) + self.module.DICT_VALUE['list_key'] = ['not derived', Derived(lambda settings: settings.DERIVED_VALUE)] def test_derived_settings_are_derived(self): derive_settings(__name__) diff --git a/openedx/core/lib/tests/test_jwt.py b/openedx/core/lib/tests/test_jwt.py new file mode 100644 index 0000000000..da1f047e48 --- /dev/null +++ b/openedx/core/lib/tests/test_jwt.py @@ -0,0 +1,117 @@ +""" +Tests for token handling +""" +import unittest +from time import time + +from jwt.exceptions import ExpiredSignatureError, InvalidSignatureError, MissingRequiredClaimError + +from openedx.core.djangolib.testing.utils import skip_unless_lms +from openedx.core.lib.jwt import _encode_and_sign, create_jwt, unpack_jwt, unpack_and_verify + + +test_user_id = 121 +invalid_test_user_id = 120 +test_timeout = 1000 +test_now = int(time()) +test_claims = {"foo": "bar", "baz": "quux", "meaning": 42} +expected_full_token = { + "lms_user_id": test_user_id, + "iat": test_now, + "exp": test_now + test_timeout, + "iss": "token-test-issuer", # these lines from test_settings.py + "version": "1.2.0", # these lines from test_settings.py +} + + +@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 = unpack_and_verify(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 = unpack_and_verify(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(InvalidSignatureError): + unpack_and_verify(token) + + +@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(InvalidSignatureError): + unpack_jwt(token, test_user_id, test_now) + + def test_unpack_token_with_invalid_user(self): + token = create_jwt(invalid_test_user_id, test_timeout, {}, test_now) + + with self.assertRaises(InvalidSignatureError): + unpack_jwt(token, test_user_id, test_now) + + def test_unpack_expired_token(self): + token = create_jwt(test_user_id, test_timeout, {}, test_now) + + with self.assertRaises(ExpiredSignatureError): + 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(MissingRequiredClaimError): + 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(MissingRequiredClaimError): + unpack_jwt(token, test_user_id, test_now) diff --git a/openedx/core/lib/xblock_pipeline/finder.py b/openedx/core/lib/xblock_pipeline/finder.py index da5f4e5330..4893e5e048 100644 --- a/openedx/core/lib/xblock_pipeline/finder.py +++ b/openedx/core/lib/xblock_pipeline/finder.py @@ -4,13 +4,13 @@ Django pipeline finder for handling static assets required by XBlocks. import os from datetime import datetime +import importlib.resources as resources from django.contrib.staticfiles import utils from django.contrib.staticfiles.finders import BaseFinder from django.contrib.staticfiles.storage import FileSystemStorage from django.core.files.storage import Storage from django.utils import timezone -from pkg_resources import resource_exists, resource_filename, resource_isdir, resource_listdir from xblock.core import XBlock from openedx.core.lib.xblock_utils import xblock_resource_pkg @@ -38,7 +38,8 @@ class XBlockPackageStorage(Storage): """ Returns a file system filename for the specified file name. """ - return resource_filename(self.module, os.path.join(self.base_dir, name)) + with resources.as_file(resources.files(self.module.rsplit('.', 1)[0]) / self.base_dir / name) as file_path: + return str(file_path) def exists(self, path): # lint-amnesty, pylint: disable=arguments-differ """ @@ -46,8 +47,7 @@ class XBlockPackageStorage(Storage): """ if self.base_dir is None: return False - - return resource_exists(self.module, os.path.join(self.base_dir, path)) + return (resources.files(self.module.rsplit('.', 1)[0]) / self.base_dir / path).exists() def listdir(self, path): """ @@ -55,13 +55,14 @@ class XBlockPackageStorage(Storage): """ directories = [] files = [] - for item in resource_listdir(self.module, os.path.join(self.base_dir, path)): - __, file_extension = os.path.splitext(item) - if file_extension not in [".py", ".pyc", ".scss"]: - if resource_isdir(self.module, os.path.join(self.base_dir, path, item)): - directories.append(item) - else: - files.append(item) + base_path = resources.files(self.module.rsplit('.', 1)[0]) / self.base_dir / path + if base_path.is_dir(): + for item in base_path.iterdir(): + if item.suffix not in [".py", ".pyc", ".scss"]: + if item.is_dir(): + directories.append(item.name) + else: + files.append(item.name) return directories, files def open(self, name, mode='rb'): diff --git a/openedx/core/lib/xblock_serializer/block_serializer.py b/openedx/core/lib/xblock_serializer/block_serializer.py index 53be26937d..b8eabfcb4f 100644 --- a/openedx/core/lib/xblock_serializer/block_serializer.py +++ b/openedx/core/lib/xblock_serializer/block_serializer.py @@ -152,6 +152,9 @@ class XBlockSerializer: olx_node.attrib["editor"] = block.editor if block.use_latex_compiler: olx_node.attrib["use_latex_compiler"] = "true" + for field_name in block.fields: + if field_name.startswith("upstream") and block.fields[field_name].is_set_on(block): + olx_node.attrib[field_name] = str(getattr(block, field_name)) # Escape any CDATA special chars escaped_block_data = block.data.replace("]]>", "]]>") diff --git a/openedx/core/lib/xblock_serializer/utils.py b/openedx/core/lib/xblock_serializer/utils.py index e78c900b18..6f48eef391 100644 --- a/openedx/core/lib/xblock_serializer/utils.py +++ b/openedx/core/lib/xblock_serializer/utils.py @@ -2,11 +2,11 @@ Helper functions for XBlock serialization """ from __future__ import annotations + import logging import re from contextlib import contextmanager -from django.conf import settings from fs.memoryfs import MemoryFS from fs.wrapfs import WrapFS from opaque_keys import InvalidKeyError @@ -17,7 +17,7 @@ from xmodule.assetstore.assetmgr import AssetManager from xmodule.contentstore.content import StaticContent from xmodule.exceptions import NotFoundError from xmodule.modulestore.exceptions import ItemNotFoundError -from xmodule.util.sandboxing import DEFAULT_PYTHON_LIB_FILENAME +from xmodule.util.sandboxing import course_code_library_asset_name from xmodule.xml_block import XmlMixin from .data import StaticFile @@ -105,7 +105,7 @@ def get_python_lib_zip_if_using(olx: str, course_id: CourseKey) -> StaticFile | using python_lib.zip """ if _has_python_script(olx): - python_lib_filename = getattr(settings, 'PYTHON_LIB_FILENAME', DEFAULT_PYTHON_LIB_FILENAME) + python_lib_filename = course_code_library_asset_name() asset_key = StaticContent.get_asset_key_from_path(course_id, python_lib_filename) # Now, it seems like this capa problem uses python_lib.zip - but does it exist in the course? if AssetManager.find(asset_key, throw_on_not_found=False): diff --git a/pylint_django_settings.py b/openedx/core/tests/pylint_django_settings.py similarity index 79% rename from pylint_django_settings.py rename to openedx/core/tests/pylint_django_settings.py index 46abfd81f8..09ec5871f8 100644 --- a/pylint_django_settings.py +++ b/openedx/core/tests/pylint_django_settings.py @@ -1,5 +1,10 @@ -from pylint_django.checkers import ForeignKeyStringsChecker -from pylint_plugin_utils import get_checker +""" +This is a plugin that helps pylint figure out what DJANGO_SETTINGS_MODULE to use for linting different files. Since the +LMS and CMS files have different expectations about what django settings including which installed apps and settings are +set when the code is run. +""" +import os +import sys class ArgumentCompatibilityError(Exception): @@ -40,13 +45,11 @@ def register(linter): """ Placeholder function to register the plugin with pylint. """ - pass + return 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/openedx/core/types/http.py b/openedx/core/types/http.py new file mode 100644 index 0000000000..2896256a10 --- /dev/null +++ b/openedx/core/types/http.py @@ -0,0 +1,45 @@ +""" +Typing utilities for the HTTP requests, responses, etc. + +Includes utilties to work with both vanilla django as well as djangorestframework. +""" +from __future__ import annotations + +import django.contrib.auth.models # pylint: disable=imported-auth-user +import django.http +import rest_framework.request + +import openedx.core.types.user +from openedx.core.types.meta import type_annotation_only + + +@type_annotation_only +class HttpRequest(django.http.HttpRequest): + """ + A request which either has a concrete User (from django.contrib.auth) or is anonymous. + """ + user: openedx.core.types.User + + +@type_annotation_only +class AuthenticatedHttpRequest(HttpRequest): + """ + A request which is guaranteed to have a concrete User (from django.contrib.auth). + """ + user: django.contrib.auth.models.User + + +@type_annotation_only +class RestRequest(rest_framework.request.Request): + """ + Same as HttpRequest, but extended for rest_framework views. + """ + user: openedx.core.types.User + + +@type_annotation_only +class AuthenticatedRestRequest(RestRequest): + """ + Same as AuthenticatedHttpRequest, but extended for rest_framework views. + """ + user: django.contrib.auth.models.User diff --git a/openedx/core/types/meta.py b/openedx/core/types/meta.py new file mode 100644 index 0000000000..39162b05b8 --- /dev/null +++ b/openedx/core/types/meta.py @@ -0,0 +1,37 @@ +""" +Typing utilities for use on other typing utilities. +""" +from __future__ import annotations + +import typing as t + + +def type_annotation_only(cls: type) -> type: + """ + Decorates class which should only be used in type annotations. + + This is useful when you want to enhance an existing 3rd-party concrete class with + type annotations for its members, but don't want the enhanced class to ever actually + be instantiated. For examples, see openedx.core.types.http. + """ + if t.TYPE_CHECKING: + return cls + return _forbid_init(cls) + + +def _forbid_init(forbidden: type) -> type: + """ + Return a class which refuses to be instantiated. + """ + class _ForbidInit: + """ + The resulting class. + """ + def __init__(self, *args, **kwargs): + raise NotImplementedError( + f"Class {forbidden.__module__}:{forbidden.__name__} " + "cannot be instantiated. You may use it as a type annotation, but objects " + "can only be created from its concrete superclasses." + ) + + return _ForbidInit diff --git a/openedx/core/types/user.py b/openedx/core/types/user.py index 95b1fec607..9eb63edba3 100644 --- a/openedx/core/types/user.py +++ b/openedx/core/types/user.py @@ -1,8 +1,10 @@ """ Typing utilities for the User models. """ -from typing import Union +from __future__ import annotations + +import typing as t import django.contrib.auth.models -User = Union[django.contrib.auth.models.User, django.contrib.auth.models.AnonymousUser] +User: t.TypeAlias = django.contrib.auth.models.User | django.contrib.auth.models.AnonymousUser diff --git a/pavelib/utils/test/__init__.py b/openedx/envs/__init__.py similarity index 100% rename from pavelib/utils/test/__init__.py rename to openedx/envs/__init__.py diff --git a/openedx/envs/common.py b/openedx/envs/common.py new file mode 100644 index 0000000000..ceccce3342 --- /dev/null +++ b/openedx/envs/common.py @@ -0,0 +1,782 @@ +""" +Common Django settings for Open edX services. + +This module defines configuration shared between the LMS and CMS (Studio) +environments. It centralizes common settings in one place and reduces duplication. + +Service-specific settings should import from this module and override as needed. + +Note: More settings will be added to this file as the effort to simplify +settings moves forward. See docs/decisions/0022-settings-simplification.rst for +more details on the effort to simplify settings across Open edX services. + +To create section headers in this file, use the following function: + +```python +def center_with_hashes(text: str, width: int = 76): + print(f"{f' {text} ':#^{width}}") +``` +""" +import os +from path import Path as path + +from django.utils.translation import gettext_lazy as _ + +from openedx.core.lib.derived import Derived + +from openedx.core.djangoapps.theming.helpers_dirs import ( + get_themes_unchecked, + get_theme_base_dirs_from_settings +) + +from openedx.core.constants import ( # pylint: disable=unused-import + ASSET_KEY_PATTERN, + COURSE_KEY_REGEX, + COURSE_KEY_PATTERN, + COURSE_ID_PATTERN, + USAGE_KEY_PATTERN, + USAGE_ID_PATTERN, +) + +################ Shared Functions for Derived Configuration ################ + + +def make_mako_template_dirs(settings): + """ + Derives the final list of Mako template directories based on the provided settings. + + Args: + settings: A Django settings module object. + + Returns: + list: A list of Mako template directories, potentially updated with additional + theme directories. + """ + if settings.ENABLE_COMPREHENSIVE_THEMING: + themes_dirs = get_theme_base_dirs_from_settings(settings.COMPREHENSIVE_THEME_DIRS) + for theme in get_themes_unchecked(themes_dirs, settings.PROJECT_ROOT): + if theme.themes_base_dir not in settings.MAKO_TEMPLATE_DIRS_BASE: + settings.MAKO_TEMPLATE_DIRS_BASE.insert(0, theme.themes_base_dir) + return settings.MAKO_TEMPLATE_DIRS_BASE + + +def _make_locale_paths(settings): + """ + Constructs a list of paths to locale directories used for translation. + + Localization (l10n) strings (e.g. django.po) are found in these directories. + + Args: + settings: A Django settings module object. + + Returns: + list: A list of paths, `str` or `path.Path`, to locale directories. + """ + locale_paths = list(settings.PREPEND_LOCALE_PATHS) + locale_paths += [settings.REPO_ROOT + '/conf/locale'] # edx-platform/conf/locale/ + + if settings.ENABLE_COMPREHENSIVE_THEMING: + # Add locale paths to settings for comprehensive theming. + for locale_path in settings.COMPREHENSIVE_THEME_LOCALE_PATHS: + locale_paths += (path(locale_path), ) + return locale_paths + +############################# Django Built-Ins ############################# + +USE_TZ = True + +# User-uploaded content +MEDIA_ROOT = '/edx/var/edxapp/media/' +MEDIA_URL = '/media/' + +# Dummy secret key for dev/test +SECRET_KEY = 'dev key' + +STATICI18N_OUTPUT_DIR = "js/i18n" + +# Sourced from http://www.localeplanet.com/icu/ and wikipedia +LANGUAGES = [ + ('en', 'English'), + ('rtl', 'Right-to-Left Test Language'), + ('eo', 'Dummy Language (Esperanto)'), # Dummy languaged used for testing + + ('am', 'አማርኛ'), # Amharic + ('ar', 'العربية'), # Arabic + ('az', 'azərbaycanca'), # Azerbaijani + ('bg-bg', 'български (България)'), # Bulgarian (Bulgaria) + ('bn-bd', 'বাংলা (বাংলাদেশ)'), # Bengali (Bangladesh) + ('bn-in', 'বাংলা (ভারত)'), # Bengali (India) + ('bs', 'bosanski'), # Bosnian + ('ca', 'Català'), # Catalan + ('ca@valencia', 'Català (València)'), # Catalan (Valencia) + ('cs', 'Čeština'), # Czech + ('cy', 'Cymraeg'), # Welsh + ('da', 'dansk'), # Danish + ('de-de', 'Deutsch (Deutschland)'), # German (Germany) + ('el', 'Ελληνικά'), # Greek + ('en-uk', 'English (United Kingdom)'), # English (United Kingdom) + ('en@lolcat', 'LOLCAT English'), # LOLCAT English + ('en@pirate', 'Pirate English'), # Pirate English + ('es-419', 'Español (Latinoamérica)'), # Spanish (Latin America) + ('es-ar', 'Español (Argentina)'), # Spanish (Argentina) + ('es-ec', 'Español (Ecuador)'), # Spanish (Ecuador) + ('es-es', 'Español (España)'), # Spanish (Spain) + ('es-mx', 'Español (México)'), # Spanish (Mexico) + ('es-pe', 'Español (Perú)'), # Spanish (Peru) + ('et-ee', 'Eesti (Eesti)'), # Estonian (Estonia) + ('eu-es', 'euskara (Espainia)'), # Basque (Spain) + ('fa', 'فارسی'), # Persian + ('fa-ir', 'فارسی (ایران)'), # Persian (Iran) + ('fi-fi', 'Suomi (Suomi)'), # Finnish (Finland) + ('fil', 'Filipino'), # Filipino + ('fr', 'Français'), # French + ('gl', 'Galego'), # Galician + ('gu', 'ગુજરાતી'), # Gujarati + ('he', 'עברית'), # Hebrew + ('hi', 'हिन्दी'), # Hindi + ('hr', 'hrvatski'), # Croatian + ('hu', 'magyar'), # Hungarian + ('hy-am', 'Հայերեն (Հայաստան)'), # Armenian (Armenia) + ('id', 'Bahasa Indonesia'), # Indonesian + ('it-it', 'Italiano (Italia)'), # Italian (Italy) + ('ja-jp', '日本語 (日本)'), # Japanese (Japan) + ('kk-kz', 'қазақ тілі (Қазақстан)'), # Kazakh (Kazakhstan) + ('km-kh', 'ភាសាខ្មែរ (កម្ពុជា)'), # Khmer (Cambodia) + ('kn', 'ಕನ್ನಡ'), # Kannada + ('ko-kr', '한국어 (대한민국)'), # Korean (Korea) + ('lt-lt', 'Lietuvių (Lietuva)'), # Lithuanian (Lithuania) + ('ml', 'മലയാളം'), # Malayalam + ('mn', 'Монгол хэл'), # Mongolian + ('mr', 'मराठी'), # Marathi + ('ms', 'Bahasa Melayu'), # Malay + ('nb', 'Norsk bokmål'), # Norwegian Bokmål + ('ne', 'नेपाली'), # Nepali + ('nl-nl', 'Nederlands (Nederland)'), # Dutch (Netherlands) + ('or', 'ଓଡ଼ିଆ'), # Oriya + ('pl', 'Polski'), # Polish + ('pt-br', 'Português (Brasil)'), # Portuguese (Brazil) + ('pt-pt', 'Português (Portugal)'), # Portuguese (Portugal) + ('ro', 'română'), # Romanian + ('ru', 'Русский'), # Russian + ('si', 'සිංහල'), # Sinhala + ('sk', 'Slovenčina'), # Slovak + ('sl', 'Slovenščina'), # Slovenian + ('sq', 'shqip'), # Albanian + ('sr', 'Српски'), # Serbian + ('sv', 'svenska'), # Swedish + ('sw', 'Kiswahili'), # Swahili + ('ta', 'தமிழ்'), # Tamil + ('te', 'తెలుగు'), # Telugu + ('th', 'ไทย'), # Thai + ('tr-tr', 'Türkçe (Türkiye)'), # Turkish (Turkey) + ('uk', 'Українська'), # Ukranian + ('ur', 'اردو'), # Urdu + ('vi', 'Tiếng Việt'), # Vietnamese + ('uz', 'Ўзбек'), # Uzbek + ('zh-cn', '中文 (简体)'), # Chinese (China) + ('zh-hk', '中文 (香港)'), # Chinese (Hong Kong) + ('zh-tw', '中文 (台灣)'), # Chinese (Taiwan) +] + +# these languages display right to left +LANGUAGES_BIDI = ("he", "ar", "fa", "ur", "fa-ir", "rtl") + +LANGUAGE_COOKIE_NAME = "openedx-language-preference" + +LOCALE_PATHS = Derived(_make_locale_paths) + +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", + }, + { + "NAME": "common.djangoapps.util.password_policy_validators.MinimumLengthValidator", + "OPTIONS": { + "min_length": 8 + } + }, + { + "NAME": "common.djangoapps.util.password_policy_validators.MaximumLengthValidator", + "OPTIONS": { + "max_length": 75 + } + }, +] + +# See https://github.com/openedx/edx-django-sites-extensions for more info. +# Default site to use if site matching request headers does not exist. +SITE_ID = 1 + +################################# Language ################################# + +# Source: +# http://loc.gov/standards/iso639-2/ISO-639-2_utf-8.txt according to http://en.wikipedia.org/wiki/ISO_639-1 +# Note that this is used as the set of choices to the `code` field of the `LanguageProficiency` model. +ALL_LANGUAGES = [ + ["aa", "Afar"], + ["ab", "Abkhazian"], + ["af", "Afrikaans"], + ["ak", "Akan"], + ["sq", "Albanian"], + ["am", "Amharic"], + ["ar", "Arabic"], + ["an", "Aragonese"], + ["hy", "Armenian"], + ["as", "Assamese"], + ["av", "Avaric"], + ["ae", "Avestan"], + ["ay", "Aymara"], + ["az", "Azerbaijani"], + ["ba", "Bashkir"], + ["bm", "Bambara"], + ["eu", "Basque"], + ["be", "Belarusian"], + ["bn", "Bengali"], + ["bh", "Bihari languages"], + ["bi", "Bislama"], + ["bs", "Bosnian"], + ["br", "Breton"], + ["bg", "Bulgarian"], + ["my", "Burmese"], + ["ca", "Catalan"], + ["ch", "Chamorro"], + ["ce", "Chechen"], + ["zh", "Chinese"], + ["zh_HANS", "Simplified Chinese"], + ["zh_HANT", "Traditional Chinese"], + ["cu", "Church Slavic"], + ["cv", "Chuvash"], + ["kw", "Cornish"], + ["co", "Corsican"], + ["cr", "Cree"], + ["cs", "Czech"], + ["da", "Danish"], + ["dv", "Divehi"], + ["nl", "Dutch"], + ["dz", "Dzongkha"], + ["en", "English"], + ["eo", "Esperanto"], + ["et", "Estonian"], + ["ee", "Ewe"], + ["fo", "Faroese"], + ["fj", "Fijian"], + ["fi", "Finnish"], + ["fr", "French"], + ["fy", "Western Frisian"], + ["ff", "Fulah"], + ["ka", "Georgian"], + ["de", "German"], + ["gd", "Gaelic"], + ["ga", "Irish"], + ["gl", "Galician"], + ["gv", "Manx"], + ["el", "Greek"], + ["gn", "Guarani"], + ["gu", "Gujarati"], + ["ht", "Haitian"], + ["ha", "Hausa"], + ["he", "Hebrew"], + ["hz", "Herero"], + ["hi", "Hindi"], + ["ho", "Hiri Motu"], + ["hr", "Croatian"], + ["hu", "Hungarian"], + ["ig", "Igbo"], + ["is", "Icelandic"], + ["io", "Ido"], + ["ii", "Sichuan Yi"], + ["iu", "Inuktitut"], + ["ie", "Interlingue"], + ["ia", "Interlingua"], + ["id", "Indonesian"], + ["ik", "Inupiaq"], + ["it", "Italian"], + ["jv", "Javanese"], + ["ja", "Japanese"], + ["kl", "Kalaallisut"], + ["kn", "Kannada"], + ["ks", "Kashmiri"], + ["kr", "Kanuri"], + ["kk", "Kazakh"], + ["km", "Central Khmer"], + ["ki", "Kikuyu"], + ["rw", "Kinyarwanda"], + ["ky", "Kirghiz"], + ["kv", "Komi"], + ["kg", "Kongo"], + ["ko", "Korean"], + ["kj", "Kuanyama"], + ["ku", "Kurdish"], + ["lo", "Lao"], + ["la", "Latin"], + ["lv", "Latvian"], + ["li", "Limburgan"], + ["ln", "Lingala"], + ["lt", "Lithuanian"], + ["lb", "Luxembourgish"], + ["lu", "Luba-Katanga"], + ["lg", "Ganda"], + ["mk", "Macedonian"], + ["mh", "Marshallese"], + ["ml", "Malayalam"], + ["mi", "Maori"], + ["mr", "Marathi"], + ["ms", "Malay"], + ["mg", "Malagasy"], + ["mt", "Maltese"], + ["mn", "Mongolian"], + ["na", "Nauru"], + ["nv", "Navajo"], + ["nr", "Ndebele, South"], + ["nd", "Ndebele, North"], + ["ng", "Ndonga"], + ["ne", "Nepali"], + ["nn", "Norwegian Nynorsk"], + ["nb", "Bokmål, Norwegian"], + ["no", "Norwegian"], + ["ny", "Chichewa"], + ["oc", "Occitan"], + ["oj", "Ojibwa"], + ["or", "Oriya"], + ["om", "Oromo"], + ["os", "Ossetian"], + ["pa", "Panjabi"], + ["fa", "Persian"], + ["pi", "Pali"], + ["pl", "Polish"], + ["pt", "Portuguese"], + ["ps", "Pushto"], + ["qu", "Quechua"], + ["rm", "Romansh"], + ["ro", "Romanian"], + ["rn", "Rundi"], + ["ru", "Russian"], + ["sg", "Sango"], + ["sa", "Sanskrit"], + ["si", "Sinhala"], + ["sk", "Slovak"], + ["sl", "Slovenian"], + ["se", "Northern Sami"], + ["sm", "Samoan"], + ["sn", "Shona"], + ["sd", "Sindhi"], + ["so", "Somali"], + ["st", "Sotho, Southern"], + ["es", "Spanish"], + ["sc", "Sardinian"], + ["sr", "Serbian"], + ["ss", "Swati"], + ["su", "Sundanese"], + ["sw", "Swahili"], + ["sv", "Swedish"], + ["ty", "Tahitian"], + ["ta", "Tamil"], + ["tt", "Tatar"], + ["te", "Telugu"], + ["tg", "Tajik"], + ["tl", "Tagalog"], + ["th", "Thai"], + ["bo", "Tibetan"], + ["ti", "Tigrinya"], + ["to", "Tonga (Tonga Islands)"], + ["tn", "Tswana"], + ["ts", "Tsonga"], + ["tk", "Turkmen"], + ["tr", "Turkish"], + ["tw", "Twi"], + ["ug", "Uighur"], + ["uk", "Ukrainian"], + ["ur", "Urdu"], + ["uz", "Uzbek"], + ["ve", "Venda"], + ["vi", "Vietnamese"], + ["vo", "Volapük"], + ["cy", "Welsh"], + ["wa", "Walloon"], + ["wo", "Wolof"], + ["xh", "Xhosa"], + ["yi", "Yiddish"], + ["yo", "Yoruba"], + ["za", "Zhuang"], + ["zu", "Zulu"] +] + +LANGUAGE_DICT = dict(LANGUAGES) + +########################## Django Rest Framework ########################### + +REST_FRAMEWORK = { + # These default classes add observability around endpoints using defaults, and should + # not be used anywhere else. + # Notes on Order: + # 1. `JwtAuthentication` does not check `is_active`, so email validation does not affect it. However, + # `SessionAuthentication` does. These work differently, and order changes in what way, which really stinks. See + # https://github.com/openedx/public-engineering/issues/165 for details. + # 2. `JwtAuthentication` may also update the database based on contents. Since the LMS creates these JWTs, this + # shouldn't have any affect at this time. But it could, when and if another service started creating the JWTs. + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'openedx.core.djangolib.default_auth_classes.DefaultJwtAuthentication', + 'openedx.core.djangolib.default_auth_classes.DefaultSessionAuthentication', + ], + 'DEFAULT_PAGINATION_CLASS': 'edx_rest_framework_extensions.paginators.DefaultPagination', + 'DEFAULT_RENDERER_CLASSES': ( + 'rest_framework.renderers.JSONRenderer', + ), + 'EXCEPTION_HANDLER': 'openedx.core.lib.request_utils.ignored_error_exception_handler', + 'PAGE_SIZE': 10, + 'URL_FORMAT_OVERRIDE': None, + 'DEFAULT_THROTTLE_RATES': { + 'user': '60/minute', + 'service_user': '800/minute', + 'registration_validation': '30/minute', + 'high_service_user': '2000/minute', + }, +} + +################################ Heartbeat ################################# + +# Checks run in normal mode by the heartbeat djangoapp +HEARTBEAT_CHECKS = [ + 'openedx.core.djangoapps.heartbeat.default_checks.check_modulestore', + 'openedx.core.djangoapps.heartbeat.default_checks.check_database', +] + +# Other checks to run by default in "extended"/heavy mode +HEARTBEAT_EXTENDED_CHECKS = ( + 'openedx.core.djangoapps.heartbeat.default_checks.check_celery', +) + +HEARTBEAT_CELERY_TIMEOUT = 5 + +############################ RedirectMiddleware ############################ + +# Setting this to None causes Redirect data to never expire +# The cache is cleared when Redirect models are saved/deleted +REDIRECT_CACHE_TIMEOUT = None # The length of time we cache Redirect model data +REDIRECT_CACHE_KEY_PREFIX = 'redirects' + +########################### Django Debug Toolbar ########################### + +# We don't enable Django Debug Toolbar universally, but whenever we do, we want +# to avoid patching settings. Patched settings can cause circular import +# problems: https://django-debug-toolbar.readthedocs.org/en/1.0/installation.html#explicit-setup + +DEBUG_TOOLBAR_PATCH_SETTINGS = False + +################################### JWT #################################### + +JWT_AUTH = { + 'JWT_VERIFY_EXPIRATION': True, + + 'JWT_PAYLOAD_GET_USERNAME_HANDLER': lambda d: d.get('username'), + 'JWT_LEEWAY': 1, + 'JWT_DECODE_HANDLER': 'edx_rest_framework_extensions.auth.jwt.decoder.jwt_decode_handler', + + 'JWT_AUTH_COOKIE': 'edx-jwt-cookie', + + # Number of seconds before JWTs expire + 'JWT_EXPIRATION': 30, + 'JWT_IN_COOKIE_EXPIRATION': 60 * 60, + + 'JWT_LOGIN_CLIENT_ID': 'login-service-client-id', + 'JWT_LOGIN_SERVICE_USERNAME': 'login_service_user', + + 'JWT_SUPPORTED_VERSION': '1.2.0', + + 'JWT_ALGORITHM': 'HS256', + 'JWT_SECRET_KEY': SECRET_KEY, + + 'JWT_SIGNING_ALGORITHM': 'RS512', + 'JWT_PRIVATE_SIGNING_JWK': None, + 'JWT_PUBLIC_SIGNING_JWK_SET': None, + + 'JWT_ISSUER': 'http://127.0.0.1:8000/oauth2', + 'JWT_AUDIENCE': 'change-me', + 'JWT_ISSUERS': [ + { + 'ISSUER': 'http://127.0.0.1:8000/oauth2', + 'AUDIENCE': 'change-me', + 'SECRET_KEY': SECRET_KEY + } + ], + 'JWT_AUTH_COOKIE_HEADER_PAYLOAD': 'edx-jwt-cookie-header-payload', + 'JWT_AUTH_COOKIE_SIGNATURE': 'edx-jwt-cookie-signature', + 'JWT_AUTH_HEADER_PREFIX': 'JWT', +} + +############################ Parental Controls ############################# + +# .. setting_name: PARENTAL_CONSENT_AGE_LIMIT +# .. setting_default: 13 +# .. setting_description: The age at which a learner no longer requires parental consent, +# or ``None`` if parental consent is never required. +PARENTAL_CONSENT_AGE_LIMIT = 13 + +############################### Registration ############################### + +# .. setting_name: REGISTRATION_EMAIL_PATTERNS_ALLOWED +# .. setting_default: None +# .. setting_description: Optional setting to restrict registration / account creation +# to only emails that match a regex in this list. Set to ``None`` to allow any email (default). +REGISTRATION_EMAIL_PATTERNS_ALLOWED = None + +######################### Course Enrollment Modes ########################## + +# The min_price key refers to the minimum price allowed for an instance +# of a particular type of course enrollment mode. This is not to be confused +# with the min_price field of the CourseMode model, which refers to the actual +# price of the CourseMode. +COURSE_ENROLLMENT_MODES = { + "audit": { + "id": 1, + "slug": "audit", + "display_name": _("Audit"), + "min_price": 0, + }, + "verified": { + "id": 2, + "slug": "verified", + "display_name": _("Verified"), + "min_price": 1, + }, + "professional": { + "id": 3, + "slug": "professional", + "display_name": _("Professional"), + "min_price": 1, + }, + "no-id-professional": { + "id": 4, + "slug": "no-id-professional", + "display_name": _("No-Id-Professional"), + "min_price": 0, + }, + "credit": { + "id": 5, + "slug": "credit", + "display_name": _("Credit"), + "min_price": 0, + }, + "honor": { + "id": 6, + "slug": "honor", + "display_name": _("Honor"), + "min_price": 0, + }, + "masters": { + "id": 7, + "slug": "masters", + "display_name": _("Master's"), + "min_price": 0, + }, + "executive-education": { + "id": 8, + "slug": "executive-educations", + "display_name": _("Executive Education"), + "min_price": 1 + }, + "unpaid-executive-education": { + "id": 9, + "slug": "unpaid-executive-education", + "display_name": _("Unpaid Executive Education"), + "min_price": 0 + }, + "paid-executive-education": { + "id": 10, + "slug": "paid-executive-education", + "display_name": _("Paid Executive Education"), + "min_price": 1 + }, + "unpaid-bootcamp": { + "id": 11, + "slug": "unpaid-bootcamp", + "display_name": _("Unpaid Bootcamp"), + "min_price": 0 + }, + "paid-bootcamp": { + "id": 12, + "slug": "paid-bootcamp", + "display_name": _("Paid Bootcamp"), + "min_price": 1 + }, +} + +CONTENT_TYPE_GATE_GROUP_IDS = { + 'limited_access': 1, + 'full_access': 2, +} + +########################## Enterprise Api Client ########################### + +ENTERPRISE_CATALOG_INTERNAL_ROOT_URL = 'http://enterprise.catalog.app:18160' + +ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_KEY = "enterprise-backend-service-key" +ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_SECRET = "enterprise-backend-service-secret" +ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL = "http://127.0.0.1:8000/oauth2" + + +############################### ModuleStore ################################ + +ASSET_IGNORE_REGEX = r"(^\._.*$)|(^\.DS_Store$)|(^.*~$)" + +########################### Django OAuth Toolkit ########################### + +# This is required for the migrations in oauth_dispatch.models +# otherwise it fails saying this attribute is not present in Settings + +# Although Studio does not enable OAuth2 Provider capability, the new approach +# to generating test databases will discover and try to create all tables +# and this setting needs to be present + +OAUTH2_PROVIDER_APPLICATION_MODEL = 'oauth2_provider.Application' + +############################## Profile Image ############################### + +# The following PROFILE_IMAGE_* settings are included as common settings as +# they are indirectly accessed through the email opt-in API, which is +# technically accessible through the CMS via legacy URLs. + +# WARNING: Certain django storage backends do not support atomic +# file overwrites (including the default, OverwriteStorage) - instead +# there are separate calls to delete and then write a new file in the +# storage backend. This introduces the risk of a race condition +# occurring when a user uploads a new profile image to replace an +# earlier one (the file will temporarily be deleted). +PROFILE_IMAGE_BACKEND = { + 'class': 'openedx.core.storage.OverwriteStorage', + 'options': { + 'location': os.path.join(MEDIA_ROOT, 'profile-images/'), + 'base_url': os.path.join(MEDIA_URL, 'profile-images/'), + }, +} +PROFILE_IMAGE_DEFAULT_FILENAME = 'images/profiles/default' +PROFILE_IMAGE_DEFAULT_FILE_EXTENSION = 'png' +# This key is used in generating unguessable URLs to users' +# profile images. Once it has been set, changing it will make the +# platform unaware of current image URLs. +PROFILE_IMAGE_HASH_SEED = 'placeholder_secret_key' +PROFILE_IMAGE_MAX_BYTES = 1024 * 1024 +PROFILE_IMAGE_MIN_BYTES = 100 +PROFILE_IMAGE_SIZES_MAP = { + 'full': 500, + 'large': 120, + 'medium': 50, + 'small': 30 +} + +######################## Built-in Blocks Extraction ######################## + +# The following Django settings flags have been introduced temporarily to facilitate +# the rollout of the extracted built-in Blocks. Flags will use to toggle between +# the old and new block quickly without putting course content or user state at risk. +# +# Ticket: https://github.com/openedx/edx-platform/issues/35308 + +# .. toggle_name: USE_EXTRACTED_WORD_CLOUD_BLOCK +# .. toggle_default: False +# .. toggle_implementation: DjangoSetting +# .. toggle_description: Enables the use of the extracted Word Cloud XBlock, which has been shifted to the 'openedx/xblocks-contrib' repo. +# .. toggle_use_cases: temporary +# .. toggle_warning: Not production-ready until https://github.com/openedx/edx-platform/issues/34840 is done. +# .. toggle_creation_date: 2024-11-10 +# .. toggle_target_removal_date: 2025-06-01 +USE_EXTRACTED_WORD_CLOUD_BLOCK = False + +# .. toggle_name: USE_EXTRACTED_ANNOTATABLE_BLOCK +# .. toggle_default: False +# .. toggle_implementation: DjangoSetting +# .. toggle_description: Enables the use of the extracted annotatable XBlock, which has been shifted to the 'openedx/xblocks-contrib' repo. +# .. toggle_use_cases: temporary +# .. toggle_warning: Not production-ready until https://github.com/openedx/edx-platform/issues/34841 is done. +# .. toggle_creation_date: 2024-11-10 +# .. toggle_target_removal_date: 2025-06-01 +USE_EXTRACTED_ANNOTATABLE_BLOCK = False + +# .. toggle_name: USE_EXTRACTED_POLL_QUESTION_BLOCK +# .. toggle_default: False +# .. toggle_implementation: DjangoSetting +# .. toggle_description: Enables the use of the extracted poll question XBlock, which has been shifted to the 'openedx/xblocks-contrib' repo. +# .. toggle_use_cases: temporary +# .. toggle_warning: Not production-ready until https://github.com/openedx/edx-platform/issues/34839 is done. +# .. toggle_creation_date: 2024-11-10 +# .. toggle_target_removal_date: 2025-06-01 +USE_EXTRACTED_POLL_QUESTION_BLOCK = False + +# .. toggle_name: USE_EXTRACTED_LTI_BLOCK +# .. toggle_default: False +# .. toggle_implementation: DjangoSetting +# .. toggle_description: Enables the use of the extracted LTI XBlock, which has been shifted to the 'openedx/xblocks-contrib' repo. +# .. toggle_use_cases: temporary +# .. toggle_warning: Not production-ready until relevant subtask https://github.com/openedx/edx-platform/issues/34827 is done. +# .. toggle_creation_date: 2024-11-10 +# .. toggle_target_removal_date: 2025-06-01 +USE_EXTRACTED_LTI_BLOCK = False + +# .. toggle_name: USE_EXTRACTED_HTML_BLOCK +# .. toggle_default: False +# .. toggle_implementation: DjangoSetting +# .. toggle_description: Enables the use of the extracted HTML XBlock, which has been shifted to the 'openedx/xblocks-contrib' repo. +# .. toggle_use_cases: temporary +# .. toggle_warning: Not production-ready until relevant subtask https://github.com/openedx/edx-platform/issues/34827 is done. +# .. toggle_creation_date: 2024-11-10 +# .. toggle_target_removal_date: 2025-06-01 +USE_EXTRACTED_HTML_BLOCK = False + +# .. toggle_name: USE_EXTRACTED_DISCUSSION_BLOCK +# .. toggle_default: False +# .. toggle_implementation: DjangoSetting +# .. toggle_description: Enables the use of the extracted Discussion XBlock, which has been shifted to the 'openedx/xblocks-contrib' repo. +# .. toggle_use_cases: temporary +# .. toggle_warning: Not production-ready until relevant subtask https://github.com/openedx/edx-platform/issues/34827 is done. +# .. toggle_creation_date: 2024-11-10 +# .. toggle_target_removal_date: 2025-06-01 +USE_EXTRACTED_DISCUSSION_BLOCK = False + +# .. toggle_name: USE_EXTRACTED_PROBLEM_BLOCK +# .. toggle_default: False +# .. toggle_implementation: DjangoSetting +# .. toggle_description: Enables the use of the extracted Problem XBlock, which has been shifted to the 'openedx/xblocks-contrib' repo. +# .. toggle_use_cases: temporary +# .. toggle_warning: Not production-ready until relevant subtask https://github.com/openedx/edx-platform/issues/34827 is done. +# .. toggle_creation_date: 2024-11-10 +# .. toggle_target_removal_date: 2025-06-01 +USE_EXTRACTED_PROBLEM_BLOCK = False + +# .. toggle_name: USE_EXTRACTED_VIDEO_BLOCK +# .. toggle_default: False +# .. toggle_implementation: DjangoSetting +# .. toggle_description: Enables the use of the extracted Video XBlock, which has been shifted to the 'openedx/xblocks-contrib' repo. +# .. toggle_use_cases: temporary +# .. toggle_warning: Not production-ready until relevant subtask https://github.com/openedx/edx-platform/issues/34827 is done. +# .. toggle_creation_date: 2024-11-10 +# .. toggle_target_removal_date: 2025-06-01 +USE_EXTRACTED_VIDEO_BLOCK = False + +############################## Miscellaneous ############################### + +COURSE_MODE_DEFAULTS = { + 'android_sku': None, + 'bulk_sku': None, + 'currency': 'usd', + 'description': None, + 'expiration_datetime': None, + 'ios_sku': None, + 'min_price': 0, + 'name': _('Audit'), + 'sku': None, + 'slug': 'audit', + 'suggested_prices': '', +} + +DEFAULT_COURSE_ABOUT_IMAGE_URL = 'images/pencils.jpg' + +DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH = "verify_student_disable_account_activation_requirement" + +# If this is true, random scores will be generated for the purpose of debugging the profile graphs +GENERATE_PROFILE_SCORES = False + +# The space is required for space-dependent languages like Arabic and Farsi. +# However, backward compatibility with Ficus older releases is still maintained (space is still not valid) +# in the AccountCreationForm and the user_api through the ENABLE_UNICODE_USERNAME feature flag. +USERNAME_REGEX_PARTIAL = r'[\w .@_+-]+' +USERNAME_PATTERN = fr'(?P{USERNAME_REGEX_PARTIAL})' diff --git a/openedx/features/announcements/static/announcements/jsx/__snapshots__/Announcements.test.jsx.snap b/openedx/features/announcements/static/announcements/jsx/__snapshots__/Announcements.test.jsx.snap index 70eb30a812..bbf9bfaaaa 100644 --- a/openedx/features/announcements/static/announcements/jsx/__snapshots__/Announcements.test.jsx.snap +++ b/openedx/features/announcements/static/announcements/jsx/__snapshots__/Announcements.test.jsx.snap @@ -13,7 +13,7 @@ exports[`Announcements component render test announcements 1`] = `
    Announcement 2", } } @@ -29,7 +29,7 @@ exports[`Announcements component render test announcements 1`] = `
    2 && usage_id !== path[path.length - 1]) { + params.append('jumpToId', usage_id); + } + if (params.size > 0) { + // Pass nested block details via query parameters for it to be passed to learning mfe + // The learning mfe should pass it back to unit xblock via iframe url params. + // This would allow us to scroll to the child xblock. + url = url + '?' + params.toString(); + } + return url; + } }); + }); }(define || RequireJS.define)); diff --git a/openedx/features/course_bookmarks/static/course_bookmarks/js/views/bookmark_button.js b/openedx/features/course_bookmarks/static/course_bookmarks/js/views/bookmark_button.js index 838f631868..3612038842 100644 --- a/openedx/features/course_bookmarks/static/course_bookmarks/js/views/bookmark_button.js +++ b/openedx/features/course_bookmarks/static/course_bookmarks/js/views/bookmark_button.js @@ -20,6 +20,12 @@ this.bookmarkId = options.bookmarkId; this.bookmarked = options.bookmarked; this.usageId = options.usageId; + if (options.bookmarkedText) { + this.bookmarkedText = options.bookmarkedText; + } + if (options.bookmarkText) { + this.bookmarkText = options.bookmarkText; + } this.setBookmarkState(this.bookmarked); }, diff --git a/openedx/features/course_bookmarks/static/course_bookmarks/js/views/bookmarks_list.js b/openedx/features/course_bookmarks/static/course_bookmarks/js/views/bookmarks_list.js index 52f5fbd74c..55dd1bd58a 100644 --- a/openedx/features/course_bookmarks/static/course_bookmarks/js/views/bookmarks_list.js +++ b/openedx/features/course_bookmarks/static/course_bookmarks/js/views/bookmarks_list.js @@ -78,9 +78,7 @@ component_type: componentType, component_usage_id: componentUsageId } - ).always(function() { - window.location.href = event.currentTarget.pathname; - }); + ); }, /** diff --git a/openedx/features/course_experience/.eslintrc.js b/openedx/features/course_experience/.eslintrc.js deleted file mode 100644 index 752a664a53..0000000000 --- a/openedx/features/course_experience/.eslintrc.js +++ /dev/null @@ -1,18 +0,0 @@ -module.exports = { - extends: '@edx/eslint-config', - root: true, - settings: { - 'import/resolver': { - webpack: { - config: 'webpack.dev.config.js', - }, - }, - }, - rules: { - indent: ['error', 4], - 'react/jsx-indent': ['error', 4], - 'react/jsx-indent-props': ['error', 4], - 'import/extensions': 'off', - 'import/no-unresolved': 'off', - }, -}; diff --git a/openedx/features/course_experience/__init__.py b/openedx/features/course_experience/__init__.py index a45d863e09..c5f11a3013 100644 --- a/openedx/features/course_experience/__init__.py +++ b/openedx/features/course_experience/__init__.py @@ -18,9 +18,6 @@ DISABLE_COURSE_OUTLINE_PAGE_FLAG = CourseWaffleFlag( # lint-amnesty, pylint: di f'{WAFFLE_FLAG_NAMESPACE}.disable_course_outline_page', __name__ ) -# Waffle flag to enable the sock on the footer of the home and courseware pages. -DISPLAY_COURSE_SOCK_FLAG = CourseWaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.display_course_sock', __name__) # lint-amnesty, pylint: disable=toggle-missing-annotation - # Waffle flag to let learners access a course before its start date. COURSE_PRE_START_ACCESS_FLAG = WaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.pre_start_access', __name__) # lint-amnesty, pylint: disable=toggle-missing-annotation @@ -104,7 +101,9 @@ def default_course_url(course_key): from .url_helpers import get_learning_mfe_home_url if DISABLE_COURSE_OUTLINE_PAGE_FLAG.is_enabled(course_key): - return reverse('courseware', args=[str(course_key)]) + # Prevent a circular dependency + from openedx.features.course_experience.url_helpers import make_learning_mfe_courseware_url + return make_learning_mfe_courseware_url(course_key) return get_learning_mfe_home_url(course_key, url_fragment='home') diff --git a/openedx/features/course_experience/static/course_experience/js/CourseSock.js b/openedx/features/course_experience/static/course_experience/js/CourseSock.js deleted file mode 100644 index 676be0a074..0000000000 --- a/openedx/features/course_experience/static/course_experience/js/CourseSock.js +++ /dev/null @@ -1,90 +0,0 @@ -/* globals Logger */ - -export class CourseSock { // eslint-disable-line import/prefer-default-export - constructor() { - // eslint-disable-next-line no-undef - const $toggleActionButton = $('.action-toggle-verification-sock'); - // eslint-disable-next-line no-undef - const $verificationSock = $('.verification-sock .verification-main-panel'); - // eslint-disable-next-line no-undef - const $upgradeToVerifiedButton = $('.verification-sock .action-upgrade-certificate'); - // eslint-disable-next-line no-undef - const $miniCert = $('.mini-cert'); - const pageLocation = window.location.href.indexOf('courseware') > -1 - ? 'Course Content Page' : 'Home Page'; - - // Behavior to fix button to bottom of screen on scroll - const fixUpgradeButton = () => { - if (!$upgradeToVerifiedButton.is(':visible')) { return; } - - // Grab the current scroll location - // eslint-disable-next-line no-undef - const documentBottom = $(window).scrollTop() + $(window).height(); - - // Establish a sliding window in which the button is fixed - const startFixed = $verificationSock.offset().top + 320; - const endFixed = (startFixed + $verificationSock.height()) - 220; - - // Ensure update button stays in sock even when max-width is exceeded - const distRight = window.outerWidth - ($miniCert.offset().left + $miniCert.width()); - - // Update positioning when scrolling is in fixed window and screen width is sufficient - if ((documentBottom > startFixed && documentBottom < endFixed - // eslint-disable-next-line no-undef - && $(window).width() > 960)) { - $upgradeToVerifiedButton.addClass('attached'); - $upgradeToVerifiedButton.css('right', `${distRight}px`); - } else { - // If outside sliding window, reset to un-attached state - $upgradeToVerifiedButton.removeClass('attached'); - $upgradeToVerifiedButton.css('right', '20px'); - - // Add class to define absolute location - if (documentBottom < startFixed) { - $upgradeToVerifiedButton.addClass('stuck-top'); - $upgradeToVerifiedButton.removeClass('stuck-bottom'); - } else if (documentBottom > endFixed) { - $upgradeToVerifiedButton.addClass('stuck-bottom'); - $upgradeToVerifiedButton.removeClass('stuck-top'); - } - } - }; - - // Fix the sock to the screen on scroll and resize events - if ($upgradeToVerifiedButton.length) { - // eslint-disable-next-line no-undef - $(window).scroll(fixUpgradeButton).resize(fixUpgradeButton); - } - - // Open the sock when user clicks to Learn More - $toggleActionButton.on('click', () => { - const toggleSpeed = 400; - $toggleActionButton.toggleClass('active'); - $verificationSock.slideToggle(toggleSpeed, fixUpgradeButton); - - // Toggle aria-expanded attribute - const newAriaExpandedState = $toggleActionButton.attr('aria-expanded') === 'false'; - $toggleActionButton.attr('aria-expanded', newAriaExpandedState); - - // Log open and close events - const isOpening = $toggleActionButton.hasClass('active'); - const logMessage = isOpening ? 'edx.bi.course.sock.toggle_opened' - : 'edx.bi.course.sock.toggle_closed'; - window.analytics.track( - logMessage, - { - from_page: pageLocation, - }, - ); - }); - - $upgradeToVerifiedButton.on('click', () => { - Logger.log( - 'edx.course.enrollment.upgrade.clicked', - { - location: 'sock', - }, - ); - }); - } -} diff --git a/openedx/features/course_experience/templates/course_experience/course-sock-fragment.html b/openedx/features/course_experience/templates/course_experience/course-sock-fragment.html deleted file mode 100644 index ede2a7f2b2..0000000000 --- a/openedx/features/course_experience/templates/course_experience/course-sock-fragment.html +++ /dev/null @@ -1,76 +0,0 @@ -## mako - -<%page expression_filter="h"/> -<%namespace name='static' file='../static_content.html'/> - -<%! -from django.utils.translation import gettext as _ -from openedx.core.djangolib.markup import HTML, Text -from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG -%> - -<%block name="content"> - % if show_course_sock: - - %endif - - -<%static:webpack entry="CourseSock"> - new CourseSock({ - el:'.verification-sock' - }); - diff --git a/openedx/features/course_experience/tests/test_url_helpers.py b/openedx/features/course_experience/tests/test_url_helpers.py index dfa84583ab..b6f134f780 100644 --- a/openedx/features/course_experience/tests/test_url_helpers.py +++ b/openedx/features/course_experience/tests/test_url_helpers.py @@ -1,8 +1,6 @@ """ Test some of the functions in url_helpers """ -from unittest import mock - import ddt from django.test import TestCase from django.test.client import RequestFactory @@ -14,14 +12,6 @@ from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory from .. import url_helpers -def _patch_courseware_mfe_is_active(ret_val): - return mock.patch.object( - url_helpers, - 'courseware_mfe_is_active', - return_value=ret_val, - ) - - @ddt.ddt class IsLearningMfeTests(TestCase): """ @@ -53,8 +43,6 @@ class IsLearningMfeTests(TestCase): class GetCoursewareUrlTests(SharedModuleStoreTestCase): """ Test get_courseware_url. - - Mock out `courseware_mfe_is_active`; that is tested elseware. """ @classmethod @@ -121,12 +109,10 @@ class GetCoursewareUrlTests(SharedModuleStoreTestCase): @ddt.data( ( - 'mfe', 'course_run', 'http://learning-mfe/course/course-v1:TestX+UrlHelpers+split' ), ( - 'mfe', 'section', ( 'http://learning-mfe/course/course-v1:TestX+UrlHelpers+split' + @@ -134,7 +120,6 @@ class GetCoursewareUrlTests(SharedModuleStoreTestCase): ), ), ( - 'mfe', 'subsection', ( 'http://learning-mfe/course/course-v1:TestX+UrlHelpers+split' + @@ -142,7 +127,6 @@ class GetCoursewareUrlTests(SharedModuleStoreTestCase): ), ), ( - 'mfe', 'unit', ( 'http://learning-mfe/course/course-v1:TestX+UrlHelpers+split' + @@ -151,7 +135,6 @@ class GetCoursewareUrlTests(SharedModuleStoreTestCase): ), ), ( - 'mfe', 'component', ( 'http://learning-mfe/course/course-v1:TestX+UrlHelpers+split' + @@ -159,31 +142,10 @@ class GetCoursewareUrlTests(SharedModuleStoreTestCase): '/block-v1:TestX+UrlHelpers+split+type@vertical+block@Generated_Unit' ), ), - ( - 'legacy', - 'course_run', - '/courses/course-v1:TestX+UrlHelpers+split/courseware', - ), - ( - 'legacy', - 'subsection', - '/courses/course-v1:TestX+UrlHelpers+split/courseware/Generated_Section/Generated_Subsection/', - ), - ( - 'legacy', - 'unit', - '/courses/course-v1:TestX+UrlHelpers+split/courseware/Generated_Section/Generated_Subsection/1', - ), - ( - 'legacy', - 'component', - '/courses/course-v1:TestX+UrlHelpers+split/courseware/Generated_Section/Generated_Subsection/1', - ) ) @ddt.unpack def test_get_courseware_url( self, - active_experience, structure_level, expected_path, ): @@ -196,9 +158,7 @@ class GetCoursewareUrlTests(SharedModuleStoreTestCase): check that the expected path (URL without querystring) is returned by `get_courseware_url`. """ block = self.items[structure_level] - with _patch_courseware_mfe_is_active(active_experience == 'mfe') as mock_mfe_is_active: - url = url_helpers.get_courseware_url(block.location) + url = url_helpers.get_courseware_url(block.location) path = url.split('?')[0] assert path == expected_path course_run = self.items['course_run'] - mock_mfe_is_active.assert_called_once() diff --git a/openedx/features/course_experience/tests/views/test_course_sock.py b/openedx/features/course_experience/tests/views/test_course_sock.py deleted file mode 100644 index 5c612e3ca8..0000000000 --- a/openedx/features/course_experience/tests/views/test_course_sock.py +++ /dev/null @@ -1,117 +0,0 @@ -""" -Tests for course verification sock -""" - - -from unittest import mock - -import ddt -from django.urls import reverse -from edx_toggles.toggles.testutils import override_waffle_flag - -from common.djangoapps.course_modes.models import CourseMode -from lms.djangoapps.commerce.models import CommerceConfiguration -from lms.djangoapps.courseware.tests.helpers import set_preview_mode -from openedx.core.djangolib.markup import HTML -from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG -from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order - -from .helpers import add_course_mode - -TEST_PASSWORD = 'Password1234' -TEST_VERIFICATION_SOCK_LOCATOR = '
    DISCOUNT_PRICE"), True)) - ) - def test_upgrade_message_discount(self): - response = self.get_courseware(self.verified_course) - self.assertContains(response, "DISCOUNT_PRICE") - - def assert_verified_sock_is_visible(self, course, response): # lint-amnesty, pylint: disable=unused-argument - return self.assertContains(response, TEST_VERIFICATION_SOCK_LOCATOR, html=False) - - def assert_verified_sock_is_not_visible(self, course, response): # lint-amnesty, pylint: disable=unused-argument - return self.assertNotContains(response, TEST_VERIFICATION_SOCK_LOCATOR, html=False) diff --git a/openedx/features/course_experience/tests/views/test_masquerade.py b/openedx/features/course_experience/tests/views/test_masquerade.py index 0f555e27f7..f0c110d8e9 100644 --- a/openedx/features/course_experience/tests/views/test_masquerade.py +++ b/openedx/features/course_experience/tests/views/test_masquerade.py @@ -2,11 +2,7 @@ Tests for masquerading functionality on course_experience """ -from django.urls import reverse - -from edx_toggles.toggles.testutils import override_waffle_flag -from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin, set_preview_mode -from openedx.features.course_experience import DISPLAY_COURSE_SOCK_FLAG +from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin from common.djangoapps.student.roles import CourseStaffRole from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order @@ -15,7 +11,6 @@ from xmodule.partitions.partitions import ENROLLMENT_TRACK_PARTITION_ID # lint- from xmodule.partitions.partitions_service import PartitionService # lint-amnesty, pylint: disable=wrong-import-order from .helpers import add_course_mode -from .test_course_sock import TEST_VERIFICATION_SOCK_LOCATOR TEST_PASSWORD = 'Password1234' @@ -59,40 +54,3 @@ class MasqueradeTestBase(SharedModuleStoreTestCase, MasqueradeMixin): if group.name == mode_name: return group.id return None - - -@set_preview_mode(True) -class TestVerifiedUpgradesWithMasquerade(MasqueradeTestBase): - """ - Tests for the course verification upgrade messages while the user is being masqueraded. - """ - - @override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True) - def test_masquerade_as_student(self): - # Elevate the staff user to be student - self.update_masquerade(course=self.verified_course, user_partition_id=ENROLLMENT_TRACK_PARTITION_ID) - response = self.client.get(reverse('courseware', kwargs={'course_id': str(self.verified_course.id)})) - self.assertContains(response, TEST_VERIFICATION_SOCK_LOCATOR, html=False) - - @override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True) - def test_masquerade_as_verified_student(self): - user_group_id = self.get_group_id_by_course_mode_name( - self.verified_course.id, - 'Verified Certificate' - ) - self.update_masquerade(course=self.verified_course, group_id=user_group_id, - user_partition_id=ENROLLMENT_TRACK_PARTITION_ID) - response = self.client.get(reverse('courseware', kwargs={'course_id': str(self.verified_course.id)})) - self.assertNotContains(response, TEST_VERIFICATION_SOCK_LOCATOR, html=False) - - @override_waffle_flag(DISPLAY_COURSE_SOCK_FLAG, active=True) - def test_masquerade_as_masters_student(self): - user_group_id = self.get_group_id_by_course_mode_name( - self.masters_course.id, - 'Masters' - ) - self.update_masquerade(course=self.masters_course, group_id=user_group_id, - user_partition_id=ENROLLMENT_TRACK_PARTITION_ID) - response = self.client.get(reverse('courseware', kwargs={'course_id': str(self.masters_course.id)})) - - self.assertNotContains(response, TEST_VERIFICATION_SOCK_LOCATOR, html=False) diff --git a/openedx/features/course_experience/url_helpers.py b/openedx/features/course_experience/url_helpers.py index e1fe4110c6..d80ac51158 100644 --- a/openedx/features/course_experience/url_helpers.py +++ b/openedx/features/course_experience/url_helpers.py @@ -14,7 +14,6 @@ from django.urls import reverse from opaque_keys.edx.keys import CourseKey, UsageKey from six.moves.urllib.parse import urlencode, urlparse -from lms.djangoapps.courseware.toggles import courseware_mfe_is_active from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.search import navigation_index, path_to_location # lint-amnesty, pylint: disable=wrong-import-order @@ -42,11 +41,7 @@ def get_courseware_url( * ItemNotFoundError if no data at the `usage_key`. * NoPathToItem if we cannot build a path to the `usage_key`. """ - if courseware_mfe_is_active(): - get_url_fn = _get_new_courseware_url - else: - get_url_fn = _get_legacy_courseware_url - return get_url_fn(usage_key=usage_key, request=request, is_staff=is_staff) + return _get_new_courseware_url(usage_key=usage_key, request=request, is_staff=is_staff) def _get_legacy_courseware_url( diff --git a/openedx/features/course_experience/views/course_sock.py b/openedx/features/course_experience/views/course_sock.py deleted file mode 100644 index af3221315d..0000000000 --- a/openedx/features/course_experience/views/course_sock.py +++ /dev/null @@ -1,47 +0,0 @@ -""" -Fragment for rendering the course's sock and associated toggle button. -""" - -from django.template.loader import render_to_string -from web_fragments.fragment import Fragment - -from lms.djangoapps.courseware.utils import ( - can_show_verified_upgrade, - verified_upgrade_deadline_link -) -from openedx.core.djangoapps.plugin_api.views import EdxFragmentView -from openedx.features.discounts.utils import format_strikeout_price -from common.djangoapps.student.models import CourseEnrollment - - -class CourseSockFragmentView(EdxFragmentView): - """ - A fragment to provide extra functionality in a dropdown sock. - """ - def render_to_fragment(self, request, course, **kwargs): # lint-amnesty, pylint: disable=arguments-differ - """ - Render the course's sock fragment. - """ - context = self.get_verification_context(request, course) - html = render_to_string('course_experience/course-sock-fragment.html', context) - return Fragment(html) - - @staticmethod - def get_verification_context(request, course): # lint-amnesty, pylint: disable=missing-function-docstring - enrollment = CourseEnrollment.get_enrollment(request.user, course.id) - show_course_sock = can_show_verified_upgrade(request.user, enrollment, course) - if show_course_sock: - upgrade_url = verified_upgrade_deadline_link(request.user, course=course) - course_price, _ = format_strikeout_price(request.user, course) - else: - upgrade_url = '' - course_price = '' - - context = { - 'show_course_sock': show_course_sock, - 'course_price': course_price, - 'course_id': course.id, - 'upgrade_url': upgrade_url, - } - - return context diff --git a/openedx/features/course_search/README.rst b/openedx/features/course_search/README.rst index c6925c2967..8d6e394c3e 100644 --- a/openedx/features/course_search/README.rst +++ b/openedx/features/course_search/README.rst @@ -4,4 +4,4 @@ Course Search This directory contains a Django application that allows a learner to search the content of their course. To learn more, see `Enabling Open edX Search`_. -.. _Enabling Open edX Search: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/edx_search.html +.. _Enabling Open edX Search: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/edx_search.html diff --git a/openedx/features/enterprise_support/tests/test_utils.py b/openedx/features/enterprise_support/tests/test_utils.py index d693f6be72..48a331083f 100644 --- a/openedx/features/enterprise_support/tests/test_utils.py +++ b/openedx/features/enterprise_support/tests/test_utils.py @@ -29,6 +29,7 @@ from openedx.features.enterprise_support.tests.factories import ( ) from openedx.features.enterprise_support.utils import ( ENTERPRISE_HEADER_LINKS, + _user_has_social_auth_record, clear_data_consent_share_cache, enterprise_fields_only, fetch_enterprise_customer_by_id, @@ -539,6 +540,54 @@ class TestEnterpriseUtils(TestCase): ) assert not mock_next_login_url.called + @mock.patch('openedx.features.enterprise_support.utils.UserSocialAuth') + @mock.patch('openedx.features.enterprise_support.utils.third_party_auth') + def test_user_has_social_auth_record(self, mock_tpa, mock_user_social_auth): + user = mock.Mock() + enterprise_customer = { + 'identity_providers': [ + {'provider_id': 'mock-idp'}, + ], + } + mock_idp = mock.MagicMock(backend_name='mock-backend') + mock_tpa.provider.Registry.get.return_value = mock_idp + mock_user_social_auth.objects.select_related.return_value.filter.return_value.exists.return_value = True + + result = _user_has_social_auth_record(user, enterprise_customer) + assert result is True + + mock_tpa.provider.Registry.get.assert_called_once_with(provider_id='mock-idp') + mock_user_social_auth.objects.select_related.assert_called_once_with('user') + mock_user_social_auth.objects.select_related.return_value.filter.assert_called_once_with( + provider__in=['mock-backend'], user=user + ) + + @mock.patch('openedx.features.enterprise_support.utils.UserSocialAuth') + @mock.patch('openedx.features.enterprise_support.utils.third_party_auth') + def test_user_has_social_auth_record_no_providers(self, mock_tpa, mock_user_social_auth): + user = mock.Mock() + enterprise_customer = { + 'identity_providers': [], + } + + result = _user_has_social_auth_record(user, enterprise_customer) + assert result is False + + assert not mock_tpa.provider.Registry.get.called + assert not mock_user_social_auth.objects.select_related.called + + @mock.patch('openedx.features.enterprise_support.utils.UserSocialAuth') + @mock.patch('openedx.features.enterprise_support.utils.third_party_auth') + def test_user_has_social_auth_record_no_enterprise_customer(self, mock_tpa, mock_user_social_auth): + user = mock.Mock() + enterprise_customer = None + + result = _user_has_social_auth_record(user, enterprise_customer) + assert result is False + + assert not mock_tpa.provider.Registry.get.called + assert not mock_user_social_auth.objects.select_related.called + @override_settings(FEATURES=FEATURES_WITH_ENTERPRISE_ENABLED) @skip_unless_lms diff --git a/openedx/features/enterprise_support/utils.py b/openedx/features/enterprise_support/utils.py index 6b007ebed5..9e99bf5992 100644 --- a/openedx/features/enterprise_support/utils.py +++ b/openedx/features/enterprise_support/utils.py @@ -294,9 +294,12 @@ def _user_has_social_auth_record(user, enterprise_customer): identity_provider = third_party_auth.provider.Registry.get( provider_id=idp['provider_id'] ) - provider_backend_names.append(identity_provider.backend_name) - return UserSocialAuth.objects.select_related('user').\ - filter(provider__in=provider_backend_names, user=user).exists() + if identity_provider and hasattr(identity_provider, 'backend_name'): + provider_backend_names.append(identity_provider.backend_name) + + if provider_backend_names: + return UserSocialAuth.objects.select_related('user').\ + filter(provider__in=provider_backend_names, user=user).exists() return False diff --git a/openedx/features/learner_profile/README.rst b/openedx/features/learner_profile/README.rst deleted file mode 100644 index 0dce8e10cc..0000000000 --- a/openedx/features/learner_profile/README.rst +++ /dev/null @@ -1,8 +0,0 @@ -Learner Profile ---------------- - -This directory contains a Django application that provides a view to render -a profile for any Open edX learner. See `Exploring Your Dashboard and Profile`_ -for more details. - -.. _Exploring Your Dashboard and Profile: https://edx.readthedocs.io/projects/open-edx-learner-guide/en/latest/SFD_dashboard_profile_SectionHead.html?highlight=profile diff --git a/openedx/features/learner_profile/static/learner_profile/fixtures/learner_profile.html b/openedx/features/learner_profile/static/learner_profile/fixtures/learner_profile.html deleted file mode 100644 index 61c139210a..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/fixtures/learner_profile.html +++ /dev/null @@ -1,40 +0,0 @@ -
    -
    -
    - - -
    -
    -

    - - - - - Loading - -

    -
    - -
    diff --git a/openedx/features/learner_profile/static/learner_profile/js/learner_profile_factory.js b/openedx/features/learner_profile/static/learner_profile/js/learner_profile_factory.js deleted file mode 100644 index d4f81ae7f4..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/learner_profile_factory.js +++ /dev/null @@ -1,219 +0,0 @@ -(function(define) { - 'use strict'; - - define([ - 'gettext', - 'jquery', - 'underscore', - 'backbone', - 'logger', - 'edx-ui-toolkit/js/utils/string-utils', - 'edx-ui-toolkit/js/pagination/paging-collection', - 'js/student_account/models/user_account_model', - 'js/student_account/models/user_preferences_model', - 'js/views/fields', - 'learner_profile/js/views/learner_profile_fields', - 'learner_profile/js/views/learner_profile_view', - 'js/student_account/views/account_settings_fields', - 'js/views/message_banner', - 'string_utils' - ], function(gettext, $, _, Backbone, Logger, StringUtils, PagingCollection, AccountSettingsModel, - AccountPreferencesModel, FieldsView, LearnerProfileFieldsView, LearnerProfileView, - AccountSettingsFieldViews, MessageBannerView) { - return function(options) { - var $learnerProfileElement = $('.wrapper-profile'); - - var accountSettingsModel = new AccountSettingsModel( - _.extend( - options.account_settings_data, - { - default_public_account_fields: options.default_public_account_fields, - parental_consent_age_limit: options.parental_consent_age_limit, - enable_coppa_compliance: options.enable_coppa_compliance - } - ), - {parse: true} - ); - var AccountPreferencesModelWithDefaults = AccountPreferencesModel.extend({ - defaults: { - account_privacy: options.default_visibility - } - }); - var accountPreferencesModel = new AccountPreferencesModelWithDefaults(options.preferences_data); - - var editable = options.own_profile ? 'toggle' : 'never'; - - var messageView = new MessageBannerView({ - el: $('.message-banner') - }); - - var accountPrivacyFieldView, - profileImageFieldView, - usernameFieldView, - nameFieldView, - sectionOneFieldViews, - sectionTwoFieldViews, - learnerProfileView, - getProfileVisibility, - showLearnerProfileView; - - accountSettingsModel.url = options.accounts_api_url; - accountPreferencesModel.url = options.preferences_api_url; - - accountPrivacyFieldView = new LearnerProfileFieldsView.AccountPrivacyFieldView({ - model: accountPreferencesModel, - required: true, - editable: 'always', - showMessages: false, - title: gettext('Profile Visibility:'), - valueAttribute: 'account_privacy', - options: [ - ['private', gettext('Limited Profile')], - ['all_users', gettext('Full Profile')] - ], - helpMessage: '', - accountSettingsPageUrl: options.account_settings_page_url, - persistChanges: true - }); - - profileImageFieldView = new LearnerProfileFieldsView.ProfileImageFieldView({ - model: accountSettingsModel, - valueAttribute: 'profile_image', - editable: editable === 'toggle', - messageView: messageView, - imageMaxBytes: options.profile_image_max_bytes, - imageMinBytes: options.profile_image_min_bytes, - imageUploadUrl: options.profile_image_upload_url, - imageRemoveUrl: options.profile_image_remove_url - }); - - usernameFieldView = new FieldsView.ReadonlyFieldView({ - model: accountSettingsModel, - screenReaderTitle: gettext('Username'), - valueAttribute: 'username', - helpMessage: '' - }); - - nameFieldView = new FieldsView.ReadonlyFieldView({ - model: accountSettingsModel, - screenReaderTitle: gettext('Full Name'), - valueAttribute: 'name', - helpMessage: '' - }); - - sectionOneFieldViews = [ - new LearnerProfileFieldsView.SocialLinkIconsView({ - model: accountSettingsModel, - socialPlatforms: options.social_platforms, - ownProfile: options.own_profile - }), - - new FieldsView.DateFieldView({ - title: gettext('Joined'), - titleVisible: true, - model: accountSettingsModel, - screenReaderTitle: gettext('Joined Date'), - valueAttribute: 'date_joined', - helpMessage: '', - userLanguage: accountSettingsModel.get('language'), - userTimezone: accountPreferencesModel.get('time_zone'), - dateFormat: 'MMMM YYYY' // not localized, but hopefully ok. - }), - - new FieldsView.DropdownFieldView({ - title: gettext('Location'), - titleVisible: true, - model: accountSettingsModel, - screenReaderTitle: gettext('Country'), - required: true, - editable: editable, - showMessages: false, - placeholderValue: gettext('Add Country'), - valueAttribute: 'country', - options: options.country_options, - helpMessage: '', - persistChanges: true - }), - - new AccountSettingsFieldViews.LanguageProficienciesFieldView({ - title: gettext('Language'), - titleVisible: true, - model: accountSettingsModel, - screenReaderTitle: gettext('Preferred Language'), - required: false, - editable: editable, - showMessages: false, - placeholderValue: gettext('Add language'), - valueAttribute: 'language_proficiencies', - options: options.language_options, - helpMessage: '', - persistChanges: true - }) - ]; - - sectionTwoFieldViews = [ - new FieldsView.TextareaFieldView({ - model: accountSettingsModel, - editable: editable, - showMessages: false, - title: gettext('About me'), - // eslint-disable-next-line max-len - placeholderValue: gettext("Tell other learners a little about yourself: where you live, what your interests are, why you're taking courses, or what you hope to learn."), - valueAttribute: 'bio', - helpMessage: '', - persistChanges: true, - messagePosition: 'header', - maxCharacters: 300 - }) - ]; - - learnerProfileView = new LearnerProfileView({ - el: $learnerProfileElement, - ownProfile: options.own_profile, - has_preferences_access: options.has_preferences_access, - accountSettingsModel: accountSettingsModel, - preferencesModel: accountPreferencesModel, - accountPrivacyFieldView: accountPrivacyFieldView, - profileImageFieldView: profileImageFieldView, - usernameFieldView: usernameFieldView, - nameFieldView: nameFieldView, - sectionOneFieldViews: sectionOneFieldViews, - sectionTwoFieldViews: sectionTwoFieldViews, - platformName: options.platform_name - }); - - getProfileVisibility = function() { - if (options.has_preferences_access) { - return accountPreferencesModel.get('account_privacy'); - } else { - return accountSettingsModel.get('profile_is_public') ? 'all_users' : 'private'; - } - }; - - showLearnerProfileView = function() { - // Record that the profile page was viewed - Logger.log('edx.user.settings.viewed', { - page: 'profile', - visibility: getProfileVisibility(), - user_id: options.profile_user_id - }); - - // Render the view for the first time - learnerProfileView.render(); - }; - - if (options.has_preferences_access) { - if (accountSettingsModel.get('requires_parental_consent')) { - accountPreferencesModel.set('account_privacy', 'private'); - } - } - showLearnerProfileView(); - - return { - accountSettingsModel: accountSettingsModel, - accountPreferencesModel: accountPreferencesModel, - learnerProfileView: learnerProfileView, - }; - }; - }); -}).call(this, define || RequireJS.define); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/learner_profile_factory_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/learner_profile_factory_spec.js deleted file mode 100644 index 0019c2fc13..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/spec/learner_profile_factory_spec.js +++ /dev/null @@ -1,79 +0,0 @@ -define( - [ - 'backbone', 'jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', - 'js/spec/student_account/helpers', - 'learner_profile/js/spec_helpers/helpers', - 'js/views/fields', - 'js/student_account/models/user_account_model', - 'js/student_account/models/user_preferences_model', - 'learner_profile/js/views/learner_profile_view', - 'learner_profile/js/views/learner_profile_fields', - 'learner_profile/js/learner_profile_factory', - 'js/views/message_banner' - ], - function(Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews, - UserAccountModel, UserPreferencesModel, LearnerProfileView, LearnerProfileFields, LearnerProfilePage) { - 'use strict'; - - describe('edx.user.LearnerProfileFactory', function() { - var createProfilePage; - - beforeEach(function() { - loadFixtures('learner_profile/fixtures/learner_profile.html'); - }); - - afterEach(function() { - Backbone.history.stop(); - }); - - createProfilePage = function(ownProfile, options) { - return new LearnerProfilePage({ - accounts_api_url: Helpers.USER_ACCOUNTS_API_URL, - preferences_api_url: Helpers.USER_PREFERENCES_API_URL, - own_profile: ownProfile, - account_settings_page_url: Helpers.USER_ACCOUNTS_API_URL, - country_options: Helpers.FIELD_OPTIONS, - language_options: Helpers.FIELD_OPTIONS, - has_preferences_access: true, - profile_image_max_bytes: Helpers.IMAGE_MAX_BYTES, - profile_image_min_bytes: Helpers.IMAGE_MIN_BYTES, - profile_image_upload_url: Helpers.IMAGE_UPLOAD_API_URL, - profile_image_remove_url: Helpers.IMAGE_REMOVE_API_URL, - default_visibility: 'all_users', - platform_name: 'edX', - find_courses_url: '/courses/', - account_settings_data: Helpers.createAccountSettingsData(options), - preferences_data: Helpers.createUserPreferencesData() - }); - }; - - it('renders the full profile for a user', function() { - var context, - learnerProfileView; - AjaxHelpers.requests(this); - context = createProfilePage(true); - learnerProfileView = context.learnerProfileView; - - // sets the profile for full view. - context.accountPreferencesModel.set({account_privacy: 'all_users'}); - LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView, false); - }); - - it("renders the limited profile for undefined 'year_of_birth'", function() { - var context = createProfilePage(true, {year_of_birth: '', requires_parental_consent: true}), - learnerProfileView = context.learnerProfileView; - - LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView); - }); - - it('renders the limited profile for under 13 users', function() { - var context = createProfilePage( - true, - {year_of_birth: new Date().getFullYear() - 10, requires_parental_consent: true} - ); - var learnerProfileView = context.learnerProfileView; - LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView); - }); - }); - }); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_fields_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_fields_spec.js deleted file mode 100644 index 49b3dbc630..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_fields_spec.js +++ /dev/null @@ -1,381 +0,0 @@ -define( - [ - 'backbone', - 'jquery', - 'underscore', - 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', - 'js/spec/student_account/helpers', - 'js/student_account/models/user_account_model', - 'learner_profile/js/views/learner_profile_fields', - 'js/views/message_banner' - ], - function(Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, UserAccountModel, LearnerProfileFields, - MessageBannerView) { - 'use strict'; - - describe('edx.user.LearnerProfileFields', function() { - var MOCK_YEAR_OF_BIRTH = 1989; - var MOCK_IMAGE_MAX_BYTES = 64; - var MOCK_IMAGE_MIN_BYTES = 16; - - var createImageView = function(options) { - var yearOfBirth = _.isUndefined(options.yearOfBirth) ? MOCK_YEAR_OF_BIRTH : options.yearOfBirth; - var imageMaxBytes = _.isUndefined(options.imageMaxBytes) ? MOCK_IMAGE_MAX_BYTES : options.imageMaxBytes; - var imageMinBytes = _.isUndefined(options.imageMinBytes) ? MOCK_IMAGE_MIN_BYTES : options.imageMinBytes; - var messageView; - - var imageData = { - image_url_large: '/media/profile-images/default.jpg', - has_image: !!options.hasImage - }; - - var accountSettingsModel = new UserAccountModel(); - accountSettingsModel.set({profile_image: imageData}); - accountSettingsModel.set({year_of_birth: yearOfBirth}); - accountSettingsModel.set({requires_parental_consent: !!_.isEmpty(yearOfBirth)}); - - accountSettingsModel.url = Helpers.USER_ACCOUNTS_API_URL; - - messageView = new MessageBannerView({ - el: $('.message-banner') - }); - - return new LearnerProfileFields.ProfileImageFieldView({ - model: accountSettingsModel, - valueAttribute: 'profile_image', - editable: options.ownProfile, - messageView: messageView, - imageMaxBytes: imageMaxBytes, - imageMinBytes: imageMinBytes, - imageUploadUrl: Helpers.IMAGE_UPLOAD_API_URL, - imageRemoveUrl: Helpers.IMAGE_REMOVE_API_URL - }); - }; - - var createSocialLinksView = function(ownProfile, socialPlatformLinks) { - var accountSettingsModel = new UserAccountModel(); - accountSettingsModel.set({social_platforms: socialPlatformLinks}); - - return new LearnerProfileFields.SocialLinkIconsView({ - model: accountSettingsModel, - socialPlatforms: ['twitter', 'facebook', 'linkedin'], - ownProfile: ownProfile - }); - }; - - var createFakeImageFile = function(size) { - var fileFakeData = 'i63ljc6giwoskyb9x5sw0169bdcmcxr3cdz8boqv0lik971972cmd6yknvcxr5sw0nvc169bdcmcxsdf'; - return new Blob( - [fileFakeData.substr(0, size)], - {type: 'image/jpg'} - ); - }; - - var initializeUploader = function(view) { - view.$('.upload-button-input').fileupload({ - url: Helpers.IMAGE_UPLOAD_API_URL, - type: 'POST', - add: view.fileSelected, - done: view.imageChangeSucceeded, - fail: view.imageChangeFailed - }); - }; - - beforeEach(function() { - loadFixtures('learner_profile/fixtures/learner_profile.html'); - TemplateHelpers.installTemplate('templates/fields/field_image'); - TemplateHelpers.installTemplate('templates/fields/message_banner'); - TemplateHelpers.installTemplate('learner_profile/templates/social_icons'); - }); - - afterEach(function() { - // image_field.js's window.onBeforeUnload breaks Karma in Chrome, clean it up after each test - $(window).off('beforeunload'); - }); - - describe('ProfileImageFieldView', function() { - var verifyImageUploadButtonMessage = function(view, inProgress) { - var iconName = inProgress ? 'fa-spinner' : 'fa-camera'; - var message = inProgress ? view.titleUploading : view.uploadButtonTitle(); - expect(view.$('.upload-button-icon span').attr('class')).toContain(iconName); - expect(view.$('.upload-button-title').text().trim()).toBe(message); - }; - - var verifyImageRemoveButtonMessage = function(view, inProgress) { - var iconName = inProgress ? 'fa-spinner' : 'fa-remove'; - var message = inProgress ? view.titleRemoving : view.removeButtonTitle(); - expect(view.$('.remove-button-icon span').attr('class')).toContain(iconName); - expect(view.$('.remove-button-title').text().trim()).toBe(message); - }; - - it('can upload profile image', function() { - var requests = AjaxHelpers.requests(this); - var imageName = 'profile_image.jpg'; - var imageView = createImageView({ownProfile: true, hasImage: false}); - var data; - imageView.render(); - - initializeUploader(imageView); - - // Remove button should not be present for default image - expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); - - // For default image, image title should be `Upload an image` - verifyImageUploadButtonMessage(imageView, false); - - // Add image to upload queue. Validate the image size and send POST request to upload image - imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(60)]}); - - // Verify image upload progress message - verifyImageUploadButtonMessage(imageView, true); - - // Verify if POST request received for image upload - AjaxHelpers.expectRequest(requests, 'POST', Helpers.IMAGE_UPLOAD_API_URL, new FormData()); - - // Send 204 NO CONTENT to confirm the image upload success - AjaxHelpers.respondWithNoContent(requests); - - // Upon successful image upload, account settings model will be fetched to - // get the url for newly uploaded image, So we need to send the response for that GET - data = { - profile_image: { - image_url_large: '/media/profile-images/' + imageName, - has_image: true - } - }; - AjaxHelpers.respondWithJson(requests, data); - - // Verify uploaded image name - expect(imageView.$('.image-frame').attr('src')).toContain(imageName); - - // Remove button should be present after successful image upload - expect(imageView.$('.u-field-remove-button').css('display') !== 'none').toBeTruthy(); - - // After image upload, image title should be `Change image` - verifyImageUploadButtonMessage(imageView, false); - }); - - it('can remove profile image', function() { - var requests = AjaxHelpers.requests(this); - var imageView = createImageView({ownProfile: true, hasImage: false}); - var data; - imageView.render(); - - // Verify image remove title - verifyImageRemoveButtonMessage(imageView, false); - - imageView.$('.u-field-remove-button').click(); - - // Verify image remove progress message - verifyImageRemoveButtonMessage(imageView, true); - - // Verify if POST request received for image remove - AjaxHelpers.expectRequest(requests, 'POST', Helpers.IMAGE_REMOVE_API_URL, null); - - // Send 204 NO CONTENT to confirm the image removal success - AjaxHelpers.respondWithNoContent(requests); - - // Upon successful image removal, account settings model will be fetched to get default image url - // So we need to send the response for that GET - data = { - profile_image: { - image_url_large: '/media/profile-images/default.jpg', - has_image: false - } - }; - AjaxHelpers.respondWithJson(requests, data); - - // Remove button should not be present for default image - expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); - }); - - it("can't remove default profile image", function() { - var imageView = createImageView({ownProfile: true, hasImage: false}); - imageView.render(); - - spyOn(imageView, 'clickedRemoveButton'); - - // Remove button should not be present for default image - expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); - - imageView.$('.u-field-remove-button').click(); - - // Remove button click handler should not be called - expect(imageView.clickedRemoveButton).not.toHaveBeenCalled(); - }); - - it("can't upload image having size greater than max size", function() { - var imageView = createImageView({ownProfile: true, hasImage: false}); - imageView.render(); - - initializeUploader(imageView); - - // Add image to upload queue, this will validate the image size - imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(70)]}); - - // Verify error message - expect($('.message-banner').text().trim()) - .toBe('The file must be smaller than 64 bytes in size.'); - }); - - it("can't upload image having size less than min size", function() { - var imageView = createImageView({ownProfile: true, hasImage: false}); - imageView.render(); - - initializeUploader(imageView); - - // Add image to upload queue, this will validate the image size - imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(10)]}); - - // Verify error message - expect($('.message-banner').text().trim()).toBe('The file must be at least 16 bytes in size.'); - }); - - it("can't upload and remove image if parental consent required", function() { - var imageView = createImageView({ownProfile: true, hasImage: false, yearOfBirth: ''}); - imageView.render(); - - spyOn(imageView, 'clickedUploadButton'); - spyOn(imageView, 'clickedRemoveButton'); - - expect(imageView.$('.u-field-upload-button').css('display') === 'none').toBeTruthy(); - expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); - - imageView.$('.u-field-upload-button').click(); - imageView.$('.u-field-remove-button').click(); - - expect(imageView.clickedUploadButton).not.toHaveBeenCalled(); - expect(imageView.clickedRemoveButton).not.toHaveBeenCalled(); - }); - - it("can't upload and remove image on others profile", function() { - var imageView = createImageView({ownProfile: false}); - imageView.render(); - - spyOn(imageView, 'clickedUploadButton'); - spyOn(imageView, 'clickedRemoveButton'); - - expect(imageView.$('.u-field-upload-button').css('display') === 'none').toBeTruthy(); - expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); - - imageView.$('.u-field-upload-button').click(); - imageView.$('.u-field-remove-button').click(); - - expect(imageView.clickedUploadButton).not.toHaveBeenCalled(); - expect(imageView.clickedRemoveButton).not.toHaveBeenCalled(); - }); - - it('shows message if we try to navigate away during image upload/remove', function() { - var imageView = createImageView({ownProfile: true, hasImage: false}); - spyOn(imageView, 'onBeforeUnload'); - imageView.render(); - - initializeUploader(imageView); - - // Add image to upload queue, this will validate image size and send POST request to upload image - imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(60)]}); - - // Verify image upload progress message - verifyImageUploadButtonMessage(imageView, true); - - window.onbeforeunload = null; - $(window).trigger('beforeunload'); - expect(imageView.onBeforeUnload).toHaveBeenCalled(); - }); - - it('shows error message for HTTP 500', function() { - var requests = AjaxHelpers.requests(this); - var imageView = createImageView({ownProfile: true, hasImage: false}); - imageView.render(); - - initializeUploader(imageView); - - // Add image to upload queue. Validate the image size and send POST request to upload image - imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(60)]}); - - // Verify image upload progress message - verifyImageUploadButtonMessage(imageView, true); - - // Verify if POST request received for image upload - AjaxHelpers.expectRequest(requests, 'POST', Helpers.IMAGE_UPLOAD_API_URL, new FormData()); - - // Send HTTP 500 - AjaxHelpers.respondWithError(requests); - - expect($('.message-banner').text().trim()).toBe(imageView.errorMessage); - }); - }); - - describe('SocialLinkIconsView', function() { - var socialPlatformLinks, - socialLinkData, - socialLinksView, - socialPlatform, - $icon; - - it('icons are visible and links to social profile if added in account settings', function() { - socialPlatformLinks = { - twitter: { - platform: 'twitter', - social_link: 'https://www.twitter.com/edX' - }, - facebook: { - platform: 'facebook', - social_link: 'https://www.facebook.com/edX' - }, - linkedin: { - platform: 'linkedin', - social_link: '' - } - }; - - socialLinksView = createSocialLinksView(true, socialPlatformLinks); - - // Icons should be present and contain links if defined - for (var i = 0; i < Object.keys(socialPlatformLinks); i++) { // eslint-disable-line vars-on-top - socialPlatform = Object.keys(socialPlatformLinks)[i]; - socialLinkData = socialPlatformLinks[socialPlatform]; - if (socialLinkData.social_link) { - // Icons with a social_link value should be displayed with a surrounding link - $icon = socialLinksView.$('span.fa-' + socialPlatform + '-square'); - expect($icon).toExist(); - expect($icon.parent().is('a')); - } else { - // Icons without a social_link value should be displayed without a surrounding link - $icon = socialLinksView.$('span.fa-' + socialPlatform + '-square'); - expect($icon).toExist(); - expect(!$icon.parent().is('a')); - } - } - }); - - it('icons are not visible on a profile with no links', function() { - socialPlatformLinks = { - twitter: { - platform: 'twitter', - social_link: '' - }, - facebook: { - platform: 'facebook', - social_link: '' - }, - linkedin: { - platform: 'linkedin', - social_link: '' - } - }; - - socialLinksView = createSocialLinksView(false, socialPlatformLinks); - - // Icons should not be present if not defined on another user's profile - for (var i = 0; i < Object.keys(socialPlatformLinks); i++) { // eslint-disable-line vars-on-top - socialPlatform = Object.keys(socialPlatformLinks)[i]; - socialLinkData = socialPlatformLinks[socialPlatform]; - $icon = socialLinksView.$('span.fa-' + socialPlatform + '-square'); - expect($icon).toBe(null); - } - }); - }); - }); - }); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_view_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_view_spec.js deleted file mode 100644 index 21d2dd0f5d..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_view_spec.js +++ /dev/null @@ -1,218 +0,0 @@ -/* eslint-disable vars-on-top */ -define( - [ - 'gettext', - 'backbone', - 'jquery', - 'underscore', - 'edx-ui-toolkit/js/pagination/paging-collection', - 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', - 'js/spec/student_account/helpers', - 'learner_profile/js/spec_helpers/helpers', - 'js/views/fields', - 'js/student_account/models/user_account_model', - 'js/student_account/models/user_preferences_model', - 'learner_profile/js/views/learner_profile_fields', - 'learner_profile/js/views/learner_profile_view', - 'js/student_account/views/account_settings_fields', - 'js/views/message_banner' - ], - function(gettext, Backbone, $, _, PagingCollection, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, - FieldViews, UserAccountModel, AccountPreferencesModel, LearnerProfileFields, LearnerProfileView, - AccountSettingsFieldViews, MessageBannerView) { - 'use strict'; - - describe('edx.user.LearnerProfileView', function() { - var createLearnerProfileView = function(ownProfile, accountPrivacy, profileIsPublic) { - var accountSettingsModel = new UserAccountModel(); - accountSettingsModel.set(Helpers.createAccountSettingsData()); - accountSettingsModel.set({profile_is_public: profileIsPublic}); - accountSettingsModel.set({profile_image: Helpers.PROFILE_IMAGE}); - - var accountPreferencesModel = new AccountPreferencesModel(); - accountPreferencesModel.set({account_privacy: accountPrivacy}); - - accountPreferencesModel.url = Helpers.USER_PREFERENCES_API_URL; - - var editable = ownProfile ? 'toggle' : 'never'; - - var accountPrivacyFieldView = new LearnerProfileFields.AccountPrivacyFieldView({ - model: accountPreferencesModel, - required: true, - editable: 'always', - showMessages: false, - title: 'edX learners can see my:', - valueAttribute: 'account_privacy', - options: [ - ['all_users', 'Full Profile'], - ['private', 'Limited Profile'] - ], - helpMessage: '', - accountSettingsPageUrl: '/account/settings/' - }); - - var messageView = new MessageBannerView({ - el: $('.message-banner') - }); - - var profileImageFieldView = new LearnerProfileFields.ProfileImageFieldView({ - model: accountSettingsModel, - valueAttribute: 'profile_image', - editable: editable, - messageView: messageView, - imageMaxBytes: Helpers.IMAGE_MAX_BYTES, - imageMinBytes: Helpers.IMAGE_MIN_BYTES, - imageUploadUrl: Helpers.IMAGE_UPLOAD_API_URL, - imageRemoveUrl: Helpers.IMAGE_REMOVE_API_URL - }); - - var usernameFieldView = new FieldViews.ReadonlyFieldView({ - model: accountSettingsModel, - valueAttribute: 'username', - helpMessage: '' - }); - - var nameFieldView = new FieldViews.ReadonlyFieldView({ - model: accountSettingsModel, - valueAttribute: 'name', - helpMessage: '' - }); - - var sectionOneFieldViews = [ - new LearnerProfileFields.SocialLinkIconsView({ - model: accountSettingsModel, - socialPlatforms: Helpers.SOCIAL_PLATFORMS, - ownProfile: true - }), - - new FieldViews.DropdownFieldView({ - title: gettext('Location'), - model: accountSettingsModel, - required: false, - editable: editable, - showMessages: false, - placeholderValue: '', - valueAttribute: 'country', - options: Helpers.FIELD_OPTIONS, - helpMessage: '' - }), - - new AccountSettingsFieldViews.LanguageProficienciesFieldView({ - title: gettext('Language'), - model: accountSettingsModel, - required: false, - editable: editable, - showMessages: false, - placeholderValue: 'Add language', - valueAttribute: 'language_proficiencies', - options: Helpers.FIELD_OPTIONS, - helpMessage: '' - }), - - new FieldViews.DateFieldView({ - model: accountSettingsModel, - valueAttribute: 'date_joined', - helpMessage: '' - }) - ]; - - var sectionTwoFieldViews = [ - new FieldViews.TextareaFieldView({ - model: accountSettingsModel, - editable: editable, - showMessages: false, - title: 'About me', - placeholderValue: 'Tell other edX learners a little about yourself: where you live, ' - + "what your interests are, why you're taking courses on edX, or what you hope to learn.", - valueAttribute: 'bio', - helpMessage: '', - messagePosition: 'header' - }) - ]; - - return new LearnerProfileView( - { - el: $('.wrapper-profile'), - ownProfile: ownProfile, - hasPreferencesAccess: true, - accountSettingsModel: accountSettingsModel, - preferencesModel: accountPreferencesModel, - accountPrivacyFieldView: accountPrivacyFieldView, - usernameFieldView: usernameFieldView, - nameFieldView: nameFieldView, - profileImageFieldView: profileImageFieldView, - sectionOneFieldViews: sectionOneFieldViews, - sectionTwoFieldViews: sectionTwoFieldViews, - }); - }; - - beforeEach(function() { - loadFixtures('learner_profile/fixtures/learner_profile.html'); - }); - - afterEach(function() { - Backbone.history.stop(); - }); - - it('shows loading error correctly', function() { - var learnerProfileView = createLearnerProfileView(false, 'all_users'); - - Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - - learnerProfileView.render(); - learnerProfileView.showLoadingError(); - - Helpers.expectLoadingErrorIsVisible(learnerProfileView, true); - }); - - it('renders all fields as expected for self with full access', function() { - var learnerProfileView = createLearnerProfileView(true, 'all_users', true); - - Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - - learnerProfileView.render(); - - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView); - }); - - it('renders all fields as expected for self with limited access', function() { - var learnerProfileView = createLearnerProfileView(true, 'private', false); - - Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - - learnerProfileView.render(); - - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView); - }); - - it('renders the fields as expected for others with full access', function() { - var learnerProfileView = createLearnerProfileView(false, 'all_users', true); - - Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - - learnerProfileView.render(); - - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView, true); - }); - - it('renders the fields as expected for others with limited access', function() { - var learnerProfileView = createLearnerProfileView(false, 'private', false); - - Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - - learnerProfileView.render(); - - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView, true); - }); - }); - }); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/section_two_tab_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/section_two_tab_spec.js deleted file mode 100644 index d0e22d670b..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/spec/views/section_two_tab_spec.js +++ /dev/null @@ -1,113 +0,0 @@ -/* eslint-disable vars-on-top */ -define( - [ - 'backbone', 'jquery', 'underscore', - 'js/spec/student_account/helpers', - 'learner_profile/js/views/section_two_tab', - 'js/views/fields', - 'js/student_account/models/user_account_model' - ], - function(Backbone, $, _, Helpers, SectionTwoTabView, FieldViews, UserAccountModel) { - 'use strict'; - - describe('edx.user.SectionTwoTab', function() { - var createSectionTwoView = function(ownProfile, profileIsPublic) { - var accountSettingsModel = new UserAccountModel(); - accountSettingsModel.set(Helpers.createAccountSettingsData()); - accountSettingsModel.set({profile_is_public: profileIsPublic}); - accountSettingsModel.set({profile_image: Helpers.PROFILE_IMAGE}); - - var editable = ownProfile ? 'toggle' : 'never'; - - var sectionTwoFieldViews = [ - new FieldViews.TextareaFieldView({ - model: accountSettingsModel, - editable: editable, - showMessages: false, - title: 'About me', - placeholderValue: 'Tell other edX learners a little about yourself: where you live, ' - + "what your interests are, why you're taking courses on edX, or what you hope to learn.", - valueAttribute: 'bio', - helpMessage: '', - messagePosition: 'header' - }) - ]; - - return new SectionTwoTabView({ - viewList: sectionTwoFieldViews, - showFullProfile: function() { - return profileIsPublic; - }, - ownProfile: ownProfile - }); - }; - - it('full profile displayed for public profile', function() { - var view = createSectionTwoView(false, true); - view.render(); - var bio = view.$el.find('.u-field-bio'); - expect(bio.length).toBe(1); - }); - - it('profile field parts are actually rendered for public profile', function() { - var view = createSectionTwoView(false, true); - _.each(view.options.viewList, function(fieldView) { - spyOn(fieldView, 'render').and.callThrough(); - }); - view.render(); - _.each(view.options.viewList, function(fieldView) { - expect(fieldView.render).toHaveBeenCalled(); - }); - }); - - var testPrivateProfile = function(ownProfile, messageString) { - var view = createSectionTwoView(ownProfile, false); - view.render(); - var bio = view.$el.find('.u-field-bio'); - expect(bio.length).toBe(0); - var msg = view.$el.find('span.profile-private-message'); - expect(msg.length).toBe(1); - expect(_.count(msg.html(), messageString)).toBeTruthy(); - }; - - it('no profile when profile is private for other people', function() { - testPrivateProfile(false, 'This learner is currently sharing a limited profile'); - }); - - it('no profile when profile is private for the user herself', function() { - testPrivateProfile(true, 'You are currently sharing a limited profile'); - }); - - var testProfilePrivatePartsDoNotRender = function(ownProfile) { - var view = createSectionTwoView(ownProfile, false); - _.each(view.options.viewList, function(fieldView) { - spyOn(fieldView, 'render'); - }); - view.render(); - _.each(view.options.viewList, function(fieldView) { - expect(fieldView.render).not.toHaveBeenCalled(); - }); - }; - - it('profile field parts are not rendered for private profile for owner', function() { - testProfilePrivatePartsDoNotRender(true); - }); - - it('profile field parts are not rendered for private profile for other people', function() { - testProfilePrivatePartsDoNotRender(false); - }); - - it('does not allow fields to be edited when visiting a profile for other people', function() { - var view = createSectionTwoView(false, true); - var bio = view.options.viewList[0]; - expect(bio.editable).toBe('never'); - }); - - it("allows fields to be edited when visiting one's own profile", function() { - var view = createSectionTwoView(true, true); - var bio = view.options.viewList[0]; - expect(bio.editable).toBe('toggle'); - }); - }); - } -); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec_helpers/helpers.js b/openedx/features/learner_profile/static/learner_profile/js/spec_helpers/helpers.js deleted file mode 100644 index e1369284a4..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/spec_helpers/helpers.js +++ /dev/null @@ -1,133 +0,0 @@ -define(['underscore', 'URI', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers'], function(_, URI, AjaxHelpers) { - 'use strict'; - - var expectProfileElementContainsField = function(element, view) { - var titleElement, fieldTitle; - var $element = $(element); - - // Avoid testing for elements without titles - titleElement = $element.find('.u-field-title'); - if (titleElement.length === 0) { - return; - } - - fieldTitle = titleElement.text().trim(); - if (!_.isUndefined(view.options.title) && !_.isUndefined(fieldTitle)) { - expect(fieldTitle).toBe(view.options.title); - } - - if ('fieldValue' in view || 'imageUrl' in view) { - if ('imageUrl' in view) { - expect($($element.find('.image-frame')[0]).attr('src')).toBe(view.imageUrl()); - } else if (view.fieldType === 'date') { - expect(view.fieldValue()).toBe(view.timezoneFormattedDate()); - } else if (view.fieldValue()) { - expect(view.fieldValue()).toBe(view.modelValue()); - } else if ('optionForValue' in view) { - expect($($element.find('.u-field-value .u-field-value-readonly')[0]).text()).toBe( - view.displayValue(view.modelValue()) - ); - } else { - expect($($element.find('.u-field-value .u-field-value-readonly')[0]).text()).toBe(view.modelValue()); - } - } else { - throw new Error('Unexpected field type: ' + view.fieldType); - } - }; - - var expectProfilePrivacyFieldTobeRendered = function(learnerProfileView, othersProfile) { - var $accountPrivacyElement = $('.wrapper-profile-field-account-privacy'); - var $privacyFieldElement = $($accountPrivacyElement).find('.u-field'); - - if (othersProfile) { - expect($privacyFieldElement.length).toBe(0); - } else { - expect($privacyFieldElement.length).toBe(1); - expectProfileElementContainsField($privacyFieldElement, learnerProfileView.options.accountPrivacyFieldView); - } - }; - - var expectSectionOneTobeRendered = function(learnerProfileView) { - var sectionOneFieldElements = $(learnerProfileView.$('.wrapper-profile-section-one')) - .find('.u-field, .social-links'); - - expect(sectionOneFieldElements.length).toBe(7); - expectProfileElementContainsField(sectionOneFieldElements[0], learnerProfileView.options.profileImageFieldView); - expectProfileElementContainsField(sectionOneFieldElements[1], learnerProfileView.options.usernameFieldView); - expectProfileElementContainsField(sectionOneFieldElements[2], learnerProfileView.options.nameFieldView); - - _.each(_.rest(sectionOneFieldElements, 3), function(sectionFieldElement, fieldIndex) { - expectProfileElementContainsField( - sectionFieldElement, - learnerProfileView.options.sectionOneFieldViews[fieldIndex] - ); - }); - }; - - var expectSectionTwoTobeRendered = function(learnerProfileView) { - var $sectionTwoElement = $('.wrapper-profile-section-two'); - var $sectionTwoFieldElements = $($sectionTwoElement).find('.u-field'); - - expect($sectionTwoFieldElements.length).toBe(learnerProfileView.options.sectionTwoFieldViews.length); - - _.each($sectionTwoFieldElements, function(sectionFieldElement, fieldIndex) { - expectProfileElementContainsField( - sectionFieldElement, - learnerProfileView.options.sectionTwoFieldViews[fieldIndex] - ); - }); - }; - - var expectProfileSectionsAndFieldsToBeRendered = function(learnerProfileView, othersProfile) { - expectProfilePrivacyFieldTobeRendered(learnerProfileView, othersProfile); - expectSectionOneTobeRendered(learnerProfileView); - expectSectionTwoTobeRendered(learnerProfileView); - }; - - var expectLimitedProfileSectionsAndFieldsToBeRendered = function(learnerProfileView, othersProfile) { - var sectionOneFieldElements = $('.wrapper-profile-section-one').find('.u-field'); - - expectProfilePrivacyFieldTobeRendered(learnerProfileView, othersProfile); - - expect(sectionOneFieldElements.length).toBe(2); - expectProfileElementContainsField( - sectionOneFieldElements[0], - learnerProfileView.options.profileImageFieldView - ); - expectProfileElementContainsField( - sectionOneFieldElements[1], - learnerProfileView.options.usernameFieldView - ); - - if (othersProfile) { - expect($('.profile-private-message').text()) - .toBe('This learner is currently sharing a limited profile.'); - } else { - expect($('.profile-private-message').text()).toBe('You are currently sharing a limited profile.'); - } - }; - - var expectProfileSectionsNotToBeRendered = function() { - expect($('.wrapper-profile-field-account-privacy').length).toBe(0); - expect($('.wrapper-profile-section-one').length).toBe(0); - expect($('.wrapper-profile-section-two').length).toBe(0); - }; - - var expectTabbedViewToBeUndefined = function(requests, tabbedViewView) { - // Unrelated initial request, no badge request - expect(requests.length).toBe(1); - expect(tabbedViewView).toBe(undefined); - }; - - var expectTabbedViewToBeShown = function(tabbedViewView) { - expect(tabbedViewView.$el.find('.page-content-nav').is(':visible')).toBe(true); - }; - - return { - expectLimitedProfileSectionsAndFieldsToBeRendered: expectLimitedProfileSectionsAndFieldsToBeRendered, - expectProfileSectionsAndFieldsToBeRendered: expectProfileSectionsAndFieldsToBeRendered, - expectProfileSectionsNotToBeRendered: expectProfileSectionsNotToBeRendered, - expectTabbedViewToBeUndefined: expectTabbedViewToBeUndefined, - expectTabbedViewToBeShown: expectTabbedViewToBeShown - }; -}); diff --git a/openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_fields.js b/openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_fields.js deleted file mode 100644 index 807e3e4c5b..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_fields.js +++ /dev/null @@ -1,169 +0,0 @@ -/* eslint-disable no-underscore-dangle */ -(function(define) { - 'use strict'; - - define([ - 'gettext', - 'jquery', - 'underscore', - 'backbone', - 'edx-ui-toolkit/js/utils/string-utils', - 'edx-ui-toolkit/js/utils/html-utils', - 'js/views/fields', - 'js/views/image_field', - 'text!learner_profile/templates/social_icons.underscore', - 'backbone-super' - ], function(gettext, $, _, Backbone, StringUtils, HtmlUtils, FieldViews, ImageFieldView, socialIconsTemplate) { - var LearnerProfileFieldViews = {}; - - LearnerProfileFieldViews.AccountPrivacyFieldView = FieldViews.DropdownFieldView.extend({ - - events: { - 'click button.btn-change-privacy': 'finishEditing', - 'change select': 'showSaveButton' - }, - - render: function() { - this._super(); - this.showNotificationMessage(); - this.updateFieldValue(); - return this; - }, - - showNotificationMessage: function() { - var accountSettingsLink = HtmlUtils.joinHtml( - HtmlUtils.interpolateHtml( - HtmlUtils.HTML(''), {settings_url: this.options.accountSettingsPageUrl} - ), - gettext('Account Settings page.'), - HtmlUtils.HTML('') - ); - if (this.profileIsPrivate) { - this._super( - HtmlUtils.interpolateHtml( - gettext('You must specify your birth year before you can share your full profile. To specify your birth year, go to the {account_settings_page_link}'), // eslint-disable-line max-len - {account_settings_page_link: accountSettingsLink} - ) - ); - } else if (this.requiresParentalConsent) { - this._super( - HtmlUtils.interpolateHtml( - gettext('You must be over 13 to share a full profile. If you are over 13, make sure that you have specified a birth year on the {account_settings_page_link}'), // eslint-disable-line max-len - {account_settings_page_link: accountSettingsLink} - ) - ); - } else { - this._super(''); - } - }, - - updateFieldValue: function() { - if (!this.isAboveMinimumAge) { - this.$('.u-field-value select').val('private'); - this.disableField(true); - } - }, - - showSaveButton: function() { - $('.btn-change-privacy').removeClass('hidden'); - } - }); - - LearnerProfileFieldViews.ProfileImageFieldView = ImageFieldView.extend({ - - screenReaderTitle: gettext('Profile Image'), - - imageUrl: function() { - return this.model.profileImageUrl(); - }, - - imageAltText: function() { - return StringUtils.interpolate( - gettext('Profile image for {username}'), - {username: this.model.get('username')} - ); - }, - - imageChangeSucceeded: function() { - var view = this; - // Update model to get the latest urls of profile image. - this.model.fetch().done(function() { - view.setCurrentStatus(''); - view.render(); - view.$('.u-field-upload-button').focus(); - }).fail(function() { - view.setCurrentStatus(''); - view.showErrorMessage(view.errorMessage); - }); - }, - - imageChangeFailed: function(e, data) { - this.setCurrentStatus(''); - this.showImageChangeFailedMessage(data.jqXHR.status, data.jqXHR.responseText); - }, - - showImageChangeFailedMessage: function(status, responseText) { - var errors; - if (_.contains([400, 404], status)) { - try { - errors = JSON.parse(responseText); - this.showErrorMessage(errors.user_message); - } catch (error) { - this.showErrorMessage(this.errorMessage); - } - } else { - this.showErrorMessage(this.errorMessage); - } - }, - - showErrorMessage: function(message) { - this.options.messageView.showMessage(message); - }, - - isEditingAllowed: function() { - return this.model.isAboveMinimumAge(); - }, - - isShowingPlaceholder: function() { - return !this.model.hasProfileImage(); - }, - - clickedRemoveButton: function(e, data) { - this.options.messageView.hideMessage(); - this._super(e, data); - }, - - fileSelected: function(e, data) { - this.options.messageView.hideMessage(); - this._super(e, data); - } - }); - - LearnerProfileFieldViews.SocialLinkIconsView = Backbone.View.extend({ - - initialize: function(options) { - this.options = _.extend({}, options); - }, - - render: function() { - var socialLinks = {}; - for (var platformName in this.options.socialPlatforms) { // eslint-disable-line no-restricted-syntax, guard-for-in, vars-on-top, max-len - socialLinks[platformName] = null; - for (var link in this.model.get('social_links')) { // eslint-disable-line no-restricted-syntax, vars-on-top, max-len - if (platformName === this.model.get('social_links')[link].platform) { - socialLinks[platformName] = this.model.get('social_links')[link].social_link; - } - } - } - - HtmlUtils.setHtml(this.$el, HtmlUtils.template(socialIconsTemplate)({ - socialLinks: socialLinks, - ownProfile: this.options.ownProfile - })); - return this; - } - }); - - return LearnerProfileFieldViews; - }); -}).call(this, define || RequireJS.define); diff --git a/openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js b/openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js deleted file mode 100644 index 74c0e6b819..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js +++ /dev/null @@ -1,150 +0,0 @@ -(function(define) { - 'use strict'; - - define( - [ - 'gettext', 'jquery', 'underscore', 'backbone', 'edx-ui-toolkit/js/utils/html-utils', - 'common/js/components/views/tabbed_view', - 'learner_profile/js/views/section_two_tab' - ], - function(gettext, $, _, Backbone, HtmlUtils, TabbedView, SectionTwoTab) { - var LearnerProfileView = Backbone.View.extend({ - - initialize: function(options) { - var Router; - this.options = _.extend({}, options); - _.bindAll(this, 'showFullProfile', 'render', 'renderFields', 'showLoadingError'); - this.listenTo(this.options.preferencesModel, 'change:account_privacy', this.render); - Router = Backbone.Router.extend({ - routes: {':about_me': 'loadTab', ':accomplishments': 'loadTab'} - }); - - this.router = new Router(); - this.firstRender = true; - }, - - showFullProfile: function() { - var isAboveMinimumAge = this.options.accountSettingsModel.isAboveMinimumAge(); - if (this.options.ownProfile) { - return isAboveMinimumAge - && this.options.preferencesModel.get('account_privacy') === 'all_users'; - } else { - return this.options.accountSettingsModel.get('profile_is_public'); - } - }, - - setActiveTab: function(tab) { - // This tab may not actually exist. - if (this.tabbedView.getTabMeta(tab).tab) { - this.tabbedView.setActiveTab(tab); - } - }, - - render: function() { - var tabs, - $tabbedViewElement, - $wrapperProfileBioElement = this.$el.find('.wrapper-profile-bio'), - self = this; - - this.sectionTwoView = new SectionTwoTab({ - viewList: this.options.sectionTwoFieldViews, - showFullProfile: this.showFullProfile, - ownProfile: this.options.ownProfile - }); - - this.renderFields(); - - // Reveal the profile and hide the loading indicator - $('.ui-loading-indicator').addClass('is-hidden'); - $('.wrapper-profile-section-container-one').removeClass('is-hidden'); - $('.wrapper-profile-section-container-two').removeClass('is-hidden'); - - - if (this.showFullProfile()) { - tabs = [ - {view: this.sectionTwoView, title: gettext('About Me'), url: 'about_me'} - ]; - - this.tabbedView = new TabbedView({ - tabs: tabs, - router: this.router, - viewLabel: gettext('Profile') - }); - - $tabbedViewElement = this.tabbedView.render().el; - HtmlUtils.setHtml( - $wrapperProfileBioElement, - HtmlUtils.HTML($tabbedViewElement) - ); - - if (this.firstRender) { - this.router.on('route:loadTab', _.bind(this.setActiveTab, this)); - Backbone.history.start(); - this.firstRender = false; - // Load from history. - this.router.navigate((Backbone.history.getFragment() || 'about_me'), {trigger: true}); - } else { - // Restart the router so the tab will be brought up anew. - Backbone.history.stop(); - Backbone.history.start(); - } - } else { - if (this.isCoppaCompliant()) { - // xss-lint: disable=javascript-jquery-html - $wrapperProfileBioElement.html(this.sectionTwoView.render().el); - } - } - return this; - }, - - isCoppaCompliant: function() { - var enableCoppaCompliance = this.options.accountSettingsModel.get('enable_coppa_compliance'), - isAboveAge = this.options.accountSettingsModel.isAboveMinimumAge(); - return !enableCoppaCompliance || (enableCoppaCompliance && isAboveAge); - }, - - renderFields: function() { - var view = this, - fieldView, - imageView, - settings; - - if (this.options.ownProfile && this.isCoppaCompliant()) { - fieldView = this.options.accountPrivacyFieldView; - settings = this.options.accountSettingsModel; - fieldView.profileIsPrivate = !settings.get('year_of_birth'); - fieldView.requiresParentalConsent = settings.get('requires_parental_consent'); - fieldView.isAboveMinimumAge = settings.isAboveMinimumAge(); - fieldView.undelegateEvents(); - this.$('.wrapper-profile-field-account-privacy').prepend(fieldView.render().el); - fieldView.delegateEvents(); - } - - // Clear existing content in user profile card - this.$('.profile-section-one-fields').html(''); - - // Do not show name when in limited mode or no name has been set - if (this.showFullProfile() && this.options.accountSettingsModel.get('name')) { - this.$('.profile-section-one-fields').append(this.options.nameFieldView.render().el); - } - this.$('.profile-section-one-fields').append(this.options.usernameFieldView.render().el); - - imageView = this.options.profileImageFieldView; - this.$('.profile-image-field').append(imageView.render().el); - - if (this.showFullProfile()) { - _.each(this.options.sectionOneFieldViews, function(childFieldView) { - view.$('.profile-section-one-fields').append(childFieldView.render().el); - }); - } - }, - - showLoadingError: function() { - this.$('.ui-loading-indicator').addClass('is-hidden'); - this.$('.ui-loading-error').removeClass('is-hidden'); - } - }); - - return LearnerProfileView; - }); -}).call(this, define || RequireJS.define); diff --git a/openedx/features/learner_profile/static/learner_profile/js/views/section_two_tab.js b/openedx/features/learner_profile/static/learner_profile/js/views/section_two_tab.js deleted file mode 100644 index 23064c9e6b..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/views/section_two_tab.js +++ /dev/null @@ -1,33 +0,0 @@ -(function(define) { - 'use strict'; - - define( - [ - 'gettext', 'jquery', 'underscore', 'backbone', 'text!learner_profile/templates/section_two.underscore', - 'edx-ui-toolkit/js/utils/html-utils' - ], - function(gettext, $, _, Backbone, sectionTwoTemplate, HtmlUtils) { - var SectionTwoTab = Backbone.View.extend({ - attributes: { - class: 'wrapper-profile-section-two' - }, - template: _.template(sectionTwoTemplate), - initialize: function(options) { - this.options = _.extend({}, options); - }, - render: function() { - var self = this; - var showFullProfile = this.options.showFullProfile(); - this.$el.html(HtmlUtils.HTML(this.template({ownProfile: self.options.ownProfile, showFullProfile: showFullProfile})).toString()); // eslint-disable-line max-len - if (showFullProfile) { - _.each(this.options.viewList, function(fieldView) { - self.$el.find('.field-container').append(fieldView.render().el); - }); - } - return this; - } - }); - - return SectionTwoTab; - }); -}).call(this, define || RequireJS.define); diff --git a/openedx/features/learner_profile/static/learner_profile/templates/section_two.underscore b/openedx/features/learner_profile/static/learner_profile/templates/section_two.underscore deleted file mode 100644 index 0c7d11cd8b..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/templates/section_two.underscore +++ /dev/null @@ -1,10 +0,0 @@ -
    -
    - <% if (!showFullProfile) { %> - <% if(ownProfile) { %> - <%- gettext("You are currently sharing a limited profile.") %> - <% } else { %> - <%- gettext("This learner is currently sharing a limited profile.") %> - <% } %> - <% } %> -
    \ No newline at end of file diff --git a/openedx/features/learner_profile/static/learner_profile/templates/social_icons.underscore b/openedx/features/learner_profile/static/learner_profile/templates/social_icons.underscore deleted file mode 100644 index 52b864cfb6..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/templates/social_icons.underscore +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/openedx/features/learner_profile/static/learner_profile/templates/third_party_auth.html b/openedx/features/learner_profile/static/learner_profile/templates/third_party_auth.html deleted file mode 100644 index 07e14bc48a..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/templates/third_party_auth.html +++ /dev/null @@ -1,47 +0,0 @@ -<%page expression_filter="h"/> -<%! -from django.utils.translation import gettext as _ -from common.djangoapps.third_party_auth import pipeline -%> - - diff --git a/openedx/features/learner_profile/templates/learner_profile/learner-achievements-fragment.html b/openedx/features/learner_profile/templates/learner_profile/learner-achievements-fragment.html deleted file mode 100644 index 09e6ce36b9..0000000000 --- a/openedx/features/learner_profile/templates/learner_profile/learner-achievements-fragment.html +++ /dev/null @@ -1,69 +0,0 @@ -## mako - -<%page expression_filter="h"/> - -<%namespace name='static' file='/static_content.html'/> - -<%! -from django.utils.translation import gettext as _ -from openedx.core.djangolib.markup import HTML, Text -%> - -
    - % if course_certificates or own_profile: -

    Course Certificates

    - % if course_certificates: - % for certificate in course_certificates: - <% - course = certificate['course'] - - completion_date_message_html = Text(_('Completed {completion_date_html}')).format( - completion_date_html=HTML( - '' - ).format( - completion_date=certificate['created'], - user_timezone=user_timezone, - user_language=user_language, - ), - ) - %> -
    - -
    -
    ${course.display_org_with_default}
    -
    ${course.display_name_with_default}
    -

    ${completion_date_message_html}

    -
    -
    - % endfor - % elif own_profile: -
    -

    ${_("You haven't earned any certificates yet.")}

    - % if settings.FEATURES.get('COURSES_ARE_BROWSABLE'): -

    - - - ${_('Explore New Courses')} - -

    - % endif -
    - % endif - % endif -
    - -<%static:require_module_async module_name="js/dateutil_factory" class_name="DateUtilFactory"> - DateUtilFactory.transform('.localized-datetime'); - diff --git a/openedx/features/learner_profile/templates/learner_profile/learner_profile.html b/openedx/features/learner_profile/templates/learner_profile/learner_profile.html deleted file mode 100644 index 6de4744e66..0000000000 --- a/openedx/features/learner_profile/templates/learner_profile/learner_profile.html +++ /dev/null @@ -1,79 +0,0 @@ -## mako - -<%page expression_filter="h"/> -<%inherit file="/main.html" /> -<%def name="online_help_token()"><% return "profile" %> -<%namespace name='static' file='/static_content.html'/> - -<%! -import json -from django.urls import reverse -from django.utils.translation import gettext as _ -from openedx.core.djangolib.js_utils import dump_js_escaped_json -from openedx.core.djangolib.markup import HTML -%> - -<%block name="pagetitle">${_("Learner Profile")} - -<%block name="bodyclass">view-profile - -<%block name="headextra"> -<%static:css group='style-course'/> - - -
    -
    -
    -
    - - % if own_profile: -
    -

    ${_("My Profile")}

    -
    - ${_('Build out your profile to personalize your identity on {platform_name}.').format( - platform_name=platform_name, - )} -
    -
    - % endif - -
    -
    -
    - -<%block name="js_extra"> -<%static:require_module module_name="learner_profile/js/learner_profile_factory" class_name="LearnerProfileFactory"> - var options = ${data | n, dump_js_escaped_json}; - LearnerProfileFactory(options); - - diff --git a/openedx/features/learner_profile/tests/views/test_learner_profile.py b/openedx/features/learner_profile/tests/views/test_learner_profile.py deleted file mode 100644 index c4c8352000..0000000000 --- a/openedx/features/learner_profile/tests/views/test_learner_profile.py +++ /dev/null @@ -1,281 +0,0 @@ -""" Tests for student profile views. """ - - -import datetime -from unittest import mock - -import ddt -from django.conf import settings -from django.test.client import RequestFactory -from django.urls import reverse -from edx_toggles.toggles.testutils import override_waffle_flag -from opaque_keys.edx.locator import CourseLocator - -from common.djangoapps.course_modes.models import CourseMode -from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory -from common.djangoapps.util.testing import UrlResetMixin -from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory -from lms.envs.test import CREDENTIALS_PUBLIC_SERVICE_URL -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin -from openedx.features.learner_profile.toggles import REDIRECT_TO_PROFILE_MICROFRONTEND -from openedx.features.learner_profile.views.learner_profile import learner_profile_context -from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order -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 - - -@ddt.ddt -class LearnerProfileViewTest(SiteMixin, UrlResetMixin, ModuleStoreTestCase): - """ Tests for the student profile view. """ - - USERNAME = "username" - OTHER_USERNAME = "other_user" - PASSWORD = "password" - DOWNLOAD_URL = "http://www.example.com/certificate.pdf" - CONTEXT_DATA = [ - 'default_public_account_fields', - 'accounts_api_url', - 'preferences_api_url', - 'account_settings_page_url', - 'has_preferences_access', - 'own_profile', - 'country_options', - 'language_options', - 'account_settings_data', - 'preferences_data', - ] - - def setUp(self): - super().setUp() - self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD) - self.other_user = UserFactory.create(username=self.OTHER_USERNAME, password=self.PASSWORD) - self.client.login(username=self.USERNAME, password=self.PASSWORD) - self.course = CourseFactory.create( - start=datetime.datetime(2013, 9, 16, 7, 17, 28), - end=datetime.datetime.now(), - certificate_available_date=datetime.datetime.now(), - ) - - def test_context(self): - """ - Verify learner profile page context data. - """ - request = RequestFactory().get('/url') - request.user = self.user - - context = learner_profile_context(request, self.USERNAME, self.user.is_staff) - - assert context['data']['default_public_account_fields'] == \ - settings.ACCOUNT_VISIBILITY_CONFIGURATION['public_fields'] - - assert context['data']['accounts_api_url'] == \ - reverse('accounts_api', kwargs={'username': self.user.username}) - - assert context['data']['preferences_api_url'] == \ - reverse('preferences_api', kwargs={'username': self.user.username}) - - assert context['data']['profile_image_upload_url'] == \ - reverse('profile_image_upload', kwargs={'username': self.user.username}) - - assert context['data']['profile_image_remove_url'] == \ - reverse('profile_image_remove', kwargs={'username': self.user.username}) - - assert context['data']['profile_image_max_bytes'] == settings.PROFILE_IMAGE_MAX_BYTES - - assert context['data']['profile_image_min_bytes'] == settings.PROFILE_IMAGE_MIN_BYTES - - assert context['data']['account_settings_page_url'] == reverse('account_settings') - - for attribute in self.CONTEXT_DATA: - assert attribute in context['data'] - - def test_view(self): - """ - Verify learner profile page view. - """ - profile_path = reverse('learner_profile', kwargs={'username': self.USERNAME}) - response = self.client.get(path=profile_path) - - for attribute in self.CONTEXT_DATA: - self.assertContains(response, attribute) - - def test_redirect_view(self): - with override_waffle_flag(REDIRECT_TO_PROFILE_MICROFRONTEND, active=True): - profile_path = reverse('learner_profile', kwargs={'username': self.USERNAME}) - - # Test with waffle flag active and site setting disabled, does not redirect - response = self.client.get(path=profile_path) - for attribute in self.CONTEXT_DATA: - self.assertContains(response, attribute) - - # Test with waffle flag active and site setting enabled, redirects to microfrontend - site_domain = 'othersite.example.com' - self.set_up_site(site_domain, { - 'SITE_NAME': site_domain, - 'ENABLE_PROFILE_MICROFRONTEND': True - }) - self.client.login(username=self.USERNAME, password=self.PASSWORD) - response = self.client.get(path=profile_path) - profile_url = settings.PROFILE_MICROFRONTEND_URL - self.assertRedirects(response, profile_url + self.USERNAME, fetch_redirect_response=False) - - def test_records_link(self): - profile_path = reverse('learner_profile', kwargs={'username': self.USERNAME}) - response = self.client.get(path=profile_path) - self.assertContains(response, f'') - - def test_undefined_profile_page(self): - """ - Verify that a 404 is returned for a non-existent profile page. - """ - profile_path = reverse('learner_profile', kwargs={'username': "no_such_user"}) - response = self.client.get(path=profile_path) - assert 404 == response.status_code - - def _create_certificate(self, course_key=None, enrollment_mode=CourseMode.HONOR, status='downloadable'): - """Simulate that the user has a generated certificate. """ - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, mode=enrollment_mode) - return GeneratedCertificateFactory( - user=self.user, - course_id=course_key or self.course.id, - mode=enrollment_mode, - download_url=self.DOWNLOAD_URL, - status=status, - ) - - @ddt.data(CourseMode.HONOR, CourseMode.PROFESSIONAL, CourseMode.VERIFIED) - def test_certificate_visibility(self, cert_mode): - """ - Verify that certificates are displayed with the correct card mode. - """ - # Add new certificate - cert = self._create_certificate(enrollment_mode=cert_mode) - cert.save() - - response = self.client.get(f'/u/{self.user.username}') - - self.assertContains(response, f'card certificate-card mode-{cert_mode}') - - @ddt.data( - ['downloadable', True], - ['notpassing', False], - ) - @ddt.unpack - def test_certificate_status_visibility(self, status, is_passed_status): - """ - Verify that certificates are only displayed for passing status. - """ - # Add new certificate - cert = self._create_certificate(status=status) - cert.save() - - # Ensure that this test is actually using both passing and non-passing certs. - assert CertificateStatuses.is_passing_status(cert.status) == is_passed_status - - response = self.client.get(f'/u/{self.user.username}') - - if is_passed_status: - self.assertContains(response, f'card certificate-card mode-{cert.mode}') - else: - self.assertNotContains(response, f'card certificate-card mode-{cert.mode}') - - def test_certificate_for_missing_course(self): - """ - Verify that a certificate is not shown for a missing course. - """ - # Add new certificate - cert = self._create_certificate(course_key=CourseLocator.from_string('course-v1:edX+INVALID+1')) - cert.save() - - response = self.client.get(f'/u/{self.user.username}') - - self.assertNotContains(response, f'card certificate-card mode-{cert.mode}') - - @ddt.data(True, False) - def test_no_certificate_visibility(self, own_profile): - """ - Verify that the 'You haven't earned any certificates yet.' well appears on the user's - own profile when they do not have certificates and does not appear when viewing - another user that does not have any certificates. - """ - profile_username = self.user.username if own_profile else self.other_user.username - response = self.client.get(f'/u/{profile_username}') - - if own_profile: - self.assertContains(response, 'You haven't earned any certificates yet.') - else: - self.assertNotContains(response, 'You haven't earned any certificates yet.') - - @ddt.data(True, False) - def test_explore_courses_visibility(self, courses_browsable): - with mock.patch.dict('django.conf.settings.FEATURES', {'COURSES_ARE_BROWSABLE': courses_browsable}): - response = self.client.get(f'/u/{self.user.username}') - if courses_browsable: - self.assertContains(response, 'Explore New Courses') - else: - self.assertNotContains(response, 'Explore New Courses') - - def test_certificate_for_visibility_for_not_viewable_course(self): - """ - Verify that a certificate is not shown if certificate are not viewable to users. - """ - # add new course with certificate_available_date is future date. - course = CourseFactory.create( - certificate_available_date=datetime.datetime.now() + datetime.timedelta(days=5), - certificates_display_behavior=CertificatesDisplayBehaviors.END_WITH_DATE - ) - - cert = self._create_certificate(course_key=course.id) - cert.save() - - response = self.client.get(f'/u/{self.user.username}') - - self.assertNotContains(response, f'card certificate-card mode-{cert.mode}') - - def test_certificates_visible_only_for_staff_and_profile_user(self): - """ - Verify that certificates data are passed to template only in case of staff user - and profile user. - """ - request = RequestFactory().get('/url') - request.user = self.user - profile_username = self.other_user.username - user_is_staff = True - context = learner_profile_context(request, profile_username, user_is_staff) - - assert 'achievements_fragment' in context - - user_is_staff = False - context = learner_profile_context(request, profile_username, user_is_staff) - assert 'achievements_fragment' not in context - - profile_username = self.user.username - context = learner_profile_context(request, profile_username, user_is_staff) - assert 'achievements_fragment' in context - - @mock.patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) - def test_certificate_visibility_with_no_cert_config(self): - """ - Verify that certificates are not displayed until there is an active - certificate configuration. - """ - # Add new certificate - cert = self._create_certificate(enrollment_mode=CourseMode.VERIFIED) - cert.download_url = '' - cert.save() - - response = self.client.get(f'/u/{self.user.username}') - self.assertNotContains( - response, f'card certificate-card mode-{CourseMode.VERIFIED}' - ) - - course_overview = CourseOverview.get_from_id(self.course.id) - course_overview.has_any_active_web_certificate = True - course_overview.save() - - response = self.client.get(f'/u/{self.user.username}') - self.assertContains( - response, f'card certificate-card mode-{CourseMode.VERIFIED}' - ) diff --git a/openedx/features/learner_profile/toggles.py b/openedx/features/learner_profile/toggles.py deleted file mode 100644 index 08378b6e90..0000000000 --- a/openedx/features/learner_profile/toggles.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Toggles for Learner Profile page. -""" - - -from edx_toggles.toggles import WaffleFlag -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers - -# Namespace for learner profile waffle flags. -WAFFLE_FLAG_NAMESPACE = 'learner_profile' - -# Waffle flag to redirect to another learner profile experience. -# .. toggle_name: learner_profile.redirect_to_microfrontend -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Supports staged rollout of a new micro-frontend-based implementation of the profile page. -# .. toggle_use_cases: temporary, open_edx -# .. toggle_creation_date: 2019-02-19 -# .. toggle_target_removal_date: 2020-12-31 -# .. toggle_warning: Also set settings.PROFILE_MICROFRONTEND_URL and site's ENABLE_PROFILE_MICROFRONTEND. -# .. toggle_tickets: DEPR-17 -REDIRECT_TO_PROFILE_MICROFRONTEND = WaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.redirect_to_microfrontend', __name__) - - -def should_redirect_to_profile_microfrontend(): - return ( - configuration_helpers.get_value('ENABLE_PROFILE_MICROFRONTEND') and - REDIRECT_TO_PROFILE_MICROFRONTEND.is_enabled() - ) diff --git a/openedx/features/learner_profile/urls.py b/openedx/features/learner_profile/urls.py deleted file mode 100644 index 0f02076568..0000000000 --- a/openedx/features/learner_profile/urls.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -Defines URLs for the learner profile. -""" - - -from django.conf import settings -from django.urls import path, re_path - -from openedx.features.learner_profile.views.learner_profile import learner_profile - -from .views.learner_achievements import LearnerAchievementsFragmentView - -urlpatterns = [ - re_path( - r'^{username_pattern}$'.format( - username_pattern=settings.USERNAME_PATTERN, - ), - learner_profile, - name='learner_profile', - ), - path('achievements', LearnerAchievementsFragmentView.as_view(), - name='openedx.learner_profile.learner_achievements_fragment_view', - ), -] diff --git a/openedx/features/learner_profile/views/learner_achievements.py b/openedx/features/learner_profile/views/learner_achievements.py deleted file mode 100644 index 6a7a07e339..0000000000 --- a/openedx/features/learner_profile/views/learner_achievements.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -Views to render a learner's achievements. -""" - - -from django.template.loader import render_to_string -from web_fragments.fragment import Fragment - -from lms.djangoapps.certificates import api as certificate_api -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from openedx.core.djangoapps.plugin_api.views import EdxFragmentView - - -class LearnerAchievementsFragmentView(EdxFragmentView): - """ - A fragment to render a learner's achievements. - """ - - def render_to_fragment(self, request, username=None, own_profile=False, **kwargs): # lint-amnesty, pylint: disable=arguments-differ - """ - Renders the current learner's achievements. - """ - course_certificates = self._get_ordered_certificates_for_user(request, username) - context = { - 'course_certificates': course_certificates, - 'own_profile': own_profile, - 'disable_courseware_js': True, - } - if course_certificates or own_profile: - html = render_to_string('learner_profile/learner-achievements-fragment.html', context) - return Fragment(html) - else: - return None - - def _get_ordered_certificates_for_user(self, request, username): - """ - Returns a user's certificates sorted by course name. - """ - course_certificates = certificate_api.get_certificates_for_user(username) - passing_certificates = [] - for course_certificate in course_certificates: - if course_certificate.get('is_passing', False): - course_key = course_certificate['course_key'] - try: - course_overview = CourseOverview.get_from_id(course_key) - course_certificate['course'] = course_overview - if certificate_api.certificates_viewable_for_course(course_overview): - # add certificate into passing certificate list only if it's a PDF certificate - # or there is an active certificate configuration. - if course_certificate['is_pdf_certificate'] or course_overview.has_any_active_web_certificate: - passing_certificates.append(course_certificate) - except CourseOverview.DoesNotExist: - # This is unlikely to fail as the course should exist. - # Ideally the cert should have all the information that - # it needs. This might be solved by the Credentials API. - pass - passing_certificates.sort(key=lambda certificate: certificate['course'].display_name_with_default) - return passing_certificates diff --git a/openedx/features/learner_profile/views/learner_profile.py b/openedx/features/learner_profile/views/learner_profile.py deleted file mode 100644 index 6a3a251fde..0000000000 --- a/openedx/features/learner_profile/views/learner_profile.py +++ /dev/null @@ -1,128 +0,0 @@ -""" Views for a student's profile information. """ - - -from django.conf import settings -from django.contrib.auth.decorators import login_required -from django.contrib.staticfiles.storage import staticfiles_storage -from django.core.exceptions import ObjectDoesNotExist -from django.http import Http404 -from django.shortcuts import redirect, render -from django.urls import reverse -from django.views.decorators.http import require_http_methods -from django_countries import countries - -from common.djangoapps.edxmako.shortcuts import marketing_link -from openedx.core.djangoapps.credentials.utils import get_credentials_records_url -from openedx.core.djangoapps.programs.models import ProgramsApiConfig -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.user_api.accounts.api import get_account_settings -from openedx.core.djangoapps.user_api.errors import UserNotAuthorized, UserNotFound -from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences -from openedx.features.learner_profile.toggles import should_redirect_to_profile_microfrontend -from openedx.features.learner_profile.views.learner_achievements import LearnerAchievementsFragmentView -from common.djangoapps.student.models import User - - -@login_required -@require_http_methods(['GET']) -def learner_profile(request, username): - """Render the profile page for the specified username. - - Args: - request (HttpRequest) - username (str): username of user whose profile is requested. - - Returns: - HttpResponse: 200 if the page was sent successfully - HttpResponse: 302 if not logged in (redirect to login page) - HttpResponse: 405 if using an unsupported HTTP method - Raises: - Http404: 404 if the specified user is not authorized or does not exist - - Example usage: - GET /account/profile - """ - if should_redirect_to_profile_microfrontend(): - profile_microfrontend_url = f"{settings.PROFILE_MICROFRONTEND_URL}{username}" - if request.GET: - profile_microfrontend_url += f'?{request.GET.urlencode()}' - return redirect(profile_microfrontend_url) - - try: - context = learner_profile_context(request, username, request.user.is_staff) - return render( - request=request, - template_name='learner_profile/learner_profile.html', - context=context - ) - except (UserNotAuthorized, UserNotFound, ObjectDoesNotExist): - raise Http404 # lint-amnesty, pylint: disable=raise-missing-from - - -def learner_profile_context(request, profile_username, user_is_staff): - """Context for the learner profile page. - - Args: - logged_in_user (object): Logged In user. - profile_username (str): username of user whose profile is requested. - user_is_staff (bool): Logged In user has staff access. - build_absolute_uri_func (): - - Returns: - dict - - Raises: - ObjectDoesNotExist: the specified profile_username does not exist. - """ - profile_user = User.objects.get(username=profile_username) - logged_in_user = request.user - - own_profile = (logged_in_user.username == profile_username) - - account_settings_data = get_account_settings(request, [profile_username])[0] - - preferences_data = get_user_preferences(profile_user, profile_username) - - context = { - 'own_profile': own_profile, - 'platform_name': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME), - 'data': { - 'profile_user_id': profile_user.id, - 'default_public_account_fields': settings.ACCOUNT_VISIBILITY_CONFIGURATION['public_fields'], - 'default_visibility': settings.ACCOUNT_VISIBILITY_CONFIGURATION['default_visibility'], - 'accounts_api_url': reverse("accounts_api", kwargs={'username': profile_username}), - 'preferences_api_url': reverse('preferences_api', kwargs={'username': profile_username}), - 'preferences_data': preferences_data, - 'account_settings_data': account_settings_data, - 'profile_image_upload_url': reverse('profile_image_upload', kwargs={'username': profile_username}), - 'profile_image_remove_url': reverse('profile_image_remove', kwargs={'username': profile_username}), - 'profile_image_max_bytes': settings.PROFILE_IMAGE_MAX_BYTES, - 'profile_image_min_bytes': settings.PROFILE_IMAGE_MIN_BYTES, - 'account_settings_page_url': reverse('account_settings'), - 'has_preferences_access': (logged_in_user.username == profile_username or user_is_staff), - 'own_profile': own_profile, - 'country_options': list(countries), - 'find_courses_url': marketing_link('COURSES'), - 'language_options': settings.ALL_LANGUAGES, - 'backpack_ui_img': staticfiles_storage.url('certificates/images/backpack-ui.png'), - 'platform_name': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME), - 'social_platforms': settings.SOCIAL_PLATFORMS, - 'enable_coppa_compliance': settings.ENABLE_COPPA_COMPLIANCE, - 'parental_consent_age_limit': settings.PARENTAL_CONSENT_AGE_LIMIT - }, - 'show_program_listing': ProgramsApiConfig.is_enabled(), - 'show_dashboard_tabs': True, - 'disable_courseware_js': True, - 'nav_hidden': True, - 'records_url': get_credentials_records_url(), - } - - if own_profile or user_is_staff: - achievements_fragment = LearnerAchievementsFragmentView().render_to_fragment( - request, - username=profile_user.username, - own_profile=own_profile, - ) - context['achievements_fragment'] = achievements_fragment - - return context diff --git a/openedx/tests/xblock_integration/test_external_xblocks.py b/openedx/tests/xblock_integration/test_external_xblocks.py index c4fc4b74e3..01bdf2133c 100644 --- a/openedx/tests/xblock_integration/test_external_xblocks.py +++ b/openedx/tests/xblock_integration/test_external_xblocks.py @@ -9,7 +9,7 @@ That be the dragon here. """ -import pkg_resources +from importlib.metadata import entry_points class DuplicateXBlockTest(Exception): @@ -37,7 +37,7 @@ class InvalidTestName(Exception): xblock_loaded = False # pylint: disable=invalid-name -for entrypoint in pkg_resources.iter_entry_points(group="xblock.test.v0"): +for entrypoint in entry_points(group="xblock.test.v0"): plugin = entrypoint.load() classname = plugin.__name__ if classname in globals(): diff --git a/package-lock.json b/package-lock.json index edb65a6f70..b3b91cc70b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,17 +9,20 @@ "version": "0.1.0", "hasInstallScript": true, "dependencies": { - "@babel/core": "7.25.2", + "@babel/core": "7.26.0", "@babel/plugin-proposal-object-rest-spread": "^7.18.9", "@babel/plugin-transform-object-assign": "^7.18.6", "@babel/preset-env": "^7.19.0", - "@babel/preset-react": "7.24.7", + "@babel/preset-react": "7.26.3", "@edx/brand-edx.org": "^2.0.7", "@edx/edx-bootstrap": "1.0.4", "@edx/edx-proctoring": "^4.18.1", "@edx/frontend-component-cookie-policy-banner": "2.2.0", "@edx/paragon": "2.6.4", "@edx/studio-frontend": "^2.1.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^12.1.5", + "@testing-library/user-event": "^12.8.3", "babel-loader": "^9.1.3", "babel-plugin-transform-class-properties": "6.24.1", "babel-polyfill": "6.26.0", @@ -29,95 +32,83 @@ "bootstrap": "4.0.0", "camelize": "1.0.1", "classnames": "2.5.1", - "css-loader": "0.28.8", + "css-loader": "7.1.2", "datatables": "1.10.18", "datatables.net-fixedcolumns": "5.0.4", "edx-proctoring-proctortrack": "git+https://git@github.com/anupdhabarde/edx-proctoring-proctortrack.git#f0fa9edbd16aa5af5a41ac309d2609e529ea8732", - "edx-ui-toolkit": "1.8.6", + "edx-ui-toolkit": "1.8.7", "exports-loader": "0.6.4", "file-loader": "^6.2.0", "font-awesome": "4.7.0", - "hls.js": "1.5.17", + "hls.js": "0.14.17", "imports-loader": "0.8.0", - "jest-environment-jsdom": "^26.0.0", + "jest-environment-jsdom": "^29.0.0", "jquery": "2.2.4", "jquery-migrate": "1.4.1", "jquery.scrollto": "2.1.3", "js-cookie": "3.0.5", "moment": "2.30.1", - "moment-timezone": "0.5.46", - "node-gyp": "10.0.1", - "picturefill": "3.0.3", + "moment-timezone": "0.5.47", + "node-gyp": "11.1.0", "popper.js": "1.16.1", - "prop-types": "15.6.0", + "prop-types": "15.8.1", "raw-loader": "0.5.1", "react": "16.14.0", "react-dom": "16.14.0", "react-focus-lock": "^1.19.1", - "react-redux": "5.0.7", - "react-router-dom": "5.1.2", - "react-slick": "0.30.2", + "react-redux": "5.1.2", + "react-router-dom": "5.3.4", + "react-slick": "0.30.3", "redux": "3.7.2", "redux-thunk": "2.2.0", "requirejs": "2.3.7", - "rtlcss": "2.6.2", + "rtlcss": "4.3.0", "sass": "^1.54.8", - "sass-loader": "^14.1.1", + "sass-loader": "^16.0.0", "scriptjs": "2.5.9", - "style-loader": "0.23.1", + "style-loader": "4.0.0", "svg-inline-loader": "0.8.2", - "uglify-js": "2.7.0", - "underscore": "1.13.1", + "uglify-js": "3.19.3", + "underscore": "1.13.7", "underscore.string": "3.3.6", "webpack": "^5.90.3", "webpack-bundle-tracker": "0.4.3", "webpack-merge": "4.2.2", - "whatwg-fetch": "2.0.4", "which-country": "1.0.0" }, "devDependencies": { - "@edx/eslint-config": "^4.0.0", "@edx/mockprock": "github:openedx/mockprock#d70b05231bd46b0122616c24e209c890ef2633c0", "@edx/stylelint-config-edx": "2.3.3", - "babel-jest": "26.6.3", - "enzyme": "3.11.0", - "enzyme-adapter-react-16": "1.15.8", - "eslint-import-resolver-webpack": "0.13.9", + "babel-jest": "29.7.0", "jasmine-core": "2.6.4", "jasmine-jquery": "git+https://git@github.com/velesin/jasmine-jquery.git#ebad463d592d3fea00c69f26ea18a930e09c7b58", - "jest": "26.6.3", - "jest-enzyme": "6.1.2", + "jest": "29.7.0", "karma": "0.13.22", - "karma-chrome-launcher": "0.2.3", - "karma-coverage": "0.5.5", - "karma-firefox-launcher": "0.1.7", + "karma-chrome-launcher": "3.2.0", + "karma-coverage": "2.2.1", + "karma-firefox-launcher": "2.1.3", "karma-jasmine": "0.3.8", "karma-jasmine-html-reporter": "0.2.2", - "karma-junit-reporter": "1.2.0", - "karma-requirejs": "0.2.6", + "karma-junit-reporter": "2.0.1", + "karma-requirejs": "1.1.0", "karma-selenium-webdriver-launcher": "github:openedx/karma-selenium-webdriver-launcher#0.0.4-openedx.0", "karma-sourcemap-loader": "0.4.0", - "karma-spec-reporter": "0.0.36", + "karma-spec-reporter": "0.0.20", "karma-webpack": "^5.0.1", "plato": "1.7.0", "react-test-renderer": "16.14.0", - "selenium-webdriver": "4.26.0", - "sinon": "2.4.1", + "selenium-webdriver": "4.30.0", + "sinon": "19.0.2", "squirejs": "0.1.0", "string-replace-loader": "^3.1.0", "stylelint-formatter-pretty": "4.0.1", "webpack-cli": "^5.1.4" } }, - "node_modules/@aashutoshrathi/word-wrap": { - "version": "1.2.6", - "resolved": "https://registry.npmjs.org/@aashutoshrathi/word-wrap/-/word-wrap-1.2.6.tgz", - "integrity": "sha512-1Yjs2SvM8TflER/OD3cOjhWWOZb58A2t7wpE2S9XfBYTiIl+XFhQG2bjy4Pu1I+EAlCNUzRDYDdFwFYUKvXcIA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=0.10.0" - } + "node_modules/@adobe/css-tools": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.2.tgz", + "integrity": "sha512-baYZExFpsdkBNuvGKTKWCwKH57HRZLVtycZS05WTQNVOiXVSeAki3nU35zlRbToeMW8aHlJfyS+1C4BOv27q0A==" }, "node_modules/@ampproject/remapping": { "version": "2.3.0", @@ -132,12 +123,13 @@ } }, "node_modules/@babel/code-frame": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.24.7.tgz", - "integrity": "sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", "license": "MIT", "dependencies": { - "@babel/highlight": "^7.24.7", + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", "picocolors": "^1.0.0" }, "engines": { @@ -145,30 +137,30 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.2.tgz", - "integrity": "sha512-bYcppcpKBvX4znYaPEeFau03bp89ShqNMLs+rmdptMw+heSZh9+z84d2YG+K7cYLbWwzdjtDoW/uqZmPjulClQ==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.2.tgz", + "integrity": "sha512-Z0WgzSEa+aUcdiJuCIqgujCshpMWgUpgOxXotrYPSA53hA3qopNaqcJpyr0hVb1FeWdnqFA35/fUtXgBK8srQg==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz", - "integrity": "sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", + "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.25.0", - "@babel/helper-compilation-targets": "^7.25.2", - "@babel/helper-module-transforms": "^7.25.2", - "@babel/helpers": "^7.25.0", - "@babel/parser": "^7.25.0", - "@babel/template": "^7.25.0", - "@babel/traverse": "^7.25.2", - "@babel/types": "^7.25.2", + "@babel/code-frame": "^7.26.0", + "@babel/generator": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.0", + "@babel/parser": "^7.26.0", + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.26.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -190,54 +182,55 @@ "license": "MIT" }, "node_modules/@babel/generator": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.0.tgz", - "integrity": "sha512-3LEEcj3PVW8pW2R1SR1M89g/qrYk/m/mB/tLqn7dn4sbBUQyTqnlod+II2U4dqiGtUmkcnAmkMDralTFZttRiw==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.2.tgz", + "integrity": "sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==", "license": "MIT", "dependencies": { - "@babel/types": "^7.25.0", + "@babel/parser": "^7.26.2", + "@babel/types": "^7.26.0", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", - "jsesc": "^2.5.1" + "jsesc": "^3.0.2" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.7.tgz", - "integrity": "sha512-BaDeOonYvhdKw+JoMVkAixAAJzG2jVPIwWoKBPdYuY9b452e2rPuI9QPYh3KpofZ3pW2akOmwZLOiOsHMiqRAg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", "license": "MIT", "dependencies": { - "@babel/types": "^7.24.7" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.24.7.tgz", - "integrity": "sha512-xZeCVVdwb4MsDBkkyZ64tReWYrLRHlMN72vP7Bdm3OUOuyFZExhsHUUnuWnm2/XOlAJzR0LfPpB56WXZn0X/lA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.25.9.tgz", + "integrity": "sha512-C47lC7LIDCnz0h4vai/tpNOI95tCd5ZT3iBt/DBH5lXKHZsyNQv18yf1wIIg2ntiQNgmAvA+DgZ82iW8Qdym8g==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz", - "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", + "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.25.2", - "@babel/helper-validator-option": "^7.24.8", - "browserslist": "^4.23.1", + "@babel/compat-data": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -246,17 +239,17 @@ } }, "node_modules/@babel/helper-create-class-features-plugin": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.0.tgz", - "integrity": "sha512-GYM6BxeQsETc9mnct+nIIpf63SAyzvyYN7UB/IlTyd+MBg06afFGp0mIeUqGyWgS2mxad6vqbMrHVlaL3m70sQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", + "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-member-expression-to-functions": "^7.24.8", - "@babel/helper-optimise-call-expression": "^7.24.7", - "@babel/helper-replace-supers": "^7.25.0", - "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", - "@babel/traverse": "^7.25.0", + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/traverse": "^7.25.9", "semver": "^6.3.1" }, "engines": { @@ -267,13 +260,13 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.2.tgz", - "integrity": "sha512-+wqVGP+DFmqwFD3EH6TMTfUNeqDehV3E/dl+Sd54eaXqm17tEUNbEIn4sVivVowbvUpOtIGxdo3GoXyDH9N/9g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.9.tgz", + "integrity": "sha512-ORPNZ3h6ZRkOyAa/SaHU+XsLZr0UQzRwuDQ0cczIA17nAzZ+85G5cVkOJIj7QavLZGSe8QXUmNFxSZzjcZF9bw==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "regexpu-core": "^5.3.1", + "@babel/helper-annotate-as-pure": "^7.25.9", + "regexpu-core": "^6.1.1", "semver": "^6.3.1" }, "engines": { @@ -284,9 +277,10 @@ } }, "node_modules/@babel/helper-define-polyfill-provider": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.1.tgz", - "integrity": "sha512-o7SDgTJuvx5vLKD6SFvkydkSMBvahDKGiNJzG22IZYXhiqoe9efY7zocICBgzHV4IRg5wdgl2nEL/tulKIEIbA==", + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", + "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", + "license": "MIT", "dependencies": { "@babel/helper-compilation-targets": "^7.22.6", "@babel/helper-plugin-utils": "^7.22.5", @@ -299,41 +293,40 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.8.tgz", - "integrity": "sha512-LABppdt+Lp/RlBxqrh4qgf1oEH/WxdzQNDJIu5gC/W1GyvPVrOBiItmmM8wan2fm4oYqFuFfkXmlGpLQhPY8CA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", + "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.24.8", - "@babel/types": "^7.24.8" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-imports": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz", - "integrity": "sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz", - "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.24.7", - "@babel/helper-simple-access": "^7.24.7", - "@babel/helper-validator-identifier": "^7.24.7", - "@babel/traverse": "^7.25.2" + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -343,35 +336,35 @@ } }, "node_modules/@babel/helper-optimise-call-expression": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.24.7.tgz", - "integrity": "sha512-jKiTsW2xmWwxT1ixIdfXUZp+P5yURx2suzLZr5Hi64rURpDYdMW0pv+Uf17EYk2Rd428Lx4tLsnjGJzYKDM/6A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", + "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", "license": "MIT", "dependencies": { - "@babel/types": "^7.24.7" + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz", - "integrity": "sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", + "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-remap-async-to-generator": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.0.tgz", - "integrity": "sha512-NhavI2eWEIz/H9dbrG0TuOicDhNexze43i5z7lEqwYm0WEZVTwnPpA0EafUTP7+6/W79HWIP2cTe3Z5NiSTVpw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", + "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-wrap-function": "^7.25.0", - "@babel/traverse": "^7.25.0" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-wrap-function": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -381,14 +374,14 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.0.tgz", - "integrity": "sha512-q688zIvQVYtZu+i2PsdIu/uWGRpfxzr5WESsfpShfZECkO+d2o+WROWezCi/Q6kJ0tfPa5+pUGUlfx2HhrA3Bg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.9.tgz", + "integrity": "sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==", "license": "MIT", "dependencies": { - "@babel/helper-member-expression-to-functions": "^7.24.8", - "@babel/helper-optimise-call-expression": "^7.24.7", - "@babel/traverse": "^7.25.0" + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -398,107 +391,92 @@ } }, "node_modules/@babel/helper-simple-access": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.24.7.tgz", - "integrity": "sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.9.tgz", + "integrity": "sha512-c6WHXuiaRsJTyHYLJV75t9IqsmTbItYfdj99PnzYGQZkYKvan5/2jKJ7gu31J3/BJ/A18grImSPModuyG/Eo0Q==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-skip-transparent-expression-wrappers": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.24.7.tgz", - "integrity": "sha512-IO+DLT3LQUElMbpzlatRASEyQtfhSE0+m465v++3jyyXeBTBUjtVZg28/gHeV5mrTJqvEKhKroBGAvhW+qPHiQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", + "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", "license": "MIT", "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", - "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz", - "integrity": "sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-validator-option": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz", - "integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", "license": "MIT", "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-wrap-function": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.0.tgz", - "integrity": "sha512-s6Q1ebqutSiZnEjaofc/UKDyC4SbzV5n5SrA2Gq8UawLycr3i04f1dX4OzoQVnexm6aOCh37SQNYlJ/8Ku+PMQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", + "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", "license": "MIT", "dependencies": { - "@babel/template": "^7.25.0", - "@babel/traverse": "^7.25.0", - "@babel/types": "^7.25.0" + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helpers": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.0.tgz", - "integrity": "sha512-MjgLZ42aCm0oGjJj8CtSM3DB8NOOf8h2l7DCTePJs29u+v7yO/RBX9nShlKMgFnRks/Q4tBAe7Hxnov9VkGwLw==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", + "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", "license": "MIT", "dependencies": { - "@babel/template": "^7.25.0", - "@babel/types": "^7.25.0" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/highlight": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/highlight/-/highlight-7.24.7.tgz", - "integrity": "sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==", - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.24.7", - "chalk": "^2.4.2", - "js-tokens": "^4.0.0", - "picocolors": "^1.0.0" + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.25.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz", - "integrity": "sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==", + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.2.tgz", + "integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==", "license": "MIT", "dependencies": { - "@babel/types": "^7.25.2" + "@babel/types": "^7.26.0" }, "bin": { "parser": "bin/babel-parser.js" @@ -508,13 +486,13 @@ } }, "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { - "version": "7.25.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.3.tgz", - "integrity": "sha512-wUrcsxZg6rqBXG05HG1FPYgsP6EvwF4WpBbxIpWIIYnH8wG0gzx3yZY3dtEHas4sTAOGkbTsc9EGPxwff8lRoA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", + "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/traverse": "^7.25.3" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -524,12 +502,12 @@ } }, "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.0.tgz", - "integrity": "sha512-Bm4bH2qsX880b/3ziJ8KD711LT7z4u8CFudmjqle65AZj/HNUFhEf90dqYv6O86buWvSBmeQDjv0Tn2aF/bIBA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", + "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -539,12 +517,12 @@ } }, "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.0.tgz", - "integrity": "sha512-lXwdNZtTmeVOOFtwM/WDe7yg1PL8sYhRk/XH0FzbR2HDQ0xC+EnQ/JHeoMYSavtU115tnUk0q9CDyq8si+LMAA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", + "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -554,14 +532,14 @@ } }, "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.24.7.tgz", - "integrity": "sha512-+izXIbke1T33mY4MSNnrqhPXDz01WYhEf3yF5NbnUtkiNnm+XBZJl3kNfoK6NKmYlz/D07+l2GWVK/QfDkNCuQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", - "@babel/plugin-transform-optional-chaining": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -571,13 +549,13 @@ } }, "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.0.tgz", - "integrity": "sha512-tggFrk1AIShG/RUQbEwt2Tr/E+ObkfwrPjR6BjbRvsx24+PSjK8zrq0GWPNCjo8qpRx4DuJzlcvWJqlm+0h3kw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", + "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/traverse": "^7.25.0" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -620,6 +598,7 @@ "version": "7.8.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-async-generators/-/plugin-syntax-async-generators-7.8.4.tgz", "integrity": "sha512-tycmZxkGfZaxhMRbXlPXuVFpdWlXpir2W4AMhSJgRKzk/eDlIXOhb2LHWoLpDF7TEHylV5zNhykX6KAgHJmTNw==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -643,6 +622,7 @@ "version": "7.12.13", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-properties/-/plugin-syntax-class-properties-7.12.13.tgz", "integrity": "sha512-fm4idjKla0YahUNgFNLCB0qySdsoPiZP3iQE3rky0mBUtMZ23yDJ9SJdg6dXTSDnulOVqiF3Hgr9nbXvXTQZYA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.12.13" }, @@ -650,49 +630,13 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-class-static-block": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-class-static-block/-/plugin-syntax-class-static-block-7.14.5.tgz", - "integrity": "sha512-b+YyPmr6ldyNnM6sqYeMWE+bgJcJpO6yS4QD7ymxgH34GBPNDM/THBh8iunyvKIZztiwLH4CJZ0RxTk9emgpjw==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-dynamic-import": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-dynamic-import/-/plugin-syntax-dynamic-import-7.8.3.tgz", - "integrity": "sha512-5gdGbFon+PszYzqs83S3E5mpi7/y/8M9eC90MRTZfduQOYW76ig6SOSPNe41IG5LoP3FGBn2N0RjVDSQiS94kQ==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-syntax-export-namespace-from": { - "version": "7.8.3", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-export-namespace-from/-/plugin-syntax-export-namespace-from-7.8.3.tgz", - "integrity": "sha512-MXf5laXo6c1IbEbegDmzGPwGNTsHZmEy6QGznu5Sh2UCWvueywb2ee+CCE4zQiZstxU9BMoQO9i6zUFSY0Kj0Q==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.8.3" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-syntax-import-assertions": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.24.7.tgz", - "integrity": "sha512-Ec3NRUMoi8gskrkBe3fNmEQfxDvY8bgfQpz6jlk/41kX9eUjvpyqWU7PBP/pLAvMaSQjbMNKJmvX57jP+M6bPg==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", + "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -702,12 +646,12 @@ } }, "node_modules/@babel/plugin-syntax-import-attributes": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.24.7.tgz", - "integrity": "sha512-hbX+lKKeUMGihnK8nvKqmXBInriT3GVjzXKFriV3YC6APGxMbP8RZNFwy91+hocLXq90Mta+HshoB31802bb8A==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", + "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -720,6 +664,7 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-meta/-/plugin-syntax-import-meta-7.10.4.tgz", "integrity": "sha512-Yqfm+XDx0+Prh3VSeEQCPU81yC+JWZ2pDPFSS4ZdpfZhp4MkFMaDC1UqseovEKwSUpnIL7+vK+Clp7bfh0iD7g==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -731,6 +676,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-json-strings/-/plugin-syntax-json-strings-7.8.3.tgz", "integrity": "sha512-lY6kdGpWHvjoe2vk4WrAapEuBR69EMxZl+RoGRhrFGNYVK8mOPAW8VfbT/ZgrFbXlDNiiaxQnAtgVCZ6jv30EA==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -739,12 +685,12 @@ } }, "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.24.7.tgz", - "integrity": "sha512-6ddciUPe/mpMnOKv/U+RSd2vvVy+Yw/JfBB0ZHYjEZt9NLHmCUylNYlsbqCCS1Bffjlb0fCwC9Vqz+sBz6PsiQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.25.9.tgz", + "integrity": "sha512-ld6oezHQMZsZfp6pWtbjaNDF2tiiCYYDqQszHt5VV437lewP9aSi2Of99CK0D0XB21k7FLgnLcmQKyKzynfeAA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -757,6 +703,7 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-logical-assignment-operators/-/plugin-syntax-logical-assignment-operators-7.10.4.tgz", "integrity": "sha512-d8waShlpFDinQ5MtvGU9xDAOzKH47+FFoney2baFIoMr952hKOLp1HR7VszoZvOsV/4+RRszNY7D17ba0te0ig==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -768,6 +715,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-nullish-coalescing-operator/-/plugin-syntax-nullish-coalescing-operator-7.8.3.tgz", "integrity": "sha512-aSff4zPII1u2QD7y+F8oDsz19ew4IGEJg9SVW+bqwpwtfFleiQDMdzA/R+UlWDzfnHFCxxleFT0PMIrR36XLNQ==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -779,6 +727,7 @@ "version": "7.10.4", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-numeric-separator/-/plugin-syntax-numeric-separator-7.10.4.tgz", "integrity": "sha512-9H6YdfkcK/uOnY/K7/aA2xpzaAgkQn37yzWUMRK7OaPOqOpGS1+n0H5hxT9AUw9EsSjPW8SVyMJwYRtWs3X3ug==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.10.4" }, @@ -801,6 +750,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-catch-binding/-/plugin-syntax-optional-catch-binding-7.8.3.tgz", "integrity": "sha512-6VPD0Pc1lpTqw0aKoeRTMiB+kWhAoT24PA+ksWSBrFtl5SIRVpZlwN3NNPQjehA2E/91FV3RjLWoVTglWcSV3Q==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -812,6 +762,7 @@ "version": "7.8.3", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-optional-chaining/-/plugin-syntax-optional-chaining-7.8.3.tgz", "integrity": "sha512-KoK9ErH1MBlCPxV0VANkXW2/dw4vlbGDrFgz8bmUsBGYkFRcbRwMh6cIJubdPrkxRwuGdtCk0v/wPTKbQgBjkg==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.8.0" }, @@ -819,10 +770,11 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-private-property-in-object": { + "node_modules/@babel/plugin-syntax-top-level-await": { "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-private-property-in-object/-/plugin-syntax-private-property-in-object-7.14.5.tgz", - "integrity": "sha512-0wVnp9dxJ72ZUJDV27ZfbSj6iHLoytYZmh3rFcxNnvsJF3ktkzLDZPy/mA17HGsaQT3/DQsWYX1f1QGWkCoVUg==", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", + "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "dev": true, "dependencies": { "@babel/helper-plugin-utils": "^7.14.5" }, @@ -833,12 +785,14 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-syntax-top-level-await": { - "version": "7.14.5", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-top-level-await/-/plugin-syntax-top-level-await-7.14.5.tgz", - "integrity": "sha512-hx++upLv5U1rgYfwe1xBQUhRmU41NEvpUvrp8jkrSCdvGSnM5/qdRMtylJ6PG5OFkBaHkbTAKTnd3/YyESRHFw==", + "node_modules/@babel/plugin-syntax-typescript": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.25.9.tgz", + "integrity": "sha512-hjMgRy5hb8uJJjUcdWunWVcoi9bGpJp8p5Ol1229PoN6aytsLwNMgmdftO23wnCLMfVmTwZDWMPNq/D1SY60JQ==", + "dev": true, + "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.14.5" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -863,12 +817,12 @@ } }, "node_modules/@babel/plugin-transform-arrow-functions": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.24.7.tgz", - "integrity": "sha512-Dt9LQs6iEY++gXUwY03DNFat5C2NbO48jj+j/bSAz6b3HgPs39qcPiYt77fDObIcFwj3/C2ICX9YMwGflUoSHQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", + "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -878,15 +832,14 @@ } }, "node_modules/@babel/plugin-transform-async-generator-functions": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.0.tgz", - "integrity": "sha512-uaIi2FdqzjpAMvVqvB51S42oC2JEVgh0LDsGfZVDysWE8LrJtQC2jvKmOqEYThKyB7bDEb7BP1GYWDm7tABA0Q==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.9.tgz", + "integrity": "sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/helper-remap-async-to-generator": "^7.25.0", - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/traverse": "^7.25.0" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -896,14 +849,14 @@ } }, "node_modules/@babel/plugin-transform-async-to-generator": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.24.7.tgz", - "integrity": "sha512-SQY01PcJfmQ+4Ash7NE+rpbLFbmqA2GPIgqzxfFTL4t1FKRq4zTms/7htKpoCUI9OcFYgzqfmCdH53s6/jn5fA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", + "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/helper-remap-async-to-generator": "^7.24.7" + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -913,12 +866,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoped-functions": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.24.7.tgz", - "integrity": "sha512-yO7RAz6EsVQDaBH18IDJcMB1HnrUn2FJ/Jslc/WtPPWcjhpUJXU/rjbwmluzp7v/ZzWcEhTMXELnnsz8djWDwQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.9.tgz", + "integrity": "sha512-toHc9fzab0ZfenFpsyYinOX0J/5dgJVA2fm64xPewu7CoYHWEivIWKxkK2rMi4r3yQqLnVmheMXRdG+k239CgA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -928,12 +881,12 @@ } }, "node_modules/@babel/plugin-transform-block-scoping": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.0.tgz", - "integrity": "sha512-yBQjYoOjXlFv9nlXb3f1casSHOZkWr29NX+zChVanLg5Nc157CrbEX9D7hxxtTpuFy7Q0YzmmWfJxzvps4kXrQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", + "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -943,13 +896,13 @@ } }, "node_modules/@babel/plugin-transform-class-properties": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.24.7.tgz", - "integrity": "sha512-vKbfawVYayKcSeSR5YYzzyXvsDFWU2mD8U5TFeXtbCPLFUqe7GyCgvO6XDHzje862ODrOwy6WCPmKeWHbCFJ4w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz", + "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -959,14 +912,13 @@ } }, "node_modules/@babel/plugin-transform-class-static-block": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.24.7.tgz", - "integrity": "sha512-HMXK3WbBPpZQufbMG4B46A90PkuuhN9vBCb5T8+VAHqvAqvcLi+2cKoukcpmUYkszLhScU3l1iudhrks3DggRQ==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", + "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-class-static-block": "^7.14.5" + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -976,16 +928,16 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.0.tgz", - "integrity": "sha512-xyi6qjr/fYU304fiRwFbekzkqVJZ6A7hOjWZd+89FVcBqPV3S9Wuozz82xdpLspckeaafntbzglaW4pqpzvtSw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", + "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-compilation-targets": "^7.24.8", - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/helper-replace-supers": "^7.25.0", - "@babel/traverse": "^7.25.0", + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/traverse": "^7.25.9", "globals": "^11.1.0" }, "engines": { @@ -996,13 +948,13 @@ } }, "node_modules/@babel/plugin-transform-computed-properties": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.24.7.tgz", - "integrity": "sha512-25cS7v+707Gu6Ds2oY6tCkUwsJ9YIDbggd9+cu9jzzDgiNq7hR/8dkzxWfKWnTic26vsI3EsCXNd4iEB6e8esQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", + "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/template": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/template": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1012,12 +964,12 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.8.tgz", - "integrity": "sha512-36e87mfY8TnRxc7yc6M9g9gOB7rKgSahqkIKwLpz4Ppk2+zC2Cy1is0uwtuSG6AE4zlTOUa+7JGz9jCJGLqQFQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", + "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1027,13 +979,13 @@ } }, "node_modules/@babel/plugin-transform-dotall-regex": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.24.7.tgz", - "integrity": "sha512-ZOA3W+1RRTSWvyqcMJDLqbchh7U4NRGqwRfFSVbOLS/ePIP4vHB5e8T8eXcuqyN1QkgKyj5wuW0lcS85v4CrSw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", + "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1043,12 +995,12 @@ } }, "node_modules/@babel/plugin-transform-duplicate-keys": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.24.7.tgz", - "integrity": "sha512-JdYfXyCRihAe46jUIliuL2/s0x0wObgwwiGxw/UbgJBr20gQBThrokO4nYKgWkD7uBaqM7+9x5TU7NkExZJyzw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", + "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1058,13 +1010,13 @@ } }, "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.0.tgz", - "integrity": "sha512-YLpb4LlYSc3sCUa35un84poXoraOiQucUTTu8X1j18JV+gNa8E0nyUf/CjZ171IRGr4jEguF+vzJU66QZhn29g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.25.0", - "@babel/helper-plugin-utils": "^7.24.8" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1074,13 +1026,12 @@ } }, "node_modules/@babel/plugin-transform-dynamic-import": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.24.7.tgz", - "integrity": "sha512-sc3X26PhZQDb3JhORmakcbvkeInvxz+A8oda99lj7J60QRuPZvNAk9wQlTBS1ZynelDrDmTU4pw1tyc5d5ZMUg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", + "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-dynamic-import": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1090,13 +1041,13 @@ } }, "node_modules/@babel/plugin-transform-exponentiation-operator": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.24.7.tgz", - "integrity": "sha512-Rqe/vSc9OYgDajNIK35u7ot+KeCoetqQYFXM4Epf7M7ez3lWlOjrDjrwMei6caCVhfdw+mIKD4cgdGNy5JQotQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.25.9.tgz", + "integrity": "sha512-KRhdhlVk2nObA5AYa7QMgTMTVJdfHprfpAk4DjZVtllqRg9qarilstTKEhpVjyt+Npi8ThRyiV8176Am3CodPA==", "license": "MIT", "dependencies": { - "@babel/helper-builder-binary-assignment-operator-visitor": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1106,13 +1057,12 @@ } }, "node_modules/@babel/plugin-transform-export-namespace-from": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.24.7.tgz", - "integrity": "sha512-v0K9uNYsPL3oXZ/7F9NNIbAj2jv1whUEtyA6aujhekLs56R++JDQuzRcP2/z4WX5Vg/c5lE9uWZA0/iUoFhLTA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", + "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-export-namespace-from": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1122,13 +1072,13 @@ } }, "node_modules/@babel/plugin-transform-for-of": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.24.7.tgz", - "integrity": "sha512-wo9ogrDG1ITTTBsy46oGiN1dS9A7MROBTcYsfS8DtsImMkHk9JXJ3EWQM6X2SUw4x80uGPlwj0o00Uoc6nEE3g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", + "integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1138,14 +1088,14 @@ } }, "node_modules/@babel/plugin-transform-function-name": { - "version": "7.25.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.1.tgz", - "integrity": "sha512-TVVJVdW9RKMNgJJlLtHsKDTydjZAbwIsn6ySBPQaEAUU5+gVvlJt/9nRmqVbsV/IBanRjzWoaAQKLoamWVOUuA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", + "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.24.8", - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/traverse": "^7.25.1" + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1155,13 +1105,12 @@ } }, "node_modules/@babel/plugin-transform-json-strings": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.24.7.tgz", - "integrity": "sha512-2yFnBGDvRuxAaE/f0vfBKvtnvvqU8tGpMHqMNpTN2oWMKIR3NqFkjaAgGwawhqK/pIN2T3XdjGPdaG0vDhOBGw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", + "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-json-strings": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1171,12 +1120,12 @@ } }, "node_modules/@babel/plugin-transform-literals": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.2.tgz", - "integrity": "sha512-HQI+HcTbm9ur3Z2DkO+jgESMAMcYLuN/A7NRw9juzxAezN9AvqvUTnpKP/9kkYANz6u7dFlAyOu44ejuGySlfw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", + "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1186,13 +1135,12 @@ } }, "node_modules/@babel/plugin-transform-logical-assignment-operators": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.24.7.tgz", - "integrity": "sha512-4D2tpwlQ1odXmTEIFWy9ELJcZHqrStlzK/dAOWYyxX3zT0iXQB6banjgeOJQXzEc4S0E0a5A+hahxPaEFYftsw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", + "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1202,12 +1150,12 @@ } }, "node_modules/@babel/plugin-transform-member-expression-literals": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.24.7.tgz", - "integrity": "sha512-T/hRC1uqrzXMKLQ6UCwMT85S3EvqaBXDGf0FaMf4446Qx9vKwlghvee0+uuZcDUCZU5RuNi4781UQ7R308zzBw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", + "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1217,13 +1165,13 @@ } }, "node_modules/@babel/plugin-transform-modules-amd": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.24.7.tgz", - "integrity": "sha512-9+pB1qxV3vs/8Hdmz/CulFB8w2tuu6EB94JZFsjdqxQokwGa9Unap7Bo2gGBGIvPmDIVvQrom7r5m/TCDMURhg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", + "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1233,14 +1181,14 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.8.tgz", - "integrity": "sha512-WHsk9H8XxRs3JXKWFiqtQebdh9b/pTk4EgueygFzYlTKAg0Ud985mSevdNjdXdFBATSKVJGQXP1tv6aGbssLKA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.25.9.tgz", + "integrity": "sha512-dwh2Ol1jWwL2MgkCzUSOvfmKElqQcuswAZypBSUsScMXvgdT8Ekq5YA6TtqpTVWH+4903NmboMuH1o9i8Rxlyg==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.24.8", - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/helper-simple-access": "^7.24.7" + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-simple-access": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1250,15 +1198,15 @@ } }, "node_modules/@babel/plugin-transform-modules-systemjs": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.0.tgz", - "integrity": "sha512-YPJfjQPDXxyQWg/0+jHKj1llnY5f/R6a0p/vP4lPymxLu7Lvl4k2WMitqi08yxwQcCVUUdG9LCUj4TNEgAp3Jw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", + "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.25.0", - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/helper-validator-identifier": "^7.24.7", - "@babel/traverse": "^7.25.0" + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1268,13 +1216,13 @@ } }, "node_modules/@babel/plugin-transform-modules-umd": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.24.7.tgz", - "integrity": "sha512-3aytQvqJ/h9z4g8AsKPLvD4Zqi2qT+L3j7XoFFu1XBlZWEl2/1kWnhmAbxpLgPrHSY0M6UA02jyTiwUVtiKR6A==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", + "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", "license": "MIT", "dependencies": { - "@babel/helper-module-transforms": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1284,13 +1232,13 @@ } }, "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.24.7.tgz", - "integrity": "sha512-/jr7h/EWeJtk1U/uz2jlsCioHkZk1JJZVcc8oQsJ1dUlaJD83f4/6Zeh2aHt9BIFokHIsSeDfhUmju0+1GPd6g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1300,12 +1248,12 @@ } }, "node_modules/@babel/plugin-transform-new-target": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.24.7.tgz", - "integrity": "sha512-RNKwfRIXg4Ls/8mMTza5oPF5RkOW8Wy/WgMAp1/F1yZ8mMbtwXW+HDoJiOsagWrAhI5f57Vncrmr9XeT4CVapA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", + "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1315,13 +1263,12 @@ } }, "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.24.7.tgz", - "integrity": "sha512-Ts7xQVk1OEocqzm8rHMXHlxvsfZ0cEF2yomUqpKENHWMF4zKk175Y4q8H5knJes6PgYad50uuRmt3UJuhBw8pQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.25.9.tgz", + "integrity": "sha512-ENfftpLZw5EItALAD4WsY/KUWvhUlZndm5GC7G3evUsVeSJB6p0pBeLQUnRnBCBx7zV0RKQjR9kCuwrsIrjWog==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1331,13 +1278,12 @@ } }, "node_modules/@babel/plugin-transform-numeric-separator": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.24.7.tgz", - "integrity": "sha512-e6q1TiVUzvH9KRvicuxdBTUj4AdKSRwzIyFFnfnezpCfP2/7Qmbb8qbU2j7GODbl4JMkblitCQjKYUaX/qkkwA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", + "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-numeric-separator": "^7.10.4" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1347,12 +1293,12 @@ } }, "node_modules/@babel/plugin-transform-object-assign": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-assign/-/plugin-transform-object-assign-7.24.7.tgz", - "integrity": "sha512-DOzAi77P9jSyPijHS7Z8vH0wLRcZH6wWxuIZgLAiy8FWOkcKMJmnyHjy2JM94k6A0QxlA/hlLh+R9T3GEryjNQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-assign/-/plugin-transform-object-assign-7.25.9.tgz", + "integrity": "sha512-I/Vl1aQnPsrrn837oLbo+VQtkNcjuuiATqwmuweg4fTauwHHQoxyjmjjOVKyO8OaTxgqYTKW3LuQsykXjDf5Ag==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1362,15 +1308,14 @@ } }, "node_modules/@babel/plugin-transform-object-rest-spread": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.24.7.tgz", - "integrity": "sha512-4QrHAr0aXQCEFni2q4DqKLD31n2DL+RxcwnNjDFkSG0eNQ/xCavnRkfCUjsyqGC2OviNJvZOF/mQqZBw7i2C5Q==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", + "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", "license": "MIT", "dependencies": { - "@babel/helper-compilation-targets": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-transform-parameters": "^7.24.7" + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1380,13 +1325,13 @@ } }, "node_modules/@babel/plugin-transform-object-super": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.24.7.tgz", - "integrity": "sha512-A/vVLwN6lBrMFmMDmPPz0jnE6ZGx7Jq7d6sT/Ev4H65RER6pZ+kczlf1DthF5N0qaPHBsI7UXiE8Zy66nmAovg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", + "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/helper-replace-supers": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1396,13 +1341,12 @@ } }, "node_modules/@babel/plugin-transform-optional-catch-binding": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.24.7.tgz", - "integrity": "sha512-uLEndKqP5BfBbC/5jTwPxLh9kqPWWgzN/f8w6UwAIirAEqiIVJWWY312X72Eub09g5KF9+Zn7+hT7sDxmhRuKA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", + "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1412,14 +1356,13 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.8.tgz", - "integrity": "sha512-5cTOLSMs9eypEy8JUVvIKOu6NgvbJMnpG62VpIHrTmROdQ+L5mDAaI40g25k5vXti55JWNX5jCkq3HZxXBQANw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", - "@babel/plugin-syntax-optional-chaining": "^7.8.3" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1429,12 +1372,12 @@ } }, "node_modules/@babel/plugin-transform-parameters": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.24.7.tgz", - "integrity": "sha512-yGWW5Rr+sQOhK0Ot8hjDJuxU3XLRQGflvT4lhlSY0DFvdb3TwKaY26CJzHtYllU0vT9j58hc37ndFPsqT1SrzA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", + "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1444,13 +1387,13 @@ } }, "node_modules/@babel/plugin-transform-private-methods": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.24.7.tgz", - "integrity": "sha512-COTCOkG2hn4JKGEKBADkA8WNb35TGkkRbI5iT845dB+NyqgO8Hn+ajPbSnIQznneJTa3d30scb6iz/DhH8GsJQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz", + "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==", "license": "MIT", "dependencies": { - "@babel/helper-create-class-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1460,15 +1403,14 @@ } }, "node_modules/@babel/plugin-transform-private-property-in-object": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.24.7.tgz", - "integrity": "sha512-9z76mxwnwFxMyxZWEgdgECQglF2Q7cFLm0kMf8pGwt+GSJsY0cONKj/UuO4bOH0w/uAel3ekS4ra5CEAyJRmDA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", + "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-create-class-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1478,12 +1420,12 @@ } }, "node_modules/@babel/plugin-transform-property-literals": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.24.7.tgz", - "integrity": "sha512-EMi4MLQSHfd2nrCqQEWxFdha2gBCqU4ZcCng4WBGZ5CJL4bBRW0ptdqqDdeirGZcpALazVVNJqRmsO8/+oNCBA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", + "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1493,12 +1435,12 @@ } }, "node_modules/@babel/plugin-transform-react-display-name": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.24.7.tgz", - "integrity": "sha512-H/Snz9PFxKsS1JLI4dJLtnJgCJRoo0AUm3chP6NYr+9En1JMKloheEiLIhlp5MDVznWo+H3AAC1Mc8lmUEpsgg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-display-name/-/plugin-transform-react-display-name-7.25.9.tgz", + "integrity": "sha512-KJfMlYIUxQB1CJfO3e0+h0ZHWOTLCPP115Awhaz8U0Zpq36Gl/cXlpoyMRnUWlhNUBAzldnCiAZNvCDj7CrKxQ==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1508,16 +1450,16 @@ } }, "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.2.tgz", - "integrity": "sha512-KQsqEAVBpU82NM/B/N9j9WOdphom1SZH3R+2V7INrQUH+V9EBFwZsEJl8eBIVeQE62FxJCc70jzEZwqU7RcVqA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.25.9.tgz", + "integrity": "sha512-s5XwpQYCqGerXl+Pu6VDL3x0j2d82eiV77UJ8a2mDHAW7j9SWRqQ2y1fNo1Z74CdcYipl5Z41zvjj4Nfzq36rw==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-module-imports": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/plugin-syntax-jsx": "^7.24.7", - "@babel/types": "^7.25.2" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-syntax-jsx": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1527,12 +1469,12 @@ } }, "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.24.7.tgz", - "integrity": "sha512-QG9EnzoGn+Qar7rxuW+ZOsbWOt56FvvI93xInqsZDC5fsekx1AlIO4KIJ5M+D0p0SqSH156EpmZyXq630B8OlQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.25.9.tgz", + "integrity": "sha512-9mj6rm7XVYs4mdLIpbZnHOYdpW42uoiBCTVowg7sP1thUOiANgMb4UtpRivR0pp5iL+ocvUv7X4mZgFRpJEzGw==", "license": "MIT", "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.24.7" + "@babel/plugin-transform-react-jsx": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1542,13 +1484,13 @@ } }, "node_modules/@babel/plugin-transform-react-pure-annotations": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.24.7.tgz", - "integrity": "sha512-PLgBVk3fzbmEjBJ/u8kFzOqS9tUeDjiaWud/rRym/yjCo/M9cASPlnrd2ZmmZpQT40fOOrvR8jh+n8jikrOhNA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-pure-annotations/-/plugin-transform-react-pure-annotations-7.25.9.tgz", + "integrity": "sha512-KQ/Takk3T8Qzj5TppkS1be588lkbTp5uj7w6a0LeQaTMSckU/wK0oJ/pih+T690tkgI5jfmg2TqDJvd41Sj1Cg==", "license": "MIT", "dependencies": { - "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1558,12 +1500,12 @@ } }, "node_modules/@babel/plugin-transform-regenerator": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.24.7.tgz", - "integrity": "sha512-lq3fvXPdimDrlg6LWBoqj+r/DEWgONuwjuOuQCSYgRroXDH/IdM1C0IZf59fL5cHLpjEH/O6opIRBbqv7ELnuA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", + "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-plugin-utils": "^7.25.9", "regenerator-transform": "^0.15.2" }, "engines": { @@ -1573,13 +1515,29 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-reserved-words": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.24.7.tgz", - "integrity": "sha512-0DUq0pHcPKbjFZCfTss/pGkYMfy3vFWydkUBd9r0GHpIyfs2eCDENvqadMycRS9wZCXR41wucAfJHJmwA0UmoQ==", + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", + "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", + "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1589,12 +1547,12 @@ } }, "node_modules/@babel/plugin-transform-shorthand-properties": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.24.7.tgz", - "integrity": "sha512-KsDsevZMDsigzbA09+vacnLpmPH4aWjcZjXdyFKGzpplxhbeB4wYtury3vglQkg6KM/xEPKt73eCjPPf1PgXBA==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", + "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1604,13 +1562,13 @@ } }, "node_modules/@babel/plugin-transform-spread": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.24.7.tgz", - "integrity": "sha512-x96oO0I09dgMDxJaANcRyD4ellXFLLiWhuwDxKZX5g2rWP1bTPkBSwCYv96VDXVT1bD9aPj8tppr5ITIh8hBng==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", + "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1620,12 +1578,12 @@ } }, "node_modules/@babel/plugin-transform-sticky-regex": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.24.7.tgz", - "integrity": "sha512-kHPSIJc9v24zEml5geKg9Mjx5ULpfncj0wRpYtxbvKyTtHCYDkVE3aHQ03FrpEo4gEe2vrJJS1Y9CJTaThA52g==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", + "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1635,12 +1593,12 @@ } }, "node_modules/@babel/plugin-transform-template-literals": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.24.7.tgz", - "integrity": "sha512-AfDTQmClklHCOLxtGoP7HkeMw56k1/bTQjwsfhL6pppo/M4TOBSq+jjBUBLmV/4oeFg4GWMavIl44ZeCtmmZTw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.9.tgz", + "integrity": "sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1650,12 +1608,12 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.24.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.8.tgz", - "integrity": "sha512-adNTUpDCVnmAE58VEqKlAA6ZBlNkMnWD0ZcW76lyNFN3MJniyGFZfNwERVk8Ap56MCnXztmDr19T4mPTztcuaw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.9.tgz", + "integrity": "sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.8" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1665,12 +1623,12 @@ } }, "node_modules/@babel/plugin-transform-unicode-escapes": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.24.7.tgz", - "integrity": "sha512-U3ap1gm5+4edc2Q/P+9VrBNhGkfnf+8ZqppY71Bo/pzZmXhhLdqgaUl6cuB07O1+AQJtCLfaOmswiNbSQ9ivhw==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", + "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1680,13 +1638,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-property-regex": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.24.7.tgz", - "integrity": "sha512-uH2O4OV5M9FZYQrwc7NdVmMxQJOCCzFeYudlZSzUAHRFeOujQefa92E74TQDVskNHCzOXoigEuoyzHDhaEaK5w==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", + "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1696,13 +1654,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-regex": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.24.7.tgz", - "integrity": "sha512-hlQ96MBZSAXUq7ltkjtu3FJCCSMx/j629ns3hA3pXnBXjanNP0LHi+JpPeA81zaWgVK1VGH95Xuy7u0RyQ8kMg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", + "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1712,13 +1670,13 @@ } }, "node_modules/@babel/plugin-transform-unicode-sets-regex": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.24.7.tgz", - "integrity": "sha512-2G8aAvF4wy1w/AGZkemprdGMRg5o6zPNhbHVImRz3lss55TYCBd6xStN19rt8XJHq20sqV0JbyWjOWwQRwV/wg==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", + "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", "license": "MIT", "dependencies": { - "@babel/helper-create-regexp-features-plugin": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1738,93 +1696,79 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.25.3", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.25.3.tgz", - "integrity": "sha512-QsYW7UeAaXvLPX9tdVliMJE7MD7M6MLYVTovRTIwhoYQVFHR1rM4wO8wqAezYi3/BpSD+NzVCZ69R6smWiIi8g==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.0.tgz", + "integrity": "sha512-H84Fxq0CQJNdPFT2DrfnylZ3cf5K43rGfWK4LJGPpjKHiZlk0/RzwEus3PDDZZg+/Er7lCA03MVacueUuXdzfw==", "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.25.2", - "@babel/helper-compilation-targets": "^7.25.2", - "@babel/helper-plugin-utils": "^7.24.8", - "@babel/helper-validator-option": "^7.24.8", - "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.3", - "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.0", - "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.0", - "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.24.7", - "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.0", + "@babel/compat-data": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", - "@babel/plugin-syntax-async-generators": "^7.8.4", - "@babel/plugin-syntax-class-properties": "^7.12.13", - "@babel/plugin-syntax-class-static-block": "^7.14.5", - "@babel/plugin-syntax-dynamic-import": "^7.8.3", - "@babel/plugin-syntax-export-namespace-from": "^7.8.3", - "@babel/plugin-syntax-import-assertions": "^7.24.7", - "@babel/plugin-syntax-import-attributes": "^7.24.7", - "@babel/plugin-syntax-import-meta": "^7.10.4", - "@babel/plugin-syntax-json-strings": "^7.8.3", - "@babel/plugin-syntax-logical-assignment-operators": "^7.10.4", - "@babel/plugin-syntax-nullish-coalescing-operator": "^7.8.3", - "@babel/plugin-syntax-numeric-separator": "^7.10.4", - "@babel/plugin-syntax-object-rest-spread": "^7.8.3", - "@babel/plugin-syntax-optional-catch-binding": "^7.8.3", - "@babel/plugin-syntax-optional-chaining": "^7.8.3", - "@babel/plugin-syntax-private-property-in-object": "^7.14.5", - "@babel/plugin-syntax-top-level-await": "^7.14.5", + "@babel/plugin-syntax-import-assertions": "^7.26.0", + "@babel/plugin-syntax-import-attributes": "^7.26.0", "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", - "@babel/plugin-transform-arrow-functions": "^7.24.7", - "@babel/plugin-transform-async-generator-functions": "^7.25.0", - "@babel/plugin-transform-async-to-generator": "^7.24.7", - "@babel/plugin-transform-block-scoped-functions": "^7.24.7", - "@babel/plugin-transform-block-scoping": "^7.25.0", - "@babel/plugin-transform-class-properties": "^7.24.7", - "@babel/plugin-transform-class-static-block": "^7.24.7", - "@babel/plugin-transform-classes": "^7.25.0", - "@babel/plugin-transform-computed-properties": "^7.24.7", - "@babel/plugin-transform-destructuring": "^7.24.8", - "@babel/plugin-transform-dotall-regex": "^7.24.7", - "@babel/plugin-transform-duplicate-keys": "^7.24.7", - "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.0", - "@babel/plugin-transform-dynamic-import": "^7.24.7", - "@babel/plugin-transform-exponentiation-operator": "^7.24.7", - "@babel/plugin-transform-export-namespace-from": "^7.24.7", - "@babel/plugin-transform-for-of": "^7.24.7", - "@babel/plugin-transform-function-name": "^7.25.1", - "@babel/plugin-transform-json-strings": "^7.24.7", - "@babel/plugin-transform-literals": "^7.25.2", - "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", - "@babel/plugin-transform-member-expression-literals": "^7.24.7", - "@babel/plugin-transform-modules-amd": "^7.24.7", - "@babel/plugin-transform-modules-commonjs": "^7.24.8", - "@babel/plugin-transform-modules-systemjs": "^7.25.0", - "@babel/plugin-transform-modules-umd": "^7.24.7", - "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", - "@babel/plugin-transform-new-target": "^7.24.7", - "@babel/plugin-transform-nullish-coalescing-operator": "^7.24.7", - "@babel/plugin-transform-numeric-separator": "^7.24.7", - "@babel/plugin-transform-object-rest-spread": "^7.24.7", - "@babel/plugin-transform-object-super": "^7.24.7", - "@babel/plugin-transform-optional-catch-binding": "^7.24.7", - "@babel/plugin-transform-optional-chaining": "^7.24.8", - "@babel/plugin-transform-parameters": "^7.24.7", - "@babel/plugin-transform-private-methods": "^7.24.7", - "@babel/plugin-transform-private-property-in-object": "^7.24.7", - "@babel/plugin-transform-property-literals": "^7.24.7", - "@babel/plugin-transform-regenerator": "^7.24.7", - "@babel/plugin-transform-reserved-words": "^7.24.7", - "@babel/plugin-transform-shorthand-properties": "^7.24.7", - "@babel/plugin-transform-spread": "^7.24.7", - "@babel/plugin-transform-sticky-regex": "^7.24.7", - "@babel/plugin-transform-template-literals": "^7.24.7", - "@babel/plugin-transform-typeof-symbol": "^7.24.8", - "@babel/plugin-transform-unicode-escapes": "^7.24.7", - "@babel/plugin-transform-unicode-property-regex": "^7.24.7", - "@babel/plugin-transform-unicode-regex": "^7.24.7", - "@babel/plugin-transform-unicode-sets-regex": "^7.24.7", + "@babel/plugin-transform-arrow-functions": "^7.25.9", + "@babel/plugin-transform-async-generator-functions": "^7.25.9", + "@babel/plugin-transform-async-to-generator": "^7.25.9", + "@babel/plugin-transform-block-scoped-functions": "^7.25.9", + "@babel/plugin-transform-block-scoping": "^7.25.9", + "@babel/plugin-transform-class-properties": "^7.25.9", + "@babel/plugin-transform-class-static-block": "^7.26.0", + "@babel/plugin-transform-classes": "^7.25.9", + "@babel/plugin-transform-computed-properties": "^7.25.9", + "@babel/plugin-transform-destructuring": "^7.25.9", + "@babel/plugin-transform-dotall-regex": "^7.25.9", + "@babel/plugin-transform-duplicate-keys": "^7.25.9", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-dynamic-import": "^7.25.9", + "@babel/plugin-transform-exponentiation-operator": "^7.25.9", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-for-of": "^7.25.9", + "@babel/plugin-transform-function-name": "^7.25.9", + "@babel/plugin-transform-json-strings": "^7.25.9", + "@babel/plugin-transform-literals": "^7.25.9", + "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", + "@babel/plugin-transform-member-expression-literals": "^7.25.9", + "@babel/plugin-transform-modules-amd": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.25.9", + "@babel/plugin-transform-modules-systemjs": "^7.25.9", + "@babel/plugin-transform-modules-umd": "^7.25.9", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-new-target": "^7.25.9", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.25.9", + "@babel/plugin-transform-numeric-separator": "^7.25.9", + "@babel/plugin-transform-object-rest-spread": "^7.25.9", + "@babel/plugin-transform-object-super": "^7.25.9", + "@babel/plugin-transform-optional-catch-binding": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9", + "@babel/plugin-transform-private-methods": "^7.25.9", + "@babel/plugin-transform-private-property-in-object": "^7.25.9", + "@babel/plugin-transform-property-literals": "^7.25.9", + "@babel/plugin-transform-regenerator": "^7.25.9", + "@babel/plugin-transform-regexp-modifiers": "^7.26.0", + "@babel/plugin-transform-reserved-words": "^7.25.9", + "@babel/plugin-transform-shorthand-properties": "^7.25.9", + "@babel/plugin-transform-spread": "^7.25.9", + "@babel/plugin-transform-sticky-regex": "^7.25.9", + "@babel/plugin-transform-template-literals": "^7.25.9", + "@babel/plugin-transform-typeof-symbol": "^7.25.9", + "@babel/plugin-transform-unicode-escapes": "^7.25.9", + "@babel/plugin-transform-unicode-property-regex": "^7.25.9", + "@babel/plugin-transform-unicode-regex": "^7.25.9", + "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", "@babel/preset-modules": "0.1.6-no-external-plugins", "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.10.4", + "babel-plugin-polyfill-corejs3": "^0.10.6", "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.37.1", + "core-js-compat": "^3.38.1", "semver": "^6.3.1" }, "engines": { @@ -1848,17 +1792,17 @@ } }, "node_modules/@babel/preset-react": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.24.7.tgz", - "integrity": "sha512-AAH4lEkpmzFWrGVlHaxJB7RLH21uPQ9+He+eFLWHmF9IuFQVugz8eAsamaW0DXRrTfco5zj1wWtpdcXJUOfsag==", + "version": "7.26.3", + "resolved": "https://registry.npmjs.org/@babel/preset-react/-/preset-react-7.26.3.tgz", + "integrity": "sha512-Nl03d6T9ky516DGK2YMxrTqvnpUW63TnJMOMonj+Zae0JiPC5BC9xPMSL6L8fiSpA5vP88qfygavVQvnLp+6Cw==", "license": "MIT", "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/helper-validator-option": "^7.24.7", - "@babel/plugin-transform-react-display-name": "^7.24.7", - "@babel/plugin-transform-react-jsx": "^7.24.7", - "@babel/plugin-transform-react-jsx-development": "^7.24.7", - "@babel/plugin-transform-react-pure-annotations": "^7.24.7" + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-transform-react-display-name": "^7.25.9", + "@babel/plugin-transform-react-jsx": "^7.25.9", + "@babel/plugin-transform-react-jsx-development": "^7.25.9", + "@babel/plugin-transform-react-pure-annotations": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1867,11 +1811,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/regjsgen": { - "version": "0.8.0", - "resolved": "https://registry.npmjs.org/@babel/regjsgen/-/regjsgen-0.8.0.tgz", - "integrity": "sha512-x/rqGMdzj+fWZvCOYForTghzbtqPDZ5gPwaoNGHdgDfF2QA/XZbCBp4Moo5scrkAMPhB7z26XM/AaHuIJdgauA==" - }, "node_modules/@babel/runtime": { "version": "7.24.1", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.24.1.tgz", @@ -1889,30 +1828,30 @@ "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==" }, "node_modules/@babel/template": { - "version": "7.25.0", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", - "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", + "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/parser": "^7.25.0", - "@babel/types": "^7.25.0" + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.25.3", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.3.tgz", - "integrity": "sha512-HefgyP1x754oGCsKmV5reSmtV7IXj/kpaE1XYY+D9G5PvKKoFfSbiS4M77MdjuwlZKDIKFCffq9rPU+H/s3ZdQ==", + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz", + "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==", "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.25.0", - "@babel/parser": "^7.25.3", - "@babel/template": "^7.25.0", - "@babel/types": "^7.25.2", + "@babel/code-frame": "^7.25.9", + "@babel/generator": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/template": "^7.25.9", + "@babel/types": "^7.25.9", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -1921,14 +1860,13 @@ } }, "node_modules/@babel/types": { - "version": "7.25.2", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz", - "integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==", + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz", + "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==", "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.24.8", - "@babel/helper-validator-identifier": "^7.24.7", - "to-fast-properties": "^2.0.0" + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" }, "engines": { "node": ">=6.9.0" @@ -1945,35 +1883,8 @@ "version": "0.2.3", "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-0.2.3.tgz", "integrity": "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==", - "dev": true - }, - "node_modules/@choojs/findup": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@choojs/findup/-/findup-0.2.1.tgz", - "integrity": "sha512-YstAqNb0MCN8PjdLCDfRsBcGVRN41f3vgLvaI0IrIcBp4AqILRSS0DeWNGkicC+f/zRIPJLc+9RURVSepwvfBw==", - "license": "MIT", - "dependencies": { - "commander": "^2.15.1" - }, - "bin": { - "findup": "bin/findup.js" - } - }, - "node_modules/@cnakazawa/watch": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@cnakazawa/watch/-/watch-1.0.4.tgz", - "integrity": "sha512-v9kIhKwjeZThiWrLmj0y17CWoyddASLj9O2yvbZkbvw/N3rWOYy9zkV66ursAoVr0mV15bL8g0c4QZUE6cdDoQ==", "dev": true, - "dependencies": { - "exec-sh": "^0.3.2", - "minimist": "^1.2.0" - }, - "bin": { - "watch": "cli.js" - }, - "engines": { - "node": ">=0.1.95" - } + "license": "MIT" }, "node_modules/@csstools/css-parser-algorithms": { "version": "2.6.3", @@ -2104,29 +2015,11 @@ "integrity": "sha512-m4avr8yL8kmFN8psrbFFFmB/If14iN5o9nw/NgnnM+kybDJpRsAynV2BsfpTYrTRysYUdADVD7CkUUizgkpLfg==" }, "node_modules/@edx/edx-proctoring": { - "version": "4.18.3", - "resolved": "https://registry.npmjs.org/@edx/edx-proctoring/-/edx-proctoring-4.18.3.tgz", - "integrity": "sha512-JeDT12uJYRumD7zIkjfemO40ZFkIhFqS5R6fGDO5MICRuoVJ/ndzcwTOhobcyGU/f8eMxlM1ukzWbSKBS+bUWg==", + "version": "4.18.4", + "resolved": "https://registry.npmjs.org/@edx/edx-proctoring/-/edx-proctoring-4.18.4.tgz", + "integrity": "sha512-S5YCZEoRaWlvNVr99icpMKehDf76holwKRlfMA8Unsktb53Nc8FslI4bFg29yNfLcyW7v8yAW4YaAFAuSBtarA==", "license": "GNU Affero GPLv3" }, - "node_modules/@edx/eslint-config": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/@edx/eslint-config/-/eslint-config-4.3.0.tgz", - "integrity": "sha512-4W9wFG4ALr3xocakCsncgJbK67RHfSmDwHDXKHReFtjxl/FRkxhS6qayz189oChqfANieeV3zRCLaq44bLf+/A==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^5.62.0", - "@typescript-eslint/parser": "^5.62.0", - "eslint": "^7.32.0 || ^8.2.0", - "eslint-config-airbnb": "^18.0.1 || ^19.0.0", - "eslint-config-airbnb-typescript": "^17.0.0", - "eslint-plugin-import": "^2.25.3", - "eslint-plugin-jsx-a11y": "^6.2.3", - "eslint-plugin-react": "^7.18.0", - "eslint-plugin-react-hooks": "^1.7.0 || ^4.0.0" - } - }, "node_modules/@edx/frontend-component-cookie-policy-banner": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@edx/frontend-component-cookie-policy-banner/-/frontend-component-cookie-policy-banner-2.2.0.tgz", @@ -2216,16 +2109,6 @@ "email-validator": "^2.0.4" } }, - "node_modules/@edx/frontend-component-cookie-policy-banner/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/@edx/frontend-component-cookie-policy-banner/node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -2325,16 +2208,6 @@ "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-2.2.1.tgz", "integrity": "sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==" }, - "node_modules/@edx/studio-frontend/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/@edx/studio-frontend/node_modules/react-responsive": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/react-responsive/-/react-responsive-5.0.0.tgz", @@ -3067,115 +2940,6 @@ "dev": true, "license": "ISC" }, - "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", - "integrity": "sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==", - "dev": true, - "peer": true, - "dependencies": { - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" - } - }, - "node_modules/@eslint-community/regexpp": { - "version": "4.10.0", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.10.0.tgz", - "integrity": "sha512-Cu96Sd2By9mCNTx2iyKOmq10v22jUVQv0lQnlGNy16oE9589yE+QADPbrMGCkA51cKZSg3Pu/aTJVTGfL/qjUA==", - "dev": true, - "peer": true, - "engines": { - "node": "^12.0.0 || ^14.0.0 || >=16.0.0" - } - }, - "node_modules/@eslint/eslintrc": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz", - "integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==", - "dev": true, - "peer": true, - "dependencies": { - "ajv": "^6.12.4", - "debug": "^4.3.2", - "espree": "^9.6.0", - "globals": "^13.19.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.1.0", - "minimatch": "^3.1.2", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "peer": true - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "peer": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/eslintrc/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "peer": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/@eslint/eslintrc/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/@eslint/js": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.57.0.tgz", - "integrity": "sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==", - "dev": true, - "peer": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - } - }, "node_modules/@fortawesome/fontawesome-common-types": { "version": "0.2.36", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-0.2.36.tgz", @@ -3221,52 +2985,6 @@ "react": ">=16.x" } }, - "node_modules/@fortawesome/react-fontawesome/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/@humanwhocodes/config-array": { - "version": "0.11.14", - "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.11.14.tgz", - "integrity": "sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==", - "dev": true, - "peer": true, - "dependencies": { - "@humanwhocodes/object-schema": "^2.0.2", - "debug": "^4.3.1", - "minimatch": "^3.0.5" - }, - "engines": { - "node": ">=10.10.0" - } - }, - "node_modules/@humanwhocodes/module-importer": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", - "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=12.22" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, - "node_modules/@humanwhocodes/object-schema": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-2.0.2.tgz", - "integrity": "sha512-6EwiSjwWYP7pTckG6I5eyFANjPhmPjUX9JRLUSfNPC7FX7zK9gyZAfUEaECL6ALTpGX5AjnBq3C9XmVWPitNpw==", - "dev": true, - "peer": true - }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -3351,6 +3069,18 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/@isaacs/fs-minipass": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", + "license": "ISC", + "dependencies": { + "minipass": "^7.0.4" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -3377,20 +3107,21 @@ } }, "node_modules/@jest/console": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/console/-/console-26.6.2.tgz", - "integrity": "sha512-IY1R2i2aLsLr7Id3S6p2BA82GNWryt4oSvEXLAKc+L2zdi89dSkE8xC1C+0kpATG4JhBJREnQOH7/zmccM2B0g==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/console/-/console-29.7.0.tgz", + "integrity": "sha512-5Ni4CU7XHQi32IJ398EEP4RrB8eV09sXP2ROqD4bksHrnTree52PsxvX8tpL8LvTZ3pFzXyPbNQReSN41CAhOg==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^26.6.2", + "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", - "jest-message-util": "^26.6.2", - "jest-util": "^26.6.2", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", "slash": "^3.0.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/console/node_modules/ansi-styles": { @@ -3398,6 +3129,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -3413,6 +3145,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -3429,6 +3162,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -3440,13 +3174,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@jest/console/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3456,6 +3192,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -3464,42 +3201,51 @@ } }, "node_modules/@jest/core": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/@jest/core/-/core-26.6.3.tgz", - "integrity": "sha512-xvV1kKbhfUqFVuZ8Cyo+JPpipAHHAV3kcDBftiduK8EICXmTFddryy3P7NfZt8Pv37rA9nEJBKCCkglCPt/Xjw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/core/-/core-29.7.0.tgz", + "integrity": "sha512-n7aeXWKMnGtDA48y8TLWJPJmLmmZ642Ceo78cYWEpiD7FzDgmNDV/GCVRorPABdXLJZ/9wzzgZAlHjXjxDHGsg==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/console": "^26.6.2", - "@jest/reporters": "^26.6.2", - "@jest/test-result": "^26.6.2", - "@jest/transform": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/console": "^29.7.0", + "@jest/reporters": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", "@types/node": "*", "ansi-escapes": "^4.2.1", "chalk": "^4.0.0", + "ci-info": "^3.2.0", "exit": "^0.1.2", - "graceful-fs": "^4.2.4", - "jest-changed-files": "^26.6.2", - "jest-config": "^26.6.3", - "jest-haste-map": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-regex-util": "^26.0.0", - "jest-resolve": "^26.6.2", - "jest-resolve-dependencies": "^26.6.3", - "jest-runner": "^26.6.3", - "jest-runtime": "^26.6.3", - "jest-snapshot": "^26.6.2", - "jest-util": "^26.6.2", - "jest-validate": "^26.6.2", - "jest-watcher": "^26.6.2", - "micromatch": "^4.0.2", - "p-each-series": "^2.1.0", - "rimraf": "^3.0.0", + "graceful-fs": "^4.2.9", + "jest-changed-files": "^29.7.0", + "jest-config": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-resolve-dependencies": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "jest-watcher": "^29.7.0", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", "slash": "^3.0.0", "strip-ansi": "^6.0.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, "node_modules/@jest/core/node_modules/ansi-styles": { @@ -3507,6 +3253,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -3522,6 +3269,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -3533,11 +3281,28 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/@jest/core/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/@jest/core/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -3549,13 +3314,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@jest/core/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3565,6 +3332,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -3573,85 +3341,122 @@ } }, "node_modules/@jest/environment": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-26.6.2.tgz", - "integrity": "sha512-nFy+fHl28zUrRsCeMB61VDThV1pVTtlEokBRgqPrcT1JNq4yRNIyTHfyht6PqtUvY9IsuLGTrbG8kPXjSZIZwA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/environment/-/environment-29.7.0.tgz", + "integrity": "sha512-aQIfHDq33ExsN4jP1NWGXhxgQ/wixs60gDiKO+XVMd8Mn0NWPWgc34ZQDTb2jKaUWQ7MuwoitXAsN2XVXNMpAw==", + "license": "MIT", "dependencies": { - "@jest/fake-timers": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", "@types/node": "*", - "jest-mock": "^26.6.2" + "jest-mock": "^29.7.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-8uMeAMycttpva3P1lBHB8VciS9V0XAr3GymPpipdyQXbBcuhkLQOSe8E/p92RyAdToS6ZD1tFkX+CkhoECE0dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "expect": "^29.7.0", + "jest-snapshot": "^29.7.0" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/expect-utils": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/expect-utils/-/expect-utils-29.7.0.tgz", + "integrity": "sha512-GlsNBWiFQFCVi9QVSx7f5AgMeLxe9YCCs5PuP2O2LdjDAA8Jh9eX7lA1Jq/xdXw3Wb3hyvlFNfZIfcRetSzYcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "jest-get-type": "^29.6.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/fake-timers": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-26.6.2.tgz", - "integrity": "sha512-14Uleatt7jdzefLPYM3KLcnUl1ZNikaKq34enpb5XG9i81JpppDb5muZvonvKyrl7ftEHkKS5L5/eB/kxJ+bvA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/fake-timers/-/fake-timers-29.7.0.tgz", + "integrity": "sha512-q4DH1Ha4TTFPdxLsqDXK1d3+ioSL7yL5oCMJZgDYm6i+6CygW5E5xVr/D1HdsGxjt1ZWSfUAs9OxSB/BNelWrQ==", + "license": "MIT", "dependencies": { - "@jest/types": "^26.6.2", - "@sinonjs/fake-timers": "^6.0.1", + "@jest/types": "^29.6.3", + "@sinonjs/fake-timers": "^10.0.2", "@types/node": "*", - "jest-message-util": "^26.6.2", - "jest-mock": "^26.6.2", - "jest-util": "^26.6.2" + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/globals": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-26.6.2.tgz", - "integrity": "sha512-85Ltnm7HlB/KesBUuALwQ68YTU72w9H2xW9FjZ1eL1U3lhtefjjl5c2MiUbpXt/i6LaPRvoOFJ22yCBSfQ0JIA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/globals/-/globals-29.7.0.tgz", + "integrity": "sha512-mpiz3dutLbkW2MNFubUGUEVLkTGiqW6yLVTA+JbP6fI6J5iL9Y0Nlg8k95pcF8ctKwCS7WVxteBs29hhfAotzQ==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/environment": "^26.6.2", - "@jest/types": "^26.6.2", - "expect": "^26.6.2" + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/types": "^29.6.3", + "jest-mock": "^29.7.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/reporters": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-26.6.2.tgz", - "integrity": "sha512-h2bW53APG4HvkOnVMo8q3QXa6pcaNt1HkwVsOPMBV6LD/q9oSpxNSYZQYkAnjdMjrJ86UuYeLo+aEZClV6opnw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/reporters/-/reporters-29.7.0.tgz", + "integrity": "sha512-DApq0KJbJOEzAFYjHADNNxAE3KbhxQB1y5Kplb5Waqw6zVbuWatSnMjE5gs8FUgEPmNsnZA3NCWl9NG0ia04Pg==", "dev": true, + "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^0.2.3", - "@jest/console": "^26.6.2", - "@jest/test-result": "^26.6.2", - "@jest/transform": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/console": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "@types/node": "*", "chalk": "^4.0.0", "collect-v8-coverage": "^1.0.0", "exit": "^0.1.2", - "glob": "^7.1.2", - "graceful-fs": "^4.2.4", + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", "istanbul-lib-coverage": "^3.0.0", - "istanbul-lib-instrument": "^4.0.3", + "istanbul-lib-instrument": "^6.0.0", "istanbul-lib-report": "^3.0.0", "istanbul-lib-source-maps": "^4.0.0", - "istanbul-reports": "^3.0.2", - "jest-haste-map": "^26.6.2", - "jest-resolve": "^26.6.2", - "jest-util": "^26.6.2", - "jest-worker": "^26.6.2", + "istanbul-reports": "^3.1.3", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", "slash": "^3.0.0", - "source-map": "^0.6.0", "string-length": "^4.0.1", - "terminal-link": "^2.0.0", - "v8-to-istanbul": "^7.0.0" + "strip-ansi": "^6.0.0", + "v8-to-istanbul": "^9.0.1" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, - "optionalDependencies": { - "node-notifier": "^8.0.0" + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, "node_modules/@jest/reporters/node_modules/ansi-styles": { @@ -3659,6 +3464,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -3674,6 +3480,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -3690,6 +3497,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -3701,30 +3509,47 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/@jest/reporters/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/@jest/reporters/node_modules/istanbul-lib-instrument": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-4.0.3.tgz", - "integrity": "sha512-BXgQl9kf4WTCPCCpmFGoJkz/+uhvm7h7PFKUYxh7qarQd3ER33vHG//qaE8eN25l07YqZPpHXU9I09l/RD5aGQ==", + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/istanbul-lib-instrument/-/istanbul-lib-instrument-6.0.3.tgz", + "integrity": "sha512-Vtgk7L/R2JHyyGW07spoFlB8/lpjiOLTjMdms6AFMraYt3BaJauod/NGrfnVG/y4Ix1JEuMRPDPEj2ua+zz1/Q==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "@babel/core": "^7.7.5", - "@istanbuljs/schema": "^0.1.2", - "istanbul-lib-coverage": "^3.0.0", - "semver": "^6.3.0" + "@babel/core": "^7.23.9", + "@babel/parser": "^7.23.9", + "@istanbuljs/schema": "^0.1.3", + "istanbul-lib-coverage": "^3.2.0", + "semver": "^7.5.4" }, "engines": { - "node": ">=8" + "node": ">=10" + } + }, + "node_modules/@jest/reporters/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/@jest/reporters/node_modules/supports-color": { @@ -3732,6 +3557,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -3739,75 +3565,90 @@ "node": ">=8" } }, - "node_modules/@jest/source-map": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-26.6.2.tgz", - "integrity": "sha512-YwYcCwAnNmOVsZ8mr3GfnzdXDAl4LaenZP5z+G0c8bzC9/dugL8zRmxZzdoTl4IaS3CryS1uWnROLPFmb6lVvA==", - "dev": true, + "node_modules/@jest/schemas": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-29.6.3.tgz", + "integrity": "sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==", + "license": "MIT", "dependencies": { - "callsites": "^3.0.0", - "graceful-fs": "^4.2.4", - "source-map": "^0.6.0" + "@sinclair/typebox": "^0.27.8" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/@jest/source-map": { + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.6.3.tgz", + "integrity": "sha512-MHjT95QuipcPrpLM+8JMSzFx6eHp5Bm+4XeFDJlwsvVBjmKNiIAvasGK2fxz2WbGRlnvqehFbh07MMa7n3YJnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.18", + "callsites": "^3.0.0", + "graceful-fs": "^4.2.9" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/test-result": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-26.6.2.tgz", - "integrity": "sha512-5O7H5c/7YlojphYNrK02LlDIV2GNPYisKwHm2QTKjNZeEzezCbwYs9swJySv2UfPMyZ0VdsmMv7jIlD/IKYQpQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-result/-/test-result-29.7.0.tgz", + "integrity": "sha512-Fdx+tv6x1zlkJPcWXmMDAG2HBnaR9XPSd5aDWQVsfrZmLVT3lU1cwyxLgRmXR9yrq4NBoEm9BMsfgFzTQAbJYA==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/console": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/console": "^29.7.0", + "@jest/types": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "collect-v8-coverage": "^1.0.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/test-sequencer": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-26.6.3.tgz", - "integrity": "sha512-YHlVIjP5nfEyjlrSr8t/YdNfU/1XEt7c5b4OxcXCjyRhjzLYu/rO69/WHPuYcbCWkz8kAeZVZp2N2+IOLLEPGw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/test-sequencer/-/test-sequencer-29.7.0.tgz", + "integrity": "sha512-GQwJ5WZVrKnOJuiYiAF52UNUJXgTZx1NHjFSEB0qEMmSZKAkdMoIzw/Cj6x6NF4AvV23AUqDpFzQkN/eYCYTxw==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/test-result": "^26.6.2", - "graceful-fs": "^4.2.4", - "jest-haste-map": "^26.6.2", - "jest-runner": "^26.6.3", - "jest-runtime": "^26.6.3" + "@jest/test-result": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "slash": "^3.0.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/transform": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-26.6.2.tgz", - "integrity": "sha512-E9JjhUgNzvuQ+vVAL21vlyfy12gP0GhazGgJC4h6qUt1jSdUXGWJ1wfu/X7Sd8etSgxV4ovT1pb9v5D6QW4XgA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/@jest/transform/-/transform-29.7.0.tgz", + "integrity": "sha512-ok/BTPFzFKVMwO5eOHRrvnBVHdRy9IrsrW1GpMaQ9MCnilNLXQKmAX8s1YXDFaai9xJpac2ySzV0YeRRECr2Vw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/core": "^7.1.0", - "@jest/types": "^26.6.2", - "babel-plugin-istanbul": "^6.0.0", + "@babel/core": "^7.11.6", + "@jest/types": "^29.6.3", + "@jridgewell/trace-mapping": "^0.3.18", + "babel-plugin-istanbul": "^6.1.1", "chalk": "^4.0.0", - "convert-source-map": "^1.4.0", - "fast-json-stable-stringify": "^2.0.0", - "graceful-fs": "^4.2.4", - "jest-haste-map": "^26.6.2", - "jest-regex-util": "^26.0.0", - "jest-util": "^26.6.2", - "micromatch": "^4.0.2", - "pirates": "^4.0.1", + "convert-source-map": "^2.0.0", + "fast-json-stable-stringify": "^2.1.0", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "micromatch": "^4.0.4", + "pirates": "^4.0.4", "slash": "^3.0.0", - "source-map": "^0.6.1", - "write-file-atomic": "^3.0.0" + "write-file-atomic": "^4.0.2" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/transform/node_modules/ansi-styles": { @@ -3815,6 +3656,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -3830,6 +3672,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -3846,6 +3689,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -3857,13 +3701,22 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" + }, + "node_modules/@jest/transform/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" }, "node_modules/@jest/transform/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -3873,6 +3726,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -3881,24 +3735,27 @@ } }, "node_modules/@jest/types": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/@jest/types/-/types-26.6.2.tgz", - "integrity": "sha512-fC6QCp7Sc5sX6g8Tvbmj4XUTbyrik0akgRy03yjXbQaBWWNWGE7SGtJk98m0N8nzegD/7SggrUlivxo5ax4KWQ==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/@jest/types/-/types-29.6.3.tgz", + "integrity": "sha512-u3UPsIilWKOM3F9CXtrG8LEJmNxwoCQC/XVj4IKYXvvpx7QIi/Kg1LI5uDmDpKlac62NUtX7eLjRh+jVZcLOzw==", + "license": "MIT", "dependencies": { + "@jest/schemas": "^29.6.3", "@types/istanbul-lib-coverage": "^2.0.0", "@types/istanbul-reports": "^3.0.0", "@types/node": "*", - "@types/yargs": "^15.0.0", + "@types/yargs": "^17.0.8", "chalk": "^4.0.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/@jest/types/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -3913,6 +3770,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -3928,6 +3786,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -3938,12 +3797,14 @@ "node_modules/@jest/types/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" }, "node_modules/@jest/types/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", "engines": { "node": ">=8" } @@ -3952,6 +3813,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -4047,27 +3909,26 @@ } }, "node_modules/@npmcli/agent": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-2.2.1.tgz", - "integrity": "sha512-H4FrOVtNyWC8MUwL3UfjOsAihHvT1Pe8POj3JvjXhSTJipsZMtgUALCT4mGyYZNxymkUfOw3PUj6dE4QPp6osQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-3.0.0.tgz", + "integrity": "sha512-S79NdEgDQd/NGCay6TCoVzXSj74skRZIKJcpJjC5lOq34SZzyI6MqtiiWoiVWoVrTcGjNeC4ipbh1VIHlpfF5Q==", + "license": "ISC", "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", "lru-cache": "^10.0.1", - "socks-proxy-agent": "^8.0.1" + "socks-proxy-agent": "^8.0.3" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/agent/node_modules/agent-base": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", - "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", - "dependencies": { - "debug": "^4.3.4" - }, + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", "engines": { "node": ">= 14" } @@ -4076,6 +3937,7 @@ "version": "7.0.2", "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" @@ -4085,11 +3947,12 @@ } }, "node_modules/@npmcli/agent/node_modules/https-proxy-agent": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.4.tgz", - "integrity": "sha512-wlwpilI7YdjSkWaQ/7omYBMTliDcmCN8OLihO6I9B86g06lMyAoqgoDpV0XqoaPOKj+0DIdAvnsWfyAAhmimcg==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "4" }, "engines": { @@ -4097,42 +3960,28 @@ } }, "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", - "engines": { - "node": "14 || >=16.14" - } + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" }, "node_modules/@npmcli/fs": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.0.tgz", - "integrity": "sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-4.0.0.tgz", + "integrity": "sha512-/xGlezI6xfGO9NwuJlnwz/K14qD1kCSAGtacBHnGzeAIuJGazcp45KP5NuyARXoKb7cwulAGWVsbeSxdG/cb0Q==", + "license": "ISC", "dependencies": { "semver": "^7.3.5" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/fs/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/@npmcli/fs/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -4140,10 +3989,81 @@ "node": ">=10" } }, - "node_modules/@npmcli/fs/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "node_modules/@parcel/watcher": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz", + "integrity": "sha512-i0GV1yJnm2n3Yq1qw6QrUrd/LI9bE8WEBOTtOkpCXHHdyN3TAGgqAK/DAT05z4fq2x04cARXt2pDmjWjL92iTQ==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.0", + "@parcel/watcher-darwin-arm64": "2.5.0", + "@parcel/watcher-darwin-x64": "2.5.0", + "@parcel/watcher-freebsd-x64": "2.5.0", + "@parcel/watcher-linux-arm-glibc": "2.5.0", + "@parcel/watcher-linux-arm-musl": "2.5.0", + "@parcel/watcher-linux-arm64-glibc": "2.5.0", + "@parcel/watcher-linux-arm64-musl": "2.5.0", + "@parcel/watcher-linux-x64-glibc": "2.5.0", + "@parcel/watcher-linux-x64-musl": "2.5.0", + "@parcel/watcher-win32-arm64": "2.5.0", + "@parcel/watcher-win32-ia32": "2.5.0", + "@parcel/watcher-win32-x64": "2.5.0" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz", + "integrity": "sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz", + "integrity": "sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, "node_modules/@pkgjs/parseargs": { "version": "0.11.0", @@ -4182,30 +4102,301 @@ "react": ">=16.8.0" } }, + "node_modules/@sinclair/typebox": { + "version": "0.27.8", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", + "integrity": "sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==", + "license": "MIT" + }, "node_modules/@sinonjs/commons": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.8.6.tgz", - "integrity": "sha512-Ky+XkAkqPZSm3NLBeUng77EBQl3cmeJhITaGHdYH8kjVB+aun3S4XBRti2zt17mtt0mIUDiNxYeoJm6drVvBJQ==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", + "integrity": "sha512-K3mCHKQ9sVh8o1C9cxkwxaOmXoAMlDxC1mYyHrjqOWEcBjYr76t96zL2zlj5dUGZ3HSw240X1qgH3Mjf1yJWpQ==", + "license": "BSD-3-Clause", "dependencies": { "type-detect": "4.0.8" } }, "node_modules/@sinonjs/fake-timers": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-6.0.1.tgz", - "integrity": "sha512-MZPUxrmFubI36XS1DI3qmI0YdN1gks62JtFZvxR67ljjSNCeK6U08Zx4msEWOXuofgqUt6zPHSi1H9fbjR/NRA==", + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", + "license": "BSD-3-Clause", "dependencies": { - "@sinonjs/commons": "^1.7.0" + "@sinonjs/commons": "^3.0.0" + } + }, + "node_modules/@sinonjs/samsam": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.2.tgz", + "integrity": "sha512-v46t/fwnhejRSFTGqbpn9u+LQ9xJDse10gNnPgAcxgdoCDMXj/G2asWAC/8Qs+BAZDicX+MNZouXT1A7c83kVw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1", + "lodash.get": "^4.4.2", + "type-detect": "^4.1.0" + } + }, + "node_modules/@sinonjs/samsam/node_modules/type-detect": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", + "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@sinonjs/text-encoding": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/@sinonjs/text-encoding/-/text-encoding-0.7.3.tgz", + "integrity": "sha512-DE427ROAphMQzU4ENbliGYrBSYPXF+TtLg9S8vzeA+OF4ZKzoDdzfL8sxuMUGS/lgRhM6j1URSk9ghf7Xo1tyA==", + "dev": true, + "license": "(Unlicense OR Apache-2.0)" + }, + "node_modules/@testing-library/dom": { + "version": "8.20.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz", + "integrity": "sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g==", + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.1.3", + "chalk": "^4.1.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/@testing-library/dom/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + }, + "node_modules/@testing-library/dom/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", + "integrity": "sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==" + }, + "node_modules/@testing-library/jest-dom/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/@testing-library/react": { + "version": "12.1.5", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-12.1.5.tgz", + "integrity": "sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg==", + "dependencies": { + "@babel/runtime": "^7.12.5", + "@testing-library/dom": "^8.0.0", + "@types/react-dom": "<18.0.0" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "<18.0.0", + "react-dom": "<18.0.0" + } + }, + "node_modules/@testing-library/user-event": { + "version": "12.8.3", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-12.8.3.tgz", + "integrity": "sha512-IR0iWbFkgd56Bu5ZI/ej8yQwrkCv8Qydx6RzwbKz9faXazR/+5tvYKsZQgyXJiwgpcva127YO6JcWy7YlCfofQ==", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=10", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" } }, "node_modules/@tootallnate/once": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz", - "integrity": "sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", + "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", + "license": "MIT", "engines": { - "node": ">= 6" + "node": ">= 10" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4247,30 +4438,43 @@ "@babel/types": "^7.20.7" } }, - "node_modules/@types/cheerio": { - "version": "0.22.35", - "resolved": "https://registry.npmjs.org/@types/cheerio/-/cheerio-0.22.35.tgz", - "integrity": "sha512-yD57BchKRvTV+JD53UZ6PD8KWY5g5rvvMLRnZR3EQBCZXiDT/HR+pKpMzFGlWNhFrXlo7VPZXtKvIEwZkAWOIA==", - "dev": true, - "dependencies": { - "@types/node": "*" - } - }, "node_modules/@types/cookie": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.3.3.tgz", "integrity": "sha512-LKVP3cgXBT9RYj+t+9FDKwS5tdI+rPBXaNSkma7hvqy35lc7mAokC2zsqWJH0LaqIt3B962nuYI77hsJoT1gow==" }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, "node_modules/@types/estree": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz", - "integrity": "sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==" + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "license": "MIT" }, "node_modules/@types/graceful-fs": { "version": "4.1.9", "resolved": "https://registry.npmjs.org/@types/graceful-fs/-/graceful-fs-4.1.9.tgz", "integrity": "sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*" } @@ -4301,18 +4505,22 @@ "@types/istanbul-lib-report": "*" } }, + "node_modules/@types/jsdom": { + "version": "20.0.1", + "resolved": "https://registry.npmjs.org/@types/jsdom/-/jsdom-20.0.1.tgz", + "integrity": "sha512-d0r18sZPmMQr1eG35u12FZfhIXNrnsPU/g5wvRKCUf/tOGilKKwYMYGqh33BNR6ba+2gkHw1EUiHoN3mn7E5IQ==", + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/tough-cookie": "*", + "parse5": "^7.0.0" + } + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==" }, - "node_modules/@types/json5": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", - "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", - "dev": true, - "peer": true - }, "node_modules/@types/minimist": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/@types/minimist/-/minimist-1.2.5.tgz", @@ -4333,26 +4541,29 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, - "node_modules/@types/prettier": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/@types/prettier/-/prettier-2.7.3.tgz", - "integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==", - "dev": true - }, "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" }, "node_modules/@types/react": { - "version": "18.2.73", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.2.73.tgz", - "integrity": "sha512-XcGdod0Jjv84HOC7N5ziY3x+qL0AfmubvKOZ9hJjJ2yd5EE+KYjWhdOjt387e9HPheHkdggF9atTifMRtyAaRA==", + "version": "17.0.85", + "resolved": "https://registry.npmjs.org/@types/react/-/react-17.0.85.tgz", + "integrity": "sha512-5oBDUsRDsrYq4DdyHaL99gE1AJCfuDhyxqF6/55fvvOIRkp1PpKuwJ+aMiGJR+GJt7YqMNclPROTHF20vY2cXA==", "dependencies": { "@types/prop-types": "*", + "@types/scheduler": "^0.16", "csstype": "^3.0.2" } }, + "node_modules/@types/react-dom": { + "version": "17.0.26", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-17.0.26.tgz", + "integrity": "sha512-Z+2VcYXJwOqQ79HreLU/1fyQ88eXSSFh6I3JdrEHQIfYSI0kCQpTGvOrbE6jFGGYXKsHuwY9tBa/w5Uo6KzrEg==", + "peerDependencies": { + "@types/react": "^17.0.0" + } + }, "node_modules/@types/react-transition-group": { "version": "4.4.10", "resolved": "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.10.tgz", @@ -4361,18 +4572,22 @@ "@types/react": "*" } }, - "node_modules/@types/semver": { - "version": "7.5.8", - "resolved": "https://registry.npmjs.org/@types/semver/-/semver-7.5.8.tgz", - "integrity": "sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==", - "dev": true, - "license": "MIT", - "peer": true + "node_modules/@types/scheduler": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.8.tgz", + "integrity": "sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A==" }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", - "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==" + "integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==", + "license": "MIT" + }, + "node_modules/@types/tough-cookie": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz", + "integrity": "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==", + "license": "MIT" }, "node_modules/@types/warning": { "version": "3.0.3", @@ -4380,9 +4595,10 @@ "integrity": "sha512-D1XC7WK8K+zZEveUPY+cf4+kgauk8N4eHr/XIHXGlGYkHLud6hK9lYfZk1ry1TNh798cZUCgb6MqGEG8DkJt6Q==" }, "node_modules/@types/yargs": { - "version": "15.0.19", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.19.tgz", - "integrity": "sha512-2XUaGVmyQjgyAZldf0D0c14vvo/yv0MhQBSTJcejMMaitsn3nxCB6TmH4G0ZQf+uxROOa9mpanoSm8h6SG/1ZA==", + "version": "17.0.33", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.33.tgz", + "integrity": "sha512-WpxBCKWPLr4xSsHgz511rFJAM+wS28w2zEO1QDNY5zM/S8ok70NNfztH0xwhqKyaK0OHCbN98LDAZuy1ctxDkA==", + "license": "MIT", "dependencies": { "@types/yargs-parser": "*" } @@ -4392,413 +4608,149 @@ "resolved": "https://registry.npmjs.org/@types/yargs-parser/-/yargs-parser-21.0.3.tgz", "integrity": "sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==" }, - "node_modules/@typescript-eslint/eslint-plugin": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.62.0.tgz", - "integrity": "sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@eslint-community/regexpp": "^4.4.0", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/type-utils": "5.62.0", - "@typescript-eslint/utils": "5.62.0", - "debug": "^4.3.4", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "natural-compare-lite": "^1.4.0", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "@typescript-eslint/parser": "^5.0.0", - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "dev": true, - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/parser": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.62.0.tgz", - "integrity": "sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", - "debug": "^4.3.4" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-5.62.0.tgz", - "integrity": "sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/type-utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-5.62.0.tgz", - "integrity": "sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/typescript-estree": "5.62.0", - "@typescript-eslint/utils": "5.62.0", - "debug": "^4.3.4", - "tsutils": "^3.21.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "*" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/types": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-5.62.0.tgz", - "integrity": "sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@typescript-eslint/typescript-estree": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-5.62.0.tgz", - "integrity": "sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/visitor-keys": "5.62.0", - "debug": "^4.3.4", - "globby": "^11.1.0", - "is-glob": "^4.0.3", - "semver": "^7.3.7", - "tsutils": "^3.21.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependenciesMeta": { - "typescript": { - "optional": true - } - } - }, - "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "dev": true, - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/utils": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-5.62.0.tgz", - "integrity": "sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@types/json-schema": "^7.0.9", - "@types/semver": "^7.3.12", - "@typescript-eslint/scope-manager": "5.62.0", - "@typescript-eslint/types": "5.62.0", - "@typescript-eslint/typescript-estree": "5.62.0", - "eslint-scope": "^5.1.1", - "semver": "^7.3.7" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - }, - "peerDependencies": { - "eslint": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/eslint-scope": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", - "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^4.1.1" - }, - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "license": "BSD-2-Clause", - "peer": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/@typescript-eslint/utils/node_modules/semver": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.2.tgz", - "integrity": "sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==", - "dev": true, - "license": "ISC", - "peer": true, - "bin": { - "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/@typescript-eslint/visitor-keys": { - "version": "5.62.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-5.62.0.tgz", - "integrity": "sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@typescript-eslint/types": "5.62.0", - "eslint-visitor-keys": "^3.3.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/typescript-eslint" - } - }, - "node_modules/@ungap/structured-clone": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", - "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==", - "dev": true, - "peer": true - }, "node_modules/@webassemblyjs/ast": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.12.1.tgz", - "integrity": "sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "license": "MIT", "dependencies": { - "@webassemblyjs/helper-numbers": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6" + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, "node_modules/@webassemblyjs/floating-point-hex-parser": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz", - "integrity": "sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==" + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "license": "MIT" }, "node_modules/@webassemblyjs/helper-api-error": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz", - "integrity": "sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==" + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT" }, "node_modules/@webassemblyjs/helper-buffer": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz", - "integrity": "sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==" + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT" }, "node_modules/@webassemblyjs/helper-numbers": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz", - "integrity": "sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", "dependencies": { - "@webassemblyjs/floating-point-hex-parser": "1.11.6", - "@webassemblyjs/helper-api-error": "1.11.6", + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", "@xtuc/long": "4.2.2" } }, "node_modules/@webassemblyjs/helper-wasm-bytecode": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz", - "integrity": "sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==" + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT" }, "node_modules/@webassemblyjs/helper-wasm-section": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz", - "integrity": "sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/wasm-gen": "1.12.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" } }, "node_modules/@webassemblyjs/ieee754": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz", - "integrity": "sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "license": "MIT", "dependencies": { "@xtuc/ieee754": "^1.2.0" } }, "node_modules/@webassemblyjs/leb128": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.11.6.tgz", - "integrity": "sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==", + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", "dependencies": { "@xtuc/long": "4.2.2" } }, "node_modules/@webassemblyjs/utf8": { - "version": "1.11.6", - "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.11.6.tgz", - "integrity": "sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==" + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT" }, "node_modules/@webassemblyjs/wasm-edit": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz", - "integrity": "sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/helper-wasm-section": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-opt": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1", - "@webassemblyjs/wast-printer": "1.12.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" } }, "node_modules/@webassemblyjs/wasm-gen": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz", - "integrity": "sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, "node_modules/@webassemblyjs/wasm-opt": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz", - "integrity": "sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-buffer": "1.12.1", - "@webassemblyjs/wasm-gen": "1.12.1", - "@webassemblyjs/wasm-parser": "1.12.1" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" } }, "node_modules/@webassemblyjs/wasm-parser": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz", - "integrity": "sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", - "@webassemblyjs/helper-api-error": "1.11.6", - "@webassemblyjs/helper-wasm-bytecode": "1.11.6", - "@webassemblyjs/ieee754": "1.11.6", - "@webassemblyjs/leb128": "1.11.6", - "@webassemblyjs/utf8": "1.11.6" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" } }, "node_modules/@webassemblyjs/wast-printer": { - "version": "1.12.1", - "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz", - "integrity": "sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==", + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", "dependencies": { - "@webassemblyjs/ast": "1.12.1", + "@webassemblyjs/ast": "1.14.1", "@xtuc/long": "4.2.2" } }, @@ -4849,12 +4801,14 @@ "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", - "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==" + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "license": "BSD-3-Clause" }, "node_modules/@xtuc/long": { "version": "4.2.2", "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", - "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==" + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "license": "Apache-2.0" }, "node_modules/abab": { "version": "2.0.6", @@ -4863,10 +4817,13 @@ "deprecated": "Use your platform's native atob() and btoa() methods instead" }, "node_modules/abbrev": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.0.9.tgz", - "integrity": "sha512-LEyx4aLEC3x6T0UguF6YILf+ntvmOaWsVfENmIW0E9H09vKlLDGelMjjSm0jkDHALj8A8quZ/HapKNigzwge+Q==", - "dev": true + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-3.0.0.tgz", + "integrity": "sha512-+/kfrslGQ7TNV2ecmQwMJj/B65g5KVq1/L3SGVZ3tCYGqlzFuFCGBZJtMP99wH3NpEUyAjn0zPdPUg0D+DwrOA==", + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } }, "node_modules/accepts": { "version": "1.3.3", @@ -4891,9 +4848,10 @@ } }, "node_modules/acorn": { - "version": "8.11.3", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz", - "integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "license": "MIT", "bin": { "acorn": "bin/acorn" }, @@ -4902,48 +4860,23 @@ } }, "node_modules/acorn-globals": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-6.0.0.tgz", - "integrity": "sha512-ZQl7LOWaF5ePqqcX4hLuv/bLXYQNfNWw2c0/yX/TsPRKamzHcTGQnlCjHT3TsmkOUVEPS3crCxiPfdzE/Trlhg==", - "dependencies": { - "acorn": "^7.1.1", - "acorn-walk": "^7.1.1" - } - }, - "node_modules/acorn-globals/node_modules/acorn": { - "version": "7.4.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", - "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/acorn-import-attributes": { - "version": "1.9.5", - "resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz", - "integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==", + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-7.0.1.tgz", + "integrity": "sha512-umOSDSDrfHbTNPuNpC2NSnnA3LUrqpevPb4T9jRx4MagXNS0rs+gwiTcAvqCRmsD6utzsrzNt+ebm00SNWiC3Q==", "license": "MIT", - "peerDependencies": { - "acorn": "^8" - } - }, - "node_modules/acorn-jsx": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", - "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", - "dev": true, - "peer": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + "dependencies": { + "acorn": "^8.1.0", + "acorn-walk": "^8.0.2" } }, "node_modules/acorn-walk": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-7.2.0.tgz", - "integrity": "sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==", + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.4.tgz", + "integrity": "sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==", + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, "engines": { "node": ">=0.4.0" } @@ -4965,18 +4898,6 @@ "node": ">= 6.0.0" } }, - "node_modules/aggregate-error": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", - "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dependencies": { - "clean-stack": "^2.0.0", - "indent-string": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/airbnb-prop-types": { "version": "2.16.0", "resolved": "https://registry.npmjs.org/airbnb-prop-types/-/airbnb-prop-types-2.16.0.tgz", @@ -4999,16 +4920,6 @@ "react": "^0.14 || ^15.0.0 || ^16.0.0-alpha" } }, - "node_modules/airbnb-prop-types/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/ajv": { "version": "6.12.6", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", @@ -5024,15 +4935,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/ajv-errors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/ajv-errors/-/ajv-errors-1.0.1.tgz", - "integrity": "sha512-DCRfO/4nQ+89p/RK43i8Ezd41EqdGIU4ld7nGF8OQ14oc/we5rEntLCUa7+jrn3nn83BosfwZA0wb4pon2o8iQ==", - "license": "MIT", - "peerDependencies": { - "ajv": ">=5.0.0" - } - }, "node_modules/ajv-formats": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", @@ -5077,53 +4979,6 @@ "ajv": "^6.9.1" } }, - "node_modules/align-text": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", - "integrity": "sha512-GrTZLRpmp6wIC2ztrWW9MjjTgSKccffgFagbNDOX95/dcjEcYZibYTeaOntySQLcdw1ztBoFkviiUvTMbb9MYg==", - "dependencies": { - "kind-of": "^3.0.2", - "longest": "^1.0.1", - "repeat-string": "^1.5.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/align-text/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/align-text/node_modules/repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", - "engines": { - "node": ">=0.10" - } - }, - "node_modules/alphanum-sort": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/alphanum-sort/-/alphanum-sort-1.0.2.tgz", - "integrity": "sha512-0FcBfdcmaumGPQ0qPn7Q5qTgz/ooXgIyp1rf8ik5bGX8mpE2YHjC0P/eyQvxu1GURYQgq9ozf2mteQ5ZD9YiyQ==" - }, - "node_modules/amdefine": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", - "integrity": "sha512-S2Hw0TtNkMJhIabBwIojKL9YHO5T0n5eNqWJ7Lrlel/zDbftQpxpapi8tZs3X1HWa+u+QeydGmzzNU0m09+Rcg==", - "dev": true, - "optional": true, - "engines": { - "node": ">=0.4.2" - } - }, "node_modules/ansi-colors": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", @@ -5207,6 +5062,8 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" @@ -5219,6 +5076,7 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, "dependencies": { "sprintf-js": "~1.0.2" } @@ -5235,13 +5093,11 @@ } }, "node_modules/aria-query": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", - "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", - "dev": true, - "peer": true, + "version": "5.1.3", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz", + "integrity": "sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ==", "dependencies": { - "dequal": "^2.0.3" + "deep-equal": "^2.0.5" } }, "node_modules/arr-diff": { @@ -5288,45 +5144,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array-equal": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-equal/-/array-equal-1.0.2.tgz", - "integrity": "sha512-gUHx76KtnhEgB3HOuFYiCm3FIdEs6ocM2asHvNTkfu/Y09qQVrrVVaOKENmS2KkSaGoxgXNqC+ZVtR/n0MOkSA==", - "dev": true, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/array-find-index": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", - "integrity": "sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/array-includes": { - "version": "3.1.8", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", - "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "is-string": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/array-slice": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-0.2.3.tgz", @@ -5354,26 +5171,6 @@ "node": ">=0.10.0" } }, - "node_modules/array.prototype.filter": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/array.prototype.filter/-/array.prototype.filter-1.0.4.tgz", - "integrity": "sha512-r+mCJ7zXgXElgR4IRC+fkvNCeoaavWBs6EdCso5Tbcf+iEMKzBU/His60lt34WEZ9vlb8wDkZvQGcVI5GwkfoQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-array-method-boxes-properly": "^1.0.0", - "es-object-atoms": "^1.0.0", - "is-string": "^1.0.7" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/array.prototype.find": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/array.prototype.find/-/array.prototype.find-2.2.3.tgz", @@ -5392,112 +5189,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/array.prototype.findlast": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", - "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.findlastindex": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz", - "integrity": "sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "es-shim-unscopables": "^1.0.2" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flat": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz", - "integrity": "sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.flatmap": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz", - "integrity": "sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/array.prototype.toreversed": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/array.prototype.toreversed/-/array.prototype.toreversed-1.1.2.tgz", - "integrity": "sha512-wwDCoT4Ck4Cz7sLtgUmzR5UV3YF5mFHUlbChCzZBQZ+0m2cl/DH3tKgvphv1nKgFsJ48oCSg6p91q2Vm0I/ZMA==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0" - } - }, - "node_modules/array.prototype.tosorted": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.3.tgz", - "integrity": "sha512-/DdH4TiTmOKzyQbp/eadcCVexiCb36xJg7HshYOYJnNZFDj33GEv0P7GxsynpShhq4OLYJzbGcBDkLsDt7MnNg==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.5", - "define-properties": "^1.2.1", - "es-abstract": "^1.22.3", - "es-errors": "^1.1.0", - "es-shim-unscopables": "^1.0.2" - } - }, "node_modules/arraybuffer.prototype.slice": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", @@ -5534,34 +5225,11 @@ "node": ">=0.10.0" } }, - "node_modules/asap": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", - "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==" - }, - "node_modules/asn1": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", - "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", - "dev": true, - "dependencies": { - "safer-buffer": "~2.1.0" - } - }, "node_modules/assert-ok": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-ok/-/assert-ok-1.0.0.tgz", "integrity": "sha512-lCvYmCpMl8c1tp9ynExhoDEk0gGW43SVVC3RE1VYrrVKhNMy8GHfdiwZdoIM6a605s56bUAbENQxtOC0uZp3wg==" }, - "node_modules/assert-plus": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", - "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", - "dev": true, - "engines": { - "node": ">=0.8" - } - }, "node_modules/assign-symbols": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", @@ -5570,13 +5238,6 @@ "node": ">=0.10.0" } }, - "node_modules/ast-types-flow": { - "version": "0.0.8", - "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", - "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", - "dev": true, - "peer": true - }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -5586,12 +5247,6 @@ "node": ">=8" } }, - "node_modules/async": { - "version": "1.5.2", - "resolved": "https://registry.npmjs.org/async/-/async-1.5.2.tgz", - "integrity": "sha512-nSVgobk4rv61R9PUSDtYt7mPVB2olxNR5RWJcAsH676/ef11bUZwvu7+RGYrYauVdDPcO519v68wRhXQtxsV9w==", - "dev": true - }, "node_modules/async-each": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.6.tgz", @@ -5604,12 +5259,6 @@ } ] }, - "node_modules/async-limiter": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.1.tgz", - "integrity": "sha512-csOlWGAcRFJaI6m+F2WKdnMKr4HhdhFVBk0H/QbJFMCr+uO2kwohwXQPxw/9OCxp05r5ghVBFSyioixx3gfkNQ==", - "dev": true - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -5638,32 +5287,6 @@ "node": ">=4" } }, - "node_modules/autoprefixer": { - "version": "6.7.7", - "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-6.7.7.tgz", - "integrity": "sha512-WKExI/eSGgGAkWAO+wMVdFObZV7hQen54UpD1kCCTN3tvlL3W1jL4+lPP/M7MwoP7Q4RHzKtO3JQ4HxYEcd+xQ==", - "dependencies": { - "browserslist": "^1.7.6", - "caniuse-db": "^1.0.30000634", - "normalize-range": "^0.1.2", - "num2fraction": "^1.2.2", - "postcss": "^5.2.16", - "postcss-value-parser": "^3.2.3" - } - }, - "node_modules/autoprefixer/node_modules/browserslist": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-1.7.7.tgz", - "integrity": "sha512-qHJblDE2bXVRYzuDetv/wAeHOJyO97+9wxC1cdCtyzgNuSozOyRCiiLaCR1f71AN66lQdVVBipWm63V+a7bPOw==", - "deprecated": "Browserslist 2 could fail on reading Browserslist >3.0 config used in other tools.", - "dependencies": { - "caniuse-db": "^1.0.30000639", - "electron-to-chromium": "^1.2.7" - }, - "bin": { - "browserslist": "cli.js" - } - }, "node_modules/available-typed-arrays": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", @@ -5678,41 +5301,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/aws-sign2": { - "version": "0.7.0", - "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", - "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/aws4": { - "version": "1.12.0", - "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", - "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==", - "dev": true - }, - "node_modules/axe-core": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.7.0.tgz", - "integrity": "sha512-M0JtH+hlOL5pLQwHOLNYZaXuhqmvS8oExsqB1SBYgA4Dk7u/xx+YdGHXaK5pyUfed5mYXdlYiphWq3G8cRi5JQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/axobject-query": { - "version": "3.2.1", - "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-3.2.1.tgz", - "integrity": "sha512-jsyHu61e6N4Vbz/v18DHwWYKK0bSWLqn47eeDSKPB7m8tqMHF9YJ+mhIk2lVteyZrY8tnSj/jHOv4YiTCuCJgg==", - "dev": true, - "peer": true, - "dependencies": { - "dequal": "^2.0.3" - } - }, "node_modules/babel-code-frame": { "version": "6.26.0", "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", @@ -5835,26 +5423,25 @@ "integrity": "sha512-m2CvfDW4+1qfDdsrtf4dwOslQC3yhbgyBFptncp4wvtdrDHqueW7slsYv4gArie056phvQFhT2nRcGS4bnm6mA==" }, "node_modules/babel-jest": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-26.6.3.tgz", - "integrity": "sha512-pl4Q+GAVOHwvjrck6jKjvmGhnO3jHX/xuB9d27f+EJZ/6k+6nMuPjorrYp7s++bKKdANwzElBWnLWaObvTnaZA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz", + "integrity": "sha512-BrvGY3xZSwEcCzKvKsCi2GgHqDqsYkOP4/by5xCgIwGXQxIEh+8ew3gmrE1y7XRR6LHZIj6yLYnUi/mm2KXKBg==", "dev": true, "license": "MIT", "dependencies": { - "@jest/transform": "^26.6.2", - "@jest/types": "^26.6.2", - "@types/babel__core": "^7.1.7", - "babel-plugin-istanbul": "^6.0.0", - "babel-preset-jest": "^26.6.2", + "@jest/transform": "^29.7.0", + "@types/babel__core": "^7.1.14", + "babel-plugin-istanbul": "^6.1.1", + "babel-preset-jest": "^29.6.3", "chalk": "^4.0.0", - "graceful-fs": "^4.2.4", + "graceful-fs": "^4.2.9", "slash": "^3.0.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, "peerDependencies": { - "@babel/core": "^7.0.0" + "@babel/core": "^7.8.0" } }, "node_modules/babel-jest/node_modules/ansi-styles": { @@ -5975,18 +5562,19 @@ } }, "node_modules/babel-plugin-jest-hoist": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-26.6.2.tgz", - "integrity": "sha512-PO9t0697lNTmcEHH69mdtYiOIkkOlj9fySqfO3K1eCcdISevLAE0xY59VLLUj0SoiPiTX/JU2CYFpILydUa5Lw==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-jest-hoist/-/babel-plugin-jest-hoist-29.6.3.tgz", + "integrity": "sha512-ESAc/RJvGTFEzRwOTT4+lNDk/GNHMkKbNzsvT0qKRfDyyYTskxB5rnU2njIDYVxXCBHHEI1c0YwHob3WaYujOg==", "dev": true, + "license": "MIT", "dependencies": { "@babel/template": "^7.3.3", "@babel/types": "^7.3.3", - "@types/babel__core": "^7.0.0", + "@types/babel__core": "^7.1.14", "@types/babel__traverse": "^7.0.6" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/babel-plugin-minify-builtins": { @@ -6086,12 +5674,13 @@ } }, "node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.10.4", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.4.tgz", - "integrity": "sha512-25J6I8NGfa5YkCDogHRID3fVCadIR8/pGl1/spvCkzb6lVn6SR3ojpx9nOn9iEBcUsjY24AmdKm5khcfKdylcg==", + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", + "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", + "license": "MIT", "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.1", - "core-js-compat": "^3.36.1" + "@babel/helper-define-polyfill-provider": "^0.6.2", + "core-js-compat": "^3.38.0" }, "peerDependencies": { "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" @@ -6224,16 +5813,17 @@ } }, "node_modules/babel-preset-jest": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-26.6.2.tgz", - "integrity": "sha512-YvdtlVm9t3k777c5NPQIv6cxFFFapys25HiUmuSgHwIZhfifweR5c5Sf5nwE3MAbfu327CYSvps8Yx6ANLyleQ==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/babel-preset-jest/-/babel-preset-jest-29.6.3.tgz", + "integrity": "sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==", "dev": true, + "license": "MIT", "dependencies": { - "babel-plugin-jest-hoist": "^26.6.2", + "babel-plugin-jest-hoist": "^29.6.3", "babel-preset-current-node-syntax": "^1.0.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, "peerDependencies": { "@babel/core": "^7.0.0" @@ -6460,15 +6050,6 @@ "integrity": "sha512-aQgHPLH2DHpFTpBl5/GiVdNzHEqsLCSs1RiPvqkKP1+7RkNJlv71kL8/KXmvvaLqoZ7ylmvqkZhLjjAoRz8Xgw==", "dev": true }, - "node_modules/bcrypt-pbkdf": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", - "integrity": "sha512-qeFIXtP4MSoi6NLqO12WfqARWWuCKi2Rn/9hJLEmtB5yTNr9DqFWkJRCf2qShWzPeAMRnOgCrq0sg/KLv5ES9w==", - "dev": true, - "dependencies": { - "tweetnacl": "^0.14.3" - } - }, "node_modules/better-assert": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/better-assert/-/better-assert-1.0.2.tgz", @@ -6498,16 +6079,6 @@ "node": ">=0.10.0" } }, - "node_modules/bindings": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", - "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", - "dev": true, - "optional": true, - "dependencies": { - "file-uri-to-path": "1.0.0" - } - }, "node_modules/blob": { "version": "0.0.4", "resolved": "https://registry.npmjs.org/blob/-/blob-0.0.4.tgz", @@ -6521,9 +6092,9 @@ "dev": true }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dev": true, "dependencies": { "bytes": "3.1.2", @@ -6534,7 +6105,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -6559,12 +6130,6 @@ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", "dev": true }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true - }, "node_modules/bootstrap": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-4.0.0.tgz", @@ -6598,15 +6163,10 @@ "node": ">=0.10.0" } }, - "node_modules/browser-process-hrtime": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/browser-process-hrtime/-/browser-process-hrtime-1.0.0.tgz", - "integrity": "sha512-9o5UecI3GhkpM6DrXr69PblIuWxPKk9Y0jHBRhdocZ2y7YECBFCsHm79Pr3OyR2AvjhDkabFJaDJMYRazHgsow==" - }, "node_modules/browserslist": { - "version": "4.23.3", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", - "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", + "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", "funding": [ { "type": "opencollective", @@ -6623,10 +6183,10 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001646", - "electron-to-chromium": "^1.5.4", + "caniuse-lite": "^1.0.30001669", + "electron-to-chromium": "^1.5.41", "node-releases": "^2.0.18", - "update-browserslist-db": "^1.1.0" + "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" @@ -6681,11 +6241,12 @@ } }, "node_modules/cacache": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-18.0.2.tgz", - "integrity": "sha512-r3NU8h/P+4lVUHfeRw1dtgQYar3DZMm4/cm2bZgOvrFC/su7budSOeqh52VJIC4U4iG1WWwV6vRW0znqBvxNuw==", + "version": "19.0.1", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-19.0.1.tgz", + "integrity": "sha512-hdsUxulXCi5STId78vRVYEtDAjq99ICAUktLTeTYsLoTE6Z8dS0c8pWNCxwdrk9YfJeobDZc2Y186hD/5ZQgFQ==", + "license": "ISC", "dependencies": { - "@npmcli/fs": "^3.1.0", + "@npmcli/fs": "^4.0.0", "fs-minipass": "^3.0.0", "glob": "^10.2.2", "lru-cache": "^10.0.1", @@ -6693,56 +6254,55 @@ "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", - "p-map": "^4.0.0", - "ssri": "^10.0.0", - "tar": "^6.1.11", - "unique-filename": "^3.0.0" + "p-map": "^7.0.2", + "ssri": "^12.0.0", + "tar": "^7.4.3", + "unique-filename": "^4.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/cacache/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/cacache/node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, "node_modules/cacache/node_modules/lru-cache": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.0.tgz", - "integrity": "sha512-2bIM8x+VAf6JT4bKAljS1qUWgMsqZRPGJS6FSahIMPVvctcNhyVp7AJu7quxOW9jwkryBReKZY5tY5JYv2n/7Q==", - "engines": { - "node": "14 || >=16.14" - } + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==", + "license": "ISC" }, "node_modules/cacache/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -6800,6 +6360,35 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.1.tgz", + "integrity": "sha512-BhYE+WDaywFg2TBWYNXAE+8B1ATnThNBqXHP5nQu0jWJdVvY2hvkpyB3qOmtmDePiS5/BDQ8wASEWGMWRG148g==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.3.tgz", + "integrity": "sha512-YTd+6wGlNlPxSuri7Y6X8tY2dmm12UMH66RpKMhiX6rsk5wXXnYgbUcOt8kiS31/AjfoTOvCsE+w8nZQLQnzHA==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caller-path": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-0.1.0.tgz", @@ -6857,39 +6446,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/caniuse-api": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/caniuse-api/-/caniuse-api-1.6.1.tgz", - "integrity": "sha512-SBTl70K0PkDUIebbkXrxWqZlHNs0wRgRD6QZ8guctShjbh63gEPfF+Wj0Yw+75f5Y8tSzqAI/NcisYv/cCah2Q==", - "dependencies": { - "browserslist": "^1.3.6", - "caniuse-db": "^1.0.30000529", - "lodash.memoize": "^4.1.2", - "lodash.uniq": "^4.5.0" - } - }, - "node_modules/caniuse-api/node_modules/browserslist": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-1.7.7.tgz", - "integrity": "sha512-qHJblDE2bXVRYzuDetv/wAeHOJyO97+9wxC1cdCtyzgNuSozOyRCiiLaCR1f71AN66lQdVVBipWm63V+a7bPOw==", - "deprecated": "Browserslist 2 could fail on reading Browserslist >3.0 config used in other tools.", - "dependencies": { - "caniuse-db": "^1.0.30000639", - "electron-to-chromium": "^1.2.7" - }, - "bin": { - "browserslist": "cli.js" - } - }, - "node_modules/caniuse-db": { - "version": "1.0.30001600", - "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30001600.tgz", - "integrity": "sha512-BS+RAD1DggiDlE2KaBUWKsMDuVmmh3hCM5LI0OW25mGlPttGLeOjDUa1DmZvJVFCXvtshY4BTyFgv31eFTLg8g==" - }, "node_modules/caniuse-lite": { - "version": "1.0.30001651", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", - "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==", + "version": "1.0.30001680", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz", + "integrity": "sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==", "funding": [ { "type": "opencollective", @@ -6906,24 +6466,6 @@ ], "license": "CC-BY-4.0" }, - "node_modules/capture-exit": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/capture-exit/-/capture-exit-2.0.0.tgz", - "integrity": "sha512-PiT/hQmTonHhl/HFGN+Lx3JJUznrVYJ3+AQsnthneZbvW7x+f08Tk7yLJTLEOUvBTbduLeeBkxEaYXUOUrRq6g==", - "dev": true, - "dependencies": { - "rsvp": "^4.8.4" - }, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/caseless": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", - "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", - "dev": true - }, "node_modules/cast-array": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cast-array/-/cast-array-1.0.1.tgz", @@ -6932,18 +6474,6 @@ "isarray": "0.0.1" } }, - "node_modules/center-align": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", - "integrity": "sha512-Baz3aNe2gd2LP2qk5U+sDk/m4oSuwSDcBfayTCTBoWpfIGO5XFxPmjILQII4NGiZjD6DoDI6kf7gKaxkf7s3VQ==", - "dependencies": { - "align-text": "^0.1.3", - "lazy-cache": "^1.0.3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -6962,48 +6492,11 @@ "resolved": "https://registry.npmjs.org/char-regex/-/char-regex-1.0.2.tgz", "integrity": "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" } }, - "node_modules/cheerio": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", - "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", - "dev": true, - "dependencies": { - "cheerio-select": "^2.1.0", - "dom-serializer": "^2.0.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "htmlparser2": "^8.0.1", - "parse5": "^7.0.0", - "parse5-htmlparser2-tree-adapter": "^7.0.0" - }, - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/cheeriojs/cheerio?sponsor=1" - } - }, - "node_modules/cheerio-select": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", - "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", - "dev": true, - "dependencies": { - "boolbase": "^1.0.0", - "css-select": "^5.1.0", - "css-what": "^6.1.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, "node_modules/chokidar": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-1.7.0.tgz", @@ -7060,25 +6553,6 @@ "node": ">=0.10.0" } }, - "node_modules/chokidar/node_modules/fsevents": { - "version": "1.2.13", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", - "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", - "deprecated": "The v1 package contains DANGEROUS / INSECURE binaries. Upgrade to safe fsevents v2", - "dev": true, - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "dependencies": { - "bindings": "^1.5.0", - "nan": "^2.12.1" - }, - "engines": { - "node": ">= 4.0" - } - }, "node_modules/chokidar/node_modules/glob-parent": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-2.0.0.tgz", @@ -7158,11 +6632,12 @@ } }, "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=10" + "node": ">=18" } }, "node_modules/chrome-trace-event": { @@ -7173,11 +6648,6 @@ "node": ">=6.0" } }, - "node_modules/ci-info": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-2.0.0.tgz", - "integrity": "sha512-5tK7EtrZ0N+OLFMthtqOj4fI2Jeb88C4CAZPu25LDVUgXJ0A3Js4PMGqrn0JU1W0Mh1/Z8wZzYPxqUrXeBboCQ==" - }, "node_modules/circular-json": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz", @@ -7185,78 +6655,12 @@ "deprecated": "CircularJSON is in maintenance only, flatted is its successor.", "dev": true }, - "node_modules/circular-json-es6": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/circular-json-es6/-/circular-json-es6-2.0.2.tgz", - "integrity": "sha512-ODYONMMNb3p658Zv+Pp+/XPa5s6q7afhz3Tzyvo+VRh9WIrJ64J76ZC4GQxnlye/NesTn09jvOiuE8+xxfpwhQ==", - "dev": true - }, "node_modules/cjs-module-lexer": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-0.6.0.tgz", - "integrity": "sha512-uc2Vix1frTfnuzxxu1Hp4ktSvM3QaI4oXl4ZUqL1wjTu/BGki9TrCWoqLTg/drR1KwAEarXuRFCG2Svr1GxPFw==", - "dev": true - }, - "node_modules/clap": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/clap/-/clap-1.2.3.tgz", - "integrity": "sha512-4CoL/A3hf90V3VIEjeuhSvlGFEHKzOz+Wfc2IVZc+FaUgU0ZQafJTP49fvnULipOPcAfqhyI2duwQyns6xqjYA==", - "dependencies": { - "chalk": "^1.1.3" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/clap/node_modules/ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/clap/node_modules/ansi-styles": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/clap/node_modules/chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", - "dependencies": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/clap/node_modules/strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/clap/node_modules/supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", - "engines": { - "node": ">=0.8.0" - } + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.4.1.tgz", + "integrity": "sha512-cuSVIHi9/9E/+821Qjdvngor+xpnlwnuwIyZOaLmHBVdXL+gP+I6QQB9VkO7RI77YIcTV+S1W9AreJ5eN63JBA==", + "dev": true, + "license": "MIT" }, "node_modules/class-utils": { "version": "0.3.6", @@ -7313,14 +6717,6 @@ "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", "license": "MIT" }, - "node_modules/clean-stack": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "engines": { - "node": ">=6" - } - }, "node_modules/cli": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/cli/-/cli-1.0.1.tgz", @@ -7353,22 +6749,18 @@ "dev": true }, "node_modules/cliui": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", - "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", "dev": true, + "license": "ISC", "dependencies": { "string-width": "^4.2.0", - "strip-ansi": "^6.0.0", - "wrap-ansi": "^6.2.0" - } - }, - "node_modules/clone": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", - "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, "engines": { - "node": ">=0.8" + "node": ">=12" } }, "node_modules/clone-deep": { @@ -7416,26 +6808,6 @@ "node": ">= 0.12.0" } }, - "node_modules/coa": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/coa/-/coa-1.0.4.tgz", - "integrity": "sha512-KAGck/eNAmCL0dcT3BiuYwLbExK6lduR8DxM3C1TyDzaXhZHyZ8ooX5I5+na2e3dPFuibfxrGdorr0/Lr7RYCQ==", - "dependencies": { - "q": "^1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/coa/node_modules/q": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/q/-/q-1.5.1.tgz", - "integrity": "sha512-kV/CThkXo6xyFEZUugw/+pIOywXcDbFYgSct5cT3gqlbkBE1SJdwy6UQoZvodiWF/ckQLZyDE/Bu1M6gVu5lVw==", - "engines": { - "node": ">=0.6.0", - "teleport": ">=0.2.0" - } - }, "node_modules/code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", @@ -7449,7 +6821,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/collect-v8-coverage/-/collect-v8-coverage-1.0.2.tgz", "integrity": "sha512-lHl4d5/ONEbLlJvaJNtsF/Lz+WvB07u2ycqTYbdrq7UypDXailES4valYb2eWiJFxZlVmpGekfqoxQhzyFdT4Q==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/collection-visit": { "version": "1.0.0", @@ -7464,16 +6837,6 @@ "node": ">=0.10.0" } }, - "node_modules/color": { - "version": "0.11.4", - "resolved": "https://registry.npmjs.org/color/-/color-0.11.4.tgz", - "integrity": "sha512-Ajpjd8asqZ6EdxQeqGzU5WBhhTfJ/0cA4Wlbre7e5vXfmDSmda7Ov6jeKoru+b0vHcb1CqvuroTHp5zIWzhVMA==", - "dependencies": { - "clone": "^1.0.2", - "color-convert": "^1.3.0", - "color-string": "^0.3.0" - } - }, "node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -7487,14 +6850,6 @@ "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", "integrity": "sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==" }, - "node_modules/color-string": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/color-string/-/color-string-0.3.0.tgz", - "integrity": "sha512-sz29j1bmSDfoAxKIEU6zwoIZXN6BrFbAMIhfYCNyiZXBDuU/aiHlN84lp/xDzL2ubyFhLDobHIlU1X70XRrMDA==", - "dependencies": { - "color-name": "^1.0.0" - } - }, "node_modules/color-support": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", @@ -7516,16 +6871,6 @@ "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true }, - "node_modules/colormin": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/colormin/-/colormin-1.1.2.tgz", - "integrity": "sha512-XSEQUUQUR/lXqGyddiNH3XYFUPYlYr1vXy9rTFMsSOw+J7Q6EQkdlQIrTlYn4TccpsOaUE1PYQNjBn20gwCdgQ==", - "dependencies": { - "color": "^0.11.0", - "css-color-names": "0.0.4", - "has": "^1.0.1" - } - }, "node_modules/colors": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz", @@ -7630,13 +6975,6 @@ "safe-buffer": "~5.1.0" } }, - "node_modules/confusing-browser-globals": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/confusing-browser-globals/-/confusing-browser-globals-1.0.11.tgz", - "integrity": "sha512-JsPKdmh8ZkmnHxDk55FZ1TqVLvEQTvoByJZRN9jzI0UjxK/QgAmsphz7PGtqgPieQZ/CQcHWXCR7ATDNhGe+YA==", - "dev": true, - "peer": true - }, "node_modules/connect": { "version": "3.7.0", "resolved": "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz", @@ -7676,12 +7014,6 @@ "node": ">= 0.6" } }, - "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", - "dev": true - }, "node_modules/cookie": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.3.1.tgz", @@ -7716,12 +7048,12 @@ "hasInstallScript": true }, "node_modules/core-js-compat": { - "version": "3.38.0", - "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.38.0.tgz", - "integrity": "sha512-75LAicdLa4OJVwFxFbQR3NdnZjNgX6ILpVcVzcC4T2smerB5lELMrJQQQoWV6TiuC/vlaFqgU2tKQx9w5s0e0A==", + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.39.0.tgz", + "integrity": "sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==", "license": "MIT", "dependencies": { - "browserslist": "^4.23.3" + "browserslist": "^4.24.2" }, "funding": { "type": "opencollective", @@ -7784,10 +7116,108 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/create-jest": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/create-jest/-/create-jest-29.7.0.tgz", + "integrity": "sha512-Adz2bdH0Vq3F53KEMJOoftQFutWCukm6J24wbPWRO4k1kMY7gS7ds/uoJkNuV8wDCtWWnuwGcJwpWcih+zEW1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/types": "^29.6.3", + "chalk": "^4.0.0", + "exit": "^0.1.2", + "graceful-fs": "^4.2.9", + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "prompts": "^2.0.1" + }, + "bin": { + "create-jest": "bin/create-jest.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/create-jest/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/create-jest/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/create-jest/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/create-jest/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/create-jest/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/create-jest/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -7797,14 +7227,6 @@ "node": ">= 8" } }, - "node_modules/css-color-names": { - "version": "0.0.4", - "resolved": "https://registry.npmjs.org/css-color-names/-/css-color-names-0.0.4.tgz", - "integrity": "sha512-zj5D7X1U2h2zsXOAM8EyUREBnnts6H+Jm+d1M2DbiQQcUtnqgQsMrdo8JW9R80YFUmIdBZeMu5wvYM7hcgWP/Q==", - "engines": { - "node": "*" - } - }, "node_modules/css-functions-list": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz", @@ -7816,27 +7238,168 @@ } }, "node_modules/css-loader": { - "version": "0.28.8", - "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-0.28.8.tgz", - "integrity": "sha512-4jGj7Ag6WUZ5lQyE4te9sJLn0lgkz6HI3WDE4aw98AkW1IAKXPP4blTpPeorlLDpNsYvojo0SYgRJOdz2KbuAw==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/css-loader/-/css-loader-7.1.2.tgz", + "integrity": "sha512-6WvYYn7l/XEGN8Xu2vWFt9nVzrCn39vKyTEFf/ExEyoksJjjSZV/0/35XPlMbpnr6VGhZIUg5yJrL8tGfes/FA==", + "license": "MIT", "dependencies": { - "babel-code-frame": "^6.26.0", - "css-selector-tokenizer": "^0.7.0", - "cssnano": "^3.10.0", - "icss-utils": "^2.1.0", - "loader-utils": "^1.0.2", - "lodash.camelcase": "^4.3.0", - "object-assign": "^4.1.1", - "postcss": "^5.0.6", - "postcss-modules-extract-imports": "^1.1.0", - "postcss-modules-local-by-default": "^1.2.0", - "postcss-modules-scope": "^1.1.0", - "postcss-modules-values": "^1.3.0", - "postcss-value-parser": "^3.3.0", - "source-list-map": "^2.0.0" + "icss-utils": "^5.1.0", + "postcss": "^8.4.33", + "postcss-modules-extract-imports": "^3.1.0", + "postcss-modules-local-by-default": "^4.0.5", + "postcss-modules-scope": "^3.2.0", + "postcss-modules-values": "^4.0.0", + "postcss-value-parser": "^4.2.0", + "semver": "^7.5.4" }, "engines": { - "node": ">=0.12.0 || >= 4.3.0 < 5.0.0 || >=5.10" + "node": ">= 18.12.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "@rspack/core": "0.x || 1.x", + "webpack": "^5.27.0" + }, + "peerDependenciesMeta": { + "@rspack/core": { + "optional": true + }, + "webpack": { + "optional": true + } + } + }, + "node_modules/css-loader/node_modules/icss-utils": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-5.1.0.tgz", + "integrity": "sha512-soFhflCVWLfRNOPU3iv5Z9VUdT44xFRbzjLsEzSr5AQmgqPMTHdU3PMT1Cf1ssx8fLNJDA1juftYl+PUcv3MqA==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/css-loader/node_modules/postcss": { + "version": "8.5.3", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", + "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.8", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/css-loader/node_modules/postcss-modules-extract-imports": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-3.1.0.tgz", + "integrity": "sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==", + "license": "ISC", + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/css-loader/node_modules/postcss-modules-local-by-default": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-4.2.0.tgz", + "integrity": "sha512-5kcJm/zk+GJDSfw+V/42fJ5fhjL5YbFDl8nVdXkJPLLW+Vf9mTD5Xe0wqIaDnLuL2U6cDNpTr+UQ+v2HWIBhzw==", + "license": "MIT", + "dependencies": { + "icss-utils": "^5.0.0", + "postcss-selector-parser": "^7.0.0", + "postcss-value-parser": "^4.1.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/css-loader/node_modules/postcss-modules-scope": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-3.2.1.tgz", + "integrity": "sha512-m9jZstCVaqGjTAuny8MdgE88scJnCiQSlSrOWcTQgM2t32UBe+MUmFSO5t7VMSfAf/FJKImAxBav8ooCHJXCJA==", + "license": "ISC", + "dependencies": { + "postcss-selector-parser": "^7.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/css-loader/node_modules/postcss-modules-values": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-4.0.0.tgz", + "integrity": "sha512-RDxHkAiEGI78gS2ofyvCsu7iycRv7oqw5xMWn9iMoR0N/7mf9D50ecQqUo5BZ9Zh2vH4bCUR/ktCqbB9m8vJjQ==", + "license": "ISC", + "dependencies": { + "icss-utils": "^5.0.0" + }, + "engines": { + "node": "^10 || ^12 || >= 14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/css-loader/node_modules/postcss-selector-parser": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz", + "integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==", + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/css-loader/node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "license": "MIT" + }, + "node_modules/css-loader/node_modules/semver": { + "version": "7.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", + "integrity": "sha512-hlq8tAfn0m/61p4BVRcPzIGr6LKiMwo4VM6dGi6pt4qcRkmNzTcWq6eCEjEh+qXjkMDvPlOFFSGwQjoEa6gyMA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" } }, "node_modules/css-mediaquery": { @@ -7844,31 +7407,6 @@ "resolved": "https://registry.npmjs.org/css-mediaquery/-/css-mediaquery-0.1.2.tgz", "integrity": "sha512-COtn4EROW5dBGlE/4PiKnh6rZpAPxDeFLaEEwt4i10jpDMFt2EhQGS79QmmrO+iKCHv0PU/HrOWEhijFd1x99Q==" }, - "node_modules/css-select": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", - "integrity": "sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==", - "dev": true, - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-selector-tokenizer": { - "version": "0.7.3", - "resolved": "https://registry.npmjs.org/css-selector-tokenizer/-/css-selector-tokenizer-0.7.3.tgz", - "integrity": "sha512-jWQv3oCEL5kMErj4wRnK/OPoBi0D+P1FR2cDCKYPaMeD2eW3/mttav8HT4hT1CKopiJI/psEULjkClhvJo4Lvg==", - "dependencies": { - "cssesc": "^3.0.0", - "fastparse": "^1.1.2" - } - }, "node_modules/css-tree": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-2.3.1.tgz", @@ -7883,17 +7421,10 @@ "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" } }, - "node_modules/css-what": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.1.0.tgz", - "integrity": "sha512-HTUrgRJ7r4dsZKU6GjmpfRK1O76h97Z8MfS1G0FozR+oF2kG6Vfe8JE6zwrkbxigziPHinCJ+gCPjA9EaBDtRw==", - "dev": true, - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==" }, "node_modules/cssesc": { "version": "3.0.0", @@ -7906,72 +7437,11 @@ "node": ">=4" } }, - "node_modules/cssnano": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/cssnano/-/cssnano-3.10.0.tgz", - "integrity": "sha512-0o0IMQE0Ezo4b41Yrm8U6Rp9/Ag81vNXY1gZMnT1XhO4DpjEf2utKERqWJbOoz3g1Wdc1d3QSta/cIuJ1wSTEg==", - "dependencies": { - "autoprefixer": "^6.3.1", - "decamelize": "^1.1.2", - "defined": "^1.0.0", - "has": "^1.0.1", - "object-assign": "^4.0.1", - "postcss": "^5.0.14", - "postcss-calc": "^5.2.0", - "postcss-colormin": "^2.1.8", - "postcss-convert-values": "^2.3.4", - "postcss-discard-comments": "^2.0.4", - "postcss-discard-duplicates": "^2.0.1", - "postcss-discard-empty": "^2.0.1", - "postcss-discard-overridden": "^0.1.1", - "postcss-discard-unused": "^2.2.1", - "postcss-filter-plugins": "^2.0.0", - "postcss-merge-idents": "^2.1.5", - "postcss-merge-longhand": "^2.0.1", - "postcss-merge-rules": "^2.0.3", - "postcss-minify-font-values": "^1.0.2", - "postcss-minify-gradients": "^1.0.1", - "postcss-minify-params": "^1.0.4", - "postcss-minify-selectors": "^2.0.4", - "postcss-normalize-charset": "^1.1.0", - "postcss-normalize-url": "^3.0.7", - "postcss-ordered-values": "^2.1.0", - "postcss-reduce-idents": "^2.2.2", - "postcss-reduce-initial": "^1.0.0", - "postcss-reduce-transforms": "^1.0.3", - "postcss-svgo": "^2.1.1", - "postcss-unique-selectors": "^2.0.2", - "postcss-value-parser": "^3.2.3", - "postcss-zindex": "^2.0.1" - } - }, - "node_modules/csso": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/csso/-/csso-2.3.2.tgz", - "integrity": "sha512-FmCI/hmqDeHHLaIQckMhMZneS84yzUZdrWDAvJVVxOwcKE1P1LF9FGmzr1ktIQSxOw6fl3PaQsmfg+GN+VvR3w==", - "dependencies": { - "clap": "^1.0.9", - "source-map": "^0.5.3" - }, - "bin": { - "csso": "bin/csso" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/csso/node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/cssom": { - "version": "0.4.4", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.4.4.tgz", - "integrity": "sha512-p3pvU7r1MyyqbTk+WbNJIgJjG2VmTIaB10rI93LzVPrmDJKkzKYMtxxyAvQXR/NS6otuzveI7+7BBq3SjBS2mw==" + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.5.0.tgz", + "integrity": "sha512-iKuQcq+NdHqlAcwUY0o/HL69XQrUaQdMjmStJ8JFmUaiiQErlhrmuigkg/CU4E2J0IyUKUrMAgl36TvN67MqTw==", + "license": "MIT" }, "node_modules/cssstyle": { "version": "2.3.0", @@ -7994,18 +7464,6 @@ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==" }, - "node_modules/currently-unhandled": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", - "integrity": "sha512-/fITjgjGU50vjQ4FH6eUoYu+iUoUKIXws2hL15JJpIR+BbTxaXQsMuuyjtNh2WqsSBS5nsaZHFsFecyw5CCAng==", - "dev": true, - "dependencies": { - "array-find-index": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/custom-event": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/custom-event/-/custom-event-1.0.1.tgz", @@ -8030,36 +7488,27 @@ "node": ">=0.12" } }, - "node_modules/damerau-levenshtein": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", - "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", - "dev": true, - "peer": true - }, - "node_modules/dashdash": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", - "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", - "dev": true, + "node_modules/data-urls": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-3.0.2.tgz", + "integrity": "sha512-Jy/tj3ldjZJo63sVAvg6LHt2mHvl4V6AgRAmNDtLdm7faqtsx+aJG42rsyCo9JCoRVKwPFzKlIPx3DIibwSIaQ==", + "license": "MIT", "dependencies": { - "assert-plus": "^1.0.0" + "abab": "^2.0.6", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0" }, "engines": { - "node": ">=0.10" + "node": ">=12" } }, - "node_modules/data-urls": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-2.0.0.tgz", - "integrity": "sha512-X5eWTSXO/BJmpdIKCRuKUgSCgAN0OwliVK3yPKbwIWU1Tdw5BRajxlzMidvh+gwko9AfQ9zIj52pzF91Q3YAvQ==", - "dependencies": { - "abab": "^2.0.3", - "whatwg-mimetype": "^2.3.0", - "whatwg-url": "^8.0.0" - }, + "node_modules/data-urls/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12" } }, "node_modules/data-view-buffer": { @@ -8164,6 +7613,7 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -8207,25 +7657,57 @@ "node": ">=0.10" } }, - "node_modules/deep-equal-ident": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/deep-equal-ident/-/deep-equal-ident-1.1.1.tgz", - "integrity": "sha512-aWv7VhTl/Lju1zenOD3E1w8PpUVrTDbwXCHtbSNr+p/uadr49Y1P1ld0W3Pl6gbvIbiRjoCVsqw70UupCNGh6g==", + "node_modules/dedent": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.3.tgz", + "integrity": "sha512-NHQtfOOW68WD8lgypbLA5oT+Bt0xXJhiYvoR6SmmNXZfpzOGXwdKWmcwG8N7PwVVWV3eF/68nmD9BaJSsTBhyQ==", "dev": true, - "dependencies": { - "lodash.isequal": "^3.0" + "license": "MIT", + "peerDependencies": { + "babel-plugin-macros": "^3.1.0" + }, + "peerDependenciesMeta": { + "babel-plugin-macros": { + "optional": true + } } }, - "node_modules/deep-equal-ident/node_modules/lodash.isequal": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-3.0.4.tgz", - "integrity": "sha512-Bsu5fP9Omd+HBk2Dz8qp4BHbC+83DBykZ87Lz1JmPKTVNy4Q0XQVtUrbfXVAK/udQrWNcGStcKSA9yj/Zkm3TQ==", - "dev": true, + "node_modules/deep-equal": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-2.2.3.tgz", + "integrity": "sha512-ZIwpnevOurS8bpT4192sqAowWM76JDKSHYzMLty3BZGSswgq6pBaH3DhCSW5xVAZICZyKdOBPjwww5wfgT/6PA==", "dependencies": { - "lodash._baseisequal": "^3.0.0", - "lodash._bindcallback": "^3.0.0" + "array-buffer-byte-length": "^1.0.0", + "call-bind": "^1.0.5", + "es-get-iterator": "^1.1.3", + "get-intrinsic": "^1.2.2", + "is-arguments": "^1.1.1", + "is-array-buffer": "^3.0.2", + "is-date-object": "^1.0.5", + "is-regex": "^1.1.4", + "is-shared-array-buffer": "^1.0.2", + "isarray": "^2.0.5", + "object-is": "^1.1.5", + "object-keys": "^1.1.1", + "object.assign": "^4.1.4", + "regexp.prototype.flags": "^1.5.1", + "side-channel": "^1.0.4", + "which-boxed-primitive": "^1.0.2", + "which-collection": "^1.0.1", + "which-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/deep-equal/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + }, "node_modules/deep-extend": { "version": "0.6.0", "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", @@ -8245,6 +7727,7 @@ "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -8303,14 +7786,6 @@ "node": ">=0.10.0" } }, - "node_modules/defined": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/defined/-/defined-1.0.1.tgz", - "integrity": "sha512-hsBd2qSVCRE+5PmNdHt1uzyrFu5d3RwmFDKzyNZMFq/EwDNJF7Ee5+D5oEKF0hU6LhtoUF1macFvOe4AskQC1Q==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -8346,11 +7821,25 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "license": "Apache-2.0", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", "integrity": "sha512-TLz+x/vEXm/Y7P7wn1EJFNLxYpUD4TgMosxY6fAVJUnJMbupHBOncxyWUG9OpTaH9EBD7uFI5LfEgmMOc54DsA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -8367,21 +7856,23 @@ "dev": true }, "node_modules/diff": { - "version": "3.5.0", - "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", - "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-7.0.0.tgz", + "integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==", "dev": true, + "license": "BSD-3-Clause", "engines": { "node": ">=0.3.1" } }, "node_modules/diff-sequences": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-26.6.2.tgz", - "integrity": "sha512-Mv/TDa3nZ9sbc5soK+OoA74BsS3mL37yixCvUAQkiuA4Wz6YtwP/K47n2rv2ovzHZvoiQeA5FTQOschKkEwB0Q==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-29.6.3.tgz", + "integrity": "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==", "dev": true, + "license": "MIT", "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/dir-glob": { @@ -8396,24 +7887,10 @@ "node": ">=8" } }, - "node_modules/discontinuous-range": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", - "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", - "dev": true - }, - "node_modules/doctrine": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", - "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, - "peer": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=6.0.0" - } + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==" }, "node_modules/dom-helpers": { "version": "3.4.0", @@ -8435,20 +7912,6 @@ "void-elements": "^2.0.0" } }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, "node_modules/domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", @@ -8461,52 +7924,30 @@ ] }, "node_modules/domexception": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-2.0.1.tgz", - "integrity": "sha512-yxJ2mFy/sibVQlu5qHjOkf9J3K6zgmCxgJ94u2EdvDOV09H+32LtRswEcUsmUWN72pVLOEnTSRaIVVzVQgS0dg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/domexception/-/domexception-4.0.0.tgz", + "integrity": "sha512-A2is4PLG+eeSfoTMA95/s4pvAoSo2mKtiM5jlHkAVewmiO8ISFTFKZjH7UAM1Atli/OT/7JHOrJRJiMKUZKYBw==", "deprecated": "Use your platform's native DOMException instead", + "license": "MIT", "dependencies": { - "webidl-conversions": "^5.0.0" + "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=8" + "node": ">=12" } }, - "node_modules/domexception/node_modules/webidl-conversions": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-5.0.0.tgz", - "integrity": "sha512-VlZwKPCkYKxQgeSbH5EyngOmRp7Ww7I9rQLERETtf5ofd9pGeswWiOtogpEO850jziPRarreGxn5QIiTqpb2wA==", - "engines": { - "node": ">=8" - } - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", "dependencies": { - "domelementtype": "^2.3.0" + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" }, "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.1.0.tgz", - "integrity": "sha512-H78uMmQtI2AhgDJjWeQmHwJJ2bLPD3GMmO7Zja/ZZh84wkm+4ut+IUnUdRa8uCGX88DiVx1j6FRe1XfxEgjEZA==", - "dev": true, - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" + "node": ">= 0.4" } }, "node_modules/eastasianwidth": { @@ -8514,22 +7955,6 @@ "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==" }, - "node_modules/ecc-jsbn": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", - "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", - "dev": true, - "dependencies": { - "jsbn": "~0.1.0", - "safer-buffer": "^2.1.0" - } - }, - "node_modules/ecc-jsbn/node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", - "dev": true - }, "node_modules/edx-proctoring-proctortrack": { "version": "1.1.1", "resolved": "git+https://git@github.com/anupdhabarde/edx-proctoring-proctortrack.git#f0fa9edbd16aa5af5a41ac309d2609e529ea8732", @@ -8540,9 +7965,9 @@ } }, "node_modules/edx-ui-toolkit": { - "version": "1.8.6", - "resolved": "https://registry.npmjs.org/edx-ui-toolkit/-/edx-ui-toolkit-1.8.6.tgz", - "integrity": "sha512-EkxjGLAUoXOs2cRZ6czSaywLofrZB8RXii943lGjfZsZAY0n0sNTZgAaGlKIxcPgiZYJppc79PKXGbYhRz9CNg==", + "version": "1.8.7", + "resolved": "https://registry.npmjs.org/edx-ui-toolkit/-/edx-ui-toolkit-1.8.7.tgz", + "integrity": "sha512-2GF463dcfKu9u0wFY1u5fX6CUs5V3VQjcTQKt1tLzFzGoK1oGg/lRp0SYsbdcIAzCT8yQAFyrpvAQh1oGuTkiA==", "license": "Apache-2.0", "dependencies": { "backbone": "1.6.0", @@ -8581,6 +8006,18 @@ "integrity": "sha512-YYp8cqz7/8eruZ15L1mzcPkvLYxipfdsWIDESvNdNmQP9o7TsDitRhNuV2xb7aFu2ofZngao1jiVrVZ842x4BQ==", "license": "BSD-3-Clause" }, + "node_modules/edx-ui-toolkit/node_modules/moment-timezone": { + "version": "0.5.46", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.46.tgz", + "integrity": "sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw==", + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/edx-ui-toolkit/node_modules/requirejs": { "version": "2.1.22", "resolved": "https://registry.npmjs.org/requirejs/-/requirejs-2.1.22.tgz", @@ -8627,9 +8064,9 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.5.6", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.6.tgz", - "integrity": "sha512-jwXWsM5RPf6j9dPYzaorcBSUg6AiqocPEyMpkchkvntaH9HGfOOMZwxMJjDY/XEs3T5dM7uyH1VhRMkqUU9qVw==", + "version": "1.5.63", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.63.tgz", + "integrity": "sha512-ddeXKuY9BHo/mw145axlyWjlJ1UBt4WK3AlvkT7W2AbqfRQoacVoRUCF6wL3uIx/8wT9oLKXzI+rFqHHscByaA==", "license": "ISC" }, "node_modules/email-prop-type": { @@ -8649,12 +8086,13 @@ } }, "node_modules/emittery": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.7.2.tgz", - "integrity": "sha512-A8OG5SR/ij3SsJdWDJdkkSYUjQdCUx6APQXem0SaEePBSRg4eymGYwBkKo1Y6DU+af/Jn2dBQqDBvjnr9Vi8nQ==", + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", + "integrity": "sha512-DeWwawk6r5yR9jFgnDKYt4sLS0LmHJJi3ZOnb5/JdbYwj3nW+FxQnHIjhBKz8YLC7oRNPVM9NQ47I3CVx34eqQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=12" }, "funding": { "url": "https://github.com/sindresorhus/emittery?sponsor=1" @@ -8686,6 +8124,8 @@ "version": "0.1.13", "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", + "license": "MIT", + "optional": true, "dependencies": { "iconv-lite": "^0.6.2" } @@ -8694,6 +8134,8 @@ "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -8701,15 +8143,6 @@ "node": ">=0.10.0" } }, - "node_modules/end-of-stream": { - "version": "1.4.4", - "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", - "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", - "dev": true, - "dependencies": { - "once": "^1.4.0" - } - }, "node_modules/engine.io": { "version": "1.8.5", "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-1.8.5.tgz", @@ -8808,20 +8241,6 @@ "ultron": "1.0.x" } }, - "node_modules/enhanced-resolve": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-0.9.1.tgz", - "integrity": "sha512-kxpoMgrdtkXZ5h0SeraBS1iRntpTpQ3R8ussdb38+UAFnMGX5DDyJXePm+OCHOcoXvHDw7mc2erbJBpDnl7TPw==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.1.2", - "memory-fs": "^0.2.0", - "tapable": "^0.1.8" - }, - "engines": { - "node": ">=0.6" - } - }, "node_modules/enquire.js": { "version": "2.1.6", "resolved": "https://registry.npmjs.org/enquire.js/-/enquire.js-2.1.6.tgz", @@ -8837,7 +8256,6 @@ "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, "engines": { "node": ">=0.12" }, @@ -8878,166 +8296,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/enzyme": { - "version": "3.11.0", - "resolved": "https://registry.npmjs.org/enzyme/-/enzyme-3.11.0.tgz", - "integrity": "sha512-Dw8/Gs4vRjxY6/6i9wU0V+utmQO9kvh9XLnz3LIudviOnVYDEe2ec+0k+NQoMamn1VrjKgCUOWj5jG/5M5M0Qw==", - "dev": true, - "dependencies": { - "array.prototype.flat": "^1.2.3", - "cheerio": "^1.0.0-rc.3", - "enzyme-shallow-equal": "^1.0.1", - "function.prototype.name": "^1.1.2", - "has": "^1.0.3", - "html-element-map": "^1.2.0", - "is-boolean-object": "^1.0.1", - "is-callable": "^1.1.5", - "is-number-object": "^1.0.4", - "is-regex": "^1.0.5", - "is-string": "^1.0.5", - "is-subset": "^0.1.1", - "lodash.escape": "^4.0.1", - "lodash.isequal": "^4.5.0", - "object-inspect": "^1.7.0", - "object-is": "^1.0.2", - "object.assign": "^4.1.0", - "object.entries": "^1.1.1", - "object.values": "^1.1.1", - "raf": "^3.4.1", - "rst-selector-parser": "^2.2.3", - "string.prototype.trim": "^1.2.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/enzyme-adapter-react-16": { - "version": "1.15.8", - "resolved": "https://registry.npmjs.org/enzyme-adapter-react-16/-/enzyme-adapter-react-16-1.15.8.tgz", - "integrity": "sha512-uYGC31eGZBp5nGsr4nKhZKvxGQjyHGjS06BJsUlWgE29/hvnpgCsT1BJvnnyny7N3GIIVyxZ4O9GChr6hy2WQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "enzyme-adapter-utils": "^1.14.2", - "enzyme-shallow-equal": "^1.0.7", - "hasown": "^2.0.0", - "object.assign": "^4.1.5", - "object.values": "^1.1.7", - "prop-types": "^15.8.1", - "react-is": "^16.13.1", - "react-test-renderer": "^16.0.0-0", - "semver": "^5.7.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - }, - "peerDependencies": { - "enzyme": "^3.0.0", - "react": "^16.0.0-0", - "react-dom": "^16.0.0-0" - } - }, - "node_modules/enzyme-adapter-react-16/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/enzyme-adapter-react-16/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/enzyme-adapter-utils": { - "version": "1.14.2", - "resolved": "https://registry.npmjs.org/enzyme-adapter-utils/-/enzyme-adapter-utils-1.14.2.tgz", - "integrity": "sha512-1ZC++RlsYRaiOWE5NRaF5OgsMt7F5rn/VuaJIgc7eW/fmgg8eS1/Ut7EugSPPi7VMdWMLcymRnMF+mJUJ4B8KA==", - "dev": true, - "dependencies": { - "airbnb-prop-types": "^2.16.0", - "function.prototype.name": "^1.1.6", - "hasown": "^2.0.0", - "object.assign": "^4.1.5", - "object.fromentries": "^2.0.7", - "prop-types": "^15.8.1", - "semver": "^6.3.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - }, - "peerDependencies": { - "react": "0.13.x || 0.14.x || ^15.0.0-0 || ^16.0.0-0" - } - }, - "node_modules/enzyme-adapter-utils/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/enzyme-matchers": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/enzyme-matchers/-/enzyme-matchers-6.1.2.tgz", - "integrity": "sha512-cP9p+HMOZ1ZXQ+k2H4dCkxmTZzIvpEy5zv0ZjgoBl6D0U43v+bJGH5IeWHdIovCzgJ0dVcMCKJ6lNu83lYUCAA==", - "dev": true, - "dependencies": { - "circular-json-es6": "^2.0.1", - "deep-equal-ident": "^1.1.1" - }, - "peerDependencies": { - "enzyme": "3.x" - } - }, - "node_modules/enzyme-shallow-equal": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/enzyme-shallow-equal/-/enzyme-shallow-equal-1.0.7.tgz", - "integrity": "sha512-/um0GFqUXnpM9SvKtje+9Tjoz3f1fpBC3eXRFrNs8kpYn69JljciYP7KZTqM/YQbUY9KUjvKB4jo/q+L6WGGvg==", - "dev": true, - "dependencies": { - "hasown": "^2.0.0", - "object-is": "^1.1.5" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/enzyme-to-json": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/enzyme-to-json/-/enzyme-to-json-3.6.2.tgz", - "integrity": "sha512-Ynm6Z6R6iwQ0g2g1YToz6DWhxVnt8Dy1ijR2zynRKxTyBGA8rCDXU3rs2Qc4OKvUvc2Qoe1bcFK6bnPs20TrTg==", - "dev": true, - "dependencies": { - "@types/cheerio": "^0.22.22", - "lodash": "^4.17.21", - "react-is": "^16.12.0" - }, - "engines": { - "node": ">=6.0.0" - }, - "peerDependencies": { - "enzyme": "^3.4.0" - } - }, "node_modules/err-code": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==" + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", + "license": "MIT" }, "node_modules/error-ex": { "version": "1.3.2", @@ -9107,19 +8370,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/es-array-method-boxes-properly": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz", - "integrity": "sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA==", - "dev": true - }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", "engines": { "node": ">= 0.4" } @@ -9132,32 +8387,30 @@ "node": ">= 0.4" } }, - "node_modules/es-iterator-helpers": { - "version": "1.0.18", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.18.tgz", - "integrity": "sha512-scxAJaewsahbqTYrGKJihhViaM6DDZDDoucfvzNbK0pOren1g/daDQ3IAhzn+1G14rBG7w+i5N+qul60++zlKA==", - "dev": true, - "peer": true, + "node_modules/es-get-iterator": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/es-get-iterator/-/es-get-iterator-1.1.3.tgz", + "integrity": "sha512-sPZmqHBe6JIiTfN5q2pEi//TwxmAFHwj/XEuYjTuse78i8KxaqMTTzxPoFKuzRpDpTJ+0NAbpfenkmH2rePtuw==", "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.0", - "es-errors": "^1.3.0", - "es-set-tostringtag": "^2.0.3", - "function-bind": "^1.1.2", - "get-intrinsic": "^1.2.4", - "globalthis": "^1.0.3", - "has-property-descriptors": "^1.0.2", - "has-proto": "^1.0.3", + "call-bind": "^1.0.2", + "get-intrinsic": "^1.1.3", "has-symbols": "^1.0.3", - "internal-slot": "^1.0.7", - "iterator.prototype": "^1.1.2", - "safe-array-concat": "^1.1.2" + "is-arguments": "^1.1.1", + "is-map": "^2.0.2", + "is-set": "^2.0.2", + "is-string": "^1.0.7", + "isarray": "^2.0.5", + "stop-iteration-iterator": "^1.0.0" }, - "engines": { - "node": ">= 0.4" + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/es-get-iterator/node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" + }, "node_modules/es-module-lexer": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.0.tgz", @@ -9295,9 +8548,9 @@ } }, "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "license": "MIT", "engines": { "node": ">=6" @@ -9398,643 +8651,6 @@ "node": ">=4.0" } }, - "node_modules/eslint": { - "version": "8.57.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-8.57.0.tgz", - "integrity": "sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==", - "dev": true, - "peer": true, - "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", - "@eslint-community/regexpp": "^4.6.1", - "@eslint/eslintrc": "^2.1.4", - "@eslint/js": "8.57.0", - "@humanwhocodes/config-array": "^0.11.14", - "@humanwhocodes/module-importer": "^1.0.1", - "@nodelib/fs.walk": "^1.2.8", - "@ungap/structured-clone": "^1.2.0", - "ajv": "^6.12.4", - "chalk": "^4.0.0", - "cross-spawn": "^7.0.2", - "debug": "^4.3.2", - "doctrine": "^3.0.0", - "escape-string-regexp": "^4.0.0", - "eslint-scope": "^7.2.2", - "eslint-visitor-keys": "^3.4.3", - "espree": "^9.6.1", - "esquery": "^1.4.2", - "esutils": "^2.0.2", - "fast-deep-equal": "^3.1.3", - "file-entry-cache": "^6.0.1", - "find-up": "^5.0.0", - "glob-parent": "^6.0.2", - "globals": "^13.19.0", - "graphemer": "^1.4.0", - "ignore": "^5.2.0", - "imurmurhash": "^0.1.4", - "is-glob": "^4.0.0", - "is-path-inside": "^3.0.3", - "js-yaml": "^4.1.0", - "json-stable-stringify-without-jsonify": "^1.0.1", - "levn": "^0.4.1", - "lodash.merge": "^4.6.2", - "minimatch": "^3.1.2", - "natural-compare": "^1.4.0", - "optionator": "^0.9.3", - "strip-ansi": "^6.0.1", - "text-table": "^0.2.0" - }, - "bin": { - "eslint": "bin/eslint.js" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-config-airbnb": { - "version": "19.0.4", - "resolved": "https://registry.npmjs.org/eslint-config-airbnb/-/eslint-config-airbnb-19.0.4.tgz", - "integrity": "sha512-T75QYQVQX57jiNgpF9r1KegMICE94VYwoFQyMGhrvc+lB8YF2E/M/PYDaQe1AJcWaEgqLE+ErXV1Og/+6Vyzew==", - "dev": true, - "peer": true, - "dependencies": { - "eslint-config-airbnb-base": "^15.0.0", - "object.assign": "^4.1.2", - "object.entries": "^1.1.5" - }, - "engines": { - "node": "^10.12.0 || ^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "peerDependencies": { - "eslint": "^7.32.0 || ^8.2.0", - "eslint-plugin-import": "^2.25.3", - "eslint-plugin-jsx-a11y": "^6.5.1", - "eslint-plugin-react": "^7.28.0", - "eslint-plugin-react-hooks": "^4.3.0" - } - }, - "node_modules/eslint-config-airbnb-base": { - "version": "15.0.0", - "resolved": "https://registry.npmjs.org/eslint-config-airbnb-base/-/eslint-config-airbnb-base-15.0.0.tgz", - "integrity": "sha512-xaX3z4ZZIcFLvh2oUNvcX5oEofXda7giYmuplVxoOg5A7EXJMrUyqRgR+mhDhPK8LZ4PttFOBvCYDbX3sUoUig==", - "dev": true, - "peer": true, - "dependencies": { - "confusing-browser-globals": "^1.0.10", - "object.assign": "^4.1.2", - "object.entries": "^1.1.5", - "semver": "^6.3.0" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - }, - "peerDependencies": { - "eslint": "^7.32.0 || ^8.2.0", - "eslint-plugin-import": "^2.25.2" - } - }, - "node_modules/eslint-config-airbnb-typescript": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/eslint-config-airbnb-typescript/-/eslint-config-airbnb-typescript-17.1.0.tgz", - "integrity": "sha512-GPxI5URre6dDpJ0CtcthSZVBAfI+Uw7un5OYNVxP2EYi3H81Jw701yFP7AU+/vCE7xBtFmjge7kfhhk4+RAiig==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "eslint-config-airbnb-base": "^15.0.0" - }, - "peerDependencies": { - "@typescript-eslint/eslint-plugin": "^5.13.0 || ^6.0.0", - "@typescript-eslint/parser": "^5.0.0 || ^6.0.0", - "eslint": "^7.32.0 || ^8.2.0", - "eslint-plugin-import": "^2.25.3" - } - }, - "node_modules/eslint-import-resolver-node": { - "version": "0.3.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.9.tgz", - "integrity": "sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==", - "dev": true, - "peer": true, - "dependencies": { - "debug": "^3.2.7", - "is-core-module": "^2.13.0", - "resolve": "^1.22.4" - } - }, - "node_modules/eslint-import-resolver-node/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "peer": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-import-resolver-webpack": { - "version": "0.13.9", - "resolved": "https://registry.npmjs.org/eslint-import-resolver-webpack/-/eslint-import-resolver-webpack-0.13.9.tgz", - "integrity": "sha512-yGngeefNiHXau2yzKKs2BNON4HLpxBabY40BGL/vUSKZtqzjlVsTTZm57jhHULhm+mJEwKsEIIN3NXup5AiiBQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "debug": "^3.2.7", - "enhanced-resolve": "^0.9.1", - "find-root": "^1.1.0", - "hasown": "^2.0.0", - "interpret": "^1.4.0", - "is-core-module": "^2.13.1", - "is-regex": "^1.1.4", - "lodash": "^4.17.21", - "resolve": "^2.0.0-next.5", - "semver": "^5.7.2" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "eslint-plugin-import": ">=1.4.0", - "webpack": ">=1.11.0" - } - }, - "node_modules/eslint-import-resolver-webpack/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-import-resolver-webpack/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", - "dev": true, - "license": "MIT", - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/eslint-import-resolver-webpack/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/eslint-module-utils": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.8.1.tgz", - "integrity": "sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==", - "dev": true, - "peer": true, - "dependencies": { - "debug": "^3.2.7" - }, - "engines": { - "node": ">=4" - }, - "peerDependenciesMeta": { - "eslint": { - "optional": true - } - } - }, - "node_modules/eslint-module-utils/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "peer": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import": { - "version": "2.29.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.29.1.tgz", - "integrity": "sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==", - "dev": true, - "peer": true, - "dependencies": { - "array-includes": "^3.1.7", - "array.prototype.findlastindex": "^1.2.3", - "array.prototype.flat": "^1.3.2", - "array.prototype.flatmap": "^1.3.2", - "debug": "^3.2.7", - "doctrine": "^2.1.0", - "eslint-import-resolver-node": "^0.3.9", - "eslint-module-utils": "^2.8.0", - "hasown": "^2.0.0", - "is-core-module": "^2.13.1", - "is-glob": "^4.0.3", - "minimatch": "^3.1.2", - "object.fromentries": "^2.0.7", - "object.groupby": "^1.0.1", - "object.values": "^1.1.7", - "semver": "^6.3.1", - "tsconfig-paths": "^3.15.0" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8" - } - }, - "node_modules/eslint-plugin-import/node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, - "peer": true, - "dependencies": { - "ms": "^2.1.1" - } - }, - "node_modules/eslint-plugin-import/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "peer": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-plugin-jsx-a11y": { - "version": "6.8.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.8.0.tgz", - "integrity": "sha512-Hdh937BS3KdwwbBaKd5+PLCOmYY6U4f2h9Z2ktwtNKvIdIEu137rjYbcb9ApSbVJfWxANNuiKTD/9tOKjK9qOA==", - "dev": true, - "peer": true, - "dependencies": { - "@babel/runtime": "^7.23.2", - "aria-query": "^5.3.0", - "array-includes": "^3.1.7", - "array.prototype.flatmap": "^1.3.2", - "ast-types-flow": "^0.0.8", - "axe-core": "=4.7.0", - "axobject-query": "^3.2.1", - "damerau-levenshtein": "^1.0.8", - "emoji-regex": "^9.2.2", - "es-iterator-helpers": "^1.0.15", - "hasown": "^2.0.0", - "jsx-ast-utils": "^3.3.5", - "language-tags": "^1.0.9", - "minimatch": "^3.1.2", - "object.entries": "^1.1.7", - "object.fromentries": "^2.0.7" - }, - "engines": { - "node": ">=4.0" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/eslint-plugin-react": { - "version": "7.34.1", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.34.1.tgz", - "integrity": "sha512-N97CxlouPT1AHt8Jn0mhhN2RrADlUAsk1/atcT2KyA/l9Q/E6ll7OIGwNumFmWfZ9skV3XXccYS19h80rHtgkw==", - "dev": true, - "peer": true, - "dependencies": { - "array-includes": "^3.1.7", - "array.prototype.findlast": "^1.2.4", - "array.prototype.flatmap": "^1.3.2", - "array.prototype.toreversed": "^1.1.2", - "array.prototype.tosorted": "^1.1.3", - "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.0.17", - "estraverse": "^5.3.0", - "jsx-ast-utils": "^2.4.1 || ^3.0.0", - "minimatch": "^3.1.2", - "object.entries": "^1.1.7", - "object.fromentries": "^2.0.7", - "object.hasown": "^1.1.3", - "object.values": "^1.1.7", - "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.5", - "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.10" - }, - "engines": { - "node": ">=4" - }, - "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" - } - }, - "node_modules/eslint-plugin-react-hooks": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.0.tgz", - "integrity": "sha512-oFc7Itz9Qxh2x4gNHStv3BqJq54ExXmfC+a1NjAta66IAN87Wu0R/QArgIS9qKzX3dXKPI9H5crl9QchNMY9+g==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "peerDependencies": { - "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0" - } - }, - "node_modules/eslint-plugin-react/node_modules/doctrine": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", - "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", - "dev": true, - "peer": true, - "dependencies": { - "esutils": "^2.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/eslint-plugin-react/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "peer": true, - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/eslint-plugin-react/node_modules/resolve": { - "version": "2.0.0-next.5", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz", - "integrity": "sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==", - "dev": true, - "peer": true, - "dependencies": { - "is-core-module": "^2.13.0", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/eslint-scope": { - "version": "7.2.2", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-7.2.2.tgz", - "integrity": "sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==", - "dev": true, - "peer": true, - "dependencies": { - "esrecurse": "^4.3.0", - "estraverse": "^5.2.0" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint-visitor-keys": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", - "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", - "dev": true, - "peer": true, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/eslint/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "peer": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/eslint/node_modules/argparse": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", - "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, - "peer": true - }, - "node_modules/eslint/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "peer": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/eslint/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "peer": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/eslint/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, - "peer": true - }, - "node_modules/eslint/node_modules/escape-string-regexp": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", - "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/find-up": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", - "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, - "peer": true, - "dependencies": { - "locate-path": "^6.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/globals": { - "version": "13.24.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz", - "integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==", - "dev": true, - "peer": true, - "dependencies": { - "type-fest": "^0.20.2" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint/node_modules/js-yaml": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", - "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, - "peer": true, - "dependencies": { - "argparse": "^2.0.1" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, - "node_modules/eslint/node_modules/locate-path": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", - "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, - "peer": true, - "dependencies": { - "p-locate": "^5.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/p-limit": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", - "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, - "peer": true, - "dependencies": { - "yocto-queue": "^0.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/p-locate": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", - "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, - "peer": true, - "dependencies": { - "p-limit": "^3.0.2" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/eslint/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "peer": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/eslint/node_modules/type-fest": { - "version": "0.20.2", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.20.2.tgz", - "integrity": "sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/esniff": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", @@ -10050,24 +8666,6 @@ "node": ">=0.10" } }, - "node_modules/espree": { - "version": "9.6.1", - "resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz", - "integrity": "sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==", - "dev": true, - "peer": true, - "dependencies": { - "acorn": "^8.9.0", - "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^3.4.1" - }, - "engines": { - "node": "^12.22.0 || ^14.17.0 || >=16.0.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, "node_modules/esprima": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", @@ -10080,19 +8678,6 @@ "node": ">=4" } }, - "node_modules/esquery": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.5.0.tgz", - "integrity": "sha512-YQLXUplAwJgCydQ78IMJywZCceoqk1oH01OERdSAJc/7U2AylwjhSCLDEtqwg811idIS/9fIU5GjG73IgjKMVg==", - "dev": true, - "peer": true, - "dependencies": { - "estraverse": "^5.1.0" - }, - "engines": { - "node": ">=0.10" - } - }, "node_modules/esrecurse": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", @@ -10133,8 +8718,7 @@ "node_modules/eventemitter3": { "version": "4.0.7", "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", - "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", - "dev": true + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==" }, "node_modules/events": { "version": "3.3.0", @@ -10144,26 +8728,21 @@ "node": ">=0.8.x" } }, - "node_modules/exec-sh": { - "version": "0.3.6", - "resolved": "https://registry.npmjs.org/exec-sh/-/exec-sh-0.3.6.tgz", - "integrity": "sha512-nQn+hI3yp+oD0huYhKwvYI32+JFeq+XkNcD1GAo3Y/MjxsfVGmrrzrnzjWiNY6f+pUCP440fThsFh5gZrRAU/w==", - "dev": true - }, "node_modules/execa": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-4.1.0.tgz", - "integrity": "sha512-j5W0//W7f8UxAn8hXVnwG8tLwdiUy4FJLcSupCg6maBYZDpyBvTApK7KyuI4bKj8KOh1r2YH+6ucuYtJv1bTZA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", + "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", "dev": true, + "license": "MIT", "dependencies": { - "cross-spawn": "^7.0.0", - "get-stream": "^5.0.0", - "human-signals": "^1.1.1", + "cross-spawn": "^7.0.3", + "get-stream": "^6.0.0", + "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", - "npm-run-path": "^4.0.0", - "onetime": "^5.1.0", - "signal-exit": "^3.0.2", + "npm-run-path": "^4.0.1", + "onetime": "^5.1.2", + "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" }, "engines": { @@ -10231,55 +8810,22 @@ } }, "node_modules/expect": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/expect/-/expect-26.6.2.tgz", - "integrity": "sha512-9/hlOBkQl2l/PLHJx6JjoDF6xPKcJEsUlWKb23rKE7KzeDqUZKXKNMW27KIue5JMdBV9HgmoJPcc8HtO85t9IA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/expect/-/expect-29.7.0.tgz", + "integrity": "sha512-2Zks0hf1VLFYI1kbh0I5jP3KHHyCHpkfyHBzsSXRFgl/Bg9mWYfMW8oD+PdMPlEwy5HNsR9JutYy6pMeOh61nw==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^26.6.2", - "ansi-styles": "^4.0.0", - "jest-get-type": "^26.3.0", - "jest-matcher-utils": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-regex-util": "^26.0.0" + "@jest/expect-utils": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, - "node_modules/expect/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/expect/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/expect/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, "node_modules/exponential-backoff": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", @@ -10380,15 +8926,6 @@ "node": ">=0.10.0" } }, - "node_modules/extsprintf": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", - "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", - "dev": true, - "engines": [ - "node >=0.6.0" - ] - }, "node_modules/fancy-log": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.3.3.tgz", @@ -10457,11 +8994,6 @@ "node": ">= 4.9.1" } }, - "node_modules/fastparse": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/fastparse/-/fastparse-1.1.2.tgz", - "integrity": "sha512-483XLLxTVIwWK3QTrMGRqUfUpoOs/0hbQrl2oz4J0pAcm3A3bu84wxTFqGqkJzewCLdME38xJLJAxBABfQT8sQ==" - }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -10480,26 +9012,6 @@ "bser": "2.1.1" } }, - "node_modules/fbjs": { - "version": "0.8.18", - "resolved": "https://registry.npmjs.org/fbjs/-/fbjs-0.8.18.tgz", - "integrity": "sha512-EQaWFK+fEPSoibjNy8IxUtaFOMXcWsY0JaVrQoZR9zC8N2Ygf9iDITPWjUTVIax95b6I742JFLqASHfsag/vKA==", - "dependencies": { - "core-js": "^1.0.0", - "isomorphic-fetch": "^2.1.1", - "loose-envify": "^1.0.0", - "object-assign": "^4.1.0", - "promise": "^7.1.1", - "setimmediate": "^1.0.5", - "ua-parser-js": "^0.7.30" - } - }, - "node_modules/fbjs/node_modules/core-js": { - "version": "1.2.7", - "resolved": "https://registry.npmjs.org/core-js/-/core-js-1.2.7.tgz", - "integrity": "sha512-ZiPp9pZlgxpWRu0M+YWbm6+aQ84XEfH1JRXvfOc/fILWI0VKhLC2LX13X1NYq4fULzLMq7Hfh43CSo2/aIaUPA==", - "deprecated": "core-js@<3.23.3 is no longer maintained and not recommended for usage due to the number of issues. Because of the V8 engine whims, feature detection in old core-js versions could cause a slowdown up to 100x even if nothing is polyfilled. Some versions have web compatibility issues. Please, upgrade your dependencies to the actual version of core-js." - }, "node_modules/figures": { "version": "1.7.0", "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", @@ -10513,19 +9025,6 @@ "node": ">=0.10.0" } }, - "node_modules/file-entry-cache": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", - "integrity": "sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==", - "dev": true, - "peer": true, - "dependencies": { - "flat-cache": "^3.0.4" - }, - "engines": { - "node": "^10.12.0 || >=12.0.0" - } - }, "node_modules/file-loader": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/file-loader/-/file-loader-6.2.0.tgz", @@ -10575,13 +9074,6 @@ "url": "https://opencollective.com/webpack" } }, - "node_modules/file-uri-to-path": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", - "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", - "dev": true, - "optional": true - }, "node_modules/filename-regex": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/filename-regex/-/filename-regex-2.0.1.tgz", @@ -10700,13 +9192,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/find-root": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/find-root/-/find-root-1.1.0.tgz", - "integrity": "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==", - "dev": true, - "license": "MIT" - }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -10749,12 +9234,6 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, - "node_modules/flatten": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/flatten/-/flatten-1.0.3.tgz", - "integrity": "sha512-dVsPA/UwQ8+2uoFe5GHtiBMu48dWLTdsuEd7CKGlZlD78r1TTWBvDuFaFGKCo/ZfEr95Uk56vZoX86OsHkUeIg==", - "deprecated": "flatten is deprecated in favor of utility frameworks such as lodash." - }, "node_modules/focus-lock": { "version": "0.6.8", "resolved": "https://registry.npmjs.org/focus-lock/-/focus-lock-0.6.8.tgz", @@ -10843,25 +9322,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/forever-agent": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", - "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", - "dev": true, - "engines": { - "node": "*" - } - }, - "node_modules/formatio": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/formatio/-/formatio-1.2.0.tgz", - "integrity": "sha512-YAF05v8+XCxAyHOdiiAmHdgCVPrWO8X744fYIPtBciIorh5LndWfi1gjeJ16sTbJhzek9kd+j3YByhohtz5Wmg==", - "deprecated": "This package is unmaintained. Use @sinonjs/formatio instead", - "dev": true, - "dependencies": { - "samsam": "1.x" - } - }, "node_modules/fragment-cache": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", @@ -10874,18 +9334,6 @@ "node": ">=0.10.0" } }, - "node_modules/fs-access": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/fs-access/-/fs-access-1.0.1.tgz", - "integrity": "sha512-05cXDIwNbFaoFWaz5gNHlUTbH5whiss/hr/ibzPd4MH3cR4w0ZKeIPiVdbyJurg3O5r/Bjpvn9KOb1/rPMf3nA==", - "dev": true, - "dependencies": { - "null-check": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/fs-extra": { "version": "0.30.0", "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-0.30.0.tgz", @@ -10915,6 +9363,7 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", + "license": "ISC", "dependencies": { "minipass": "^7.0.3" }, @@ -10991,6 +9440,7 @@ "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", "dev": true, + "license": "ISC", "engines": { "node": "6.* || 8.* || >= 10.*" } @@ -11009,15 +9459,21 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.7.tgz", + "integrity": "sha512-VW6Pxhsrk0KAOqs3WEd0klDiF/+V7gQOpAvY1jVU/LHmaD/kQO4523aiJuikX/QAKYiW6x8Jh+RJej1almdtCA==", + "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.0", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -11043,25 +9499,27 @@ "node": ">=8.0.0" } }, - "node_modules/get-stdin": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", - "integrity": "sha512-F5aQMywwJ2n85s4hJPTT9RPxGmubonuB10MNYo17/xph174n2MIR33HRguhzVag10O/npM7SPk73LMZNP+FaWw==", - "dev": true, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, "node_modules/get-stream": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-5.2.0.tgz", - "integrity": "sha512-nBF+F1rAZVCu/p7rjzgA+Yb4lfYXrpl7a6VmJrU8wF9I1CKvP/QwPNZHnOlwbTkY6dvtFIzFMSyQXbLoTQPRpA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", + "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", "dev": true, - "dependencies": { - "pump": "^3.0.0" - }, + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=10" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -11092,15 +9550,6 @@ "node": ">=0.10.0" } }, - "node_modules/getpass": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", - "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", - "dev": true, - "dependencies": { - "assert-plus": "^1.0.0" - } - }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -11163,19 +9612,6 @@ "node": ">=0.10.0" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "peer": true, - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/glob-to-regexp": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", @@ -11268,11 +9704,12 @@ "dev": true }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", - "dependencies": { - "get-intrinsic": "^1.1.3" + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -11283,19 +9720,13 @@ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" }, - "node_modules/graphemer": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/graphemer/-/graphemer-1.4.0.tgz", - "integrity": "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==", - "dev": true, - "peer": true - }, "node_modules/growly": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/growly/-/growly-1.3.0.tgz", "integrity": "sha512-+xGQY0YyAWCnqy7Cd++hc2JqMYzlm0dG30Jd0beaA64sROr8C4nt8Yc9V5Ro3avlSUDTN0ulqP/VBKi1/lLygw==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "node_modules/gulp-shell": { "version": "0.8.0", @@ -11387,63 +9818,6 @@ "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", "license": "0BSD" }, - "node_modules/handlebars": { - "version": "4.7.8", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz", - "integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==", - "dev": true, - "dependencies": { - "minimist": "^1.2.5", - "neo-async": "^2.6.2", - "source-map": "^0.6.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "handlebars": "bin/handlebars" - }, - "engines": { - "node": ">=0.4.7" - }, - "optionalDependencies": { - "uglify-js": "^3.1.4" - } - }, - "node_modules/handlebars/node_modules/uglify-js": { - "version": "3.17.4", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.17.4.tgz", - "integrity": "sha512-T9q82TJI9e/C1TAxYvfb16xO120tMVFZrGA3f9/P4424DNu6ypK103y0GPFVa17yotwSyZW5iYXgjYHkGrJW/g==", - "dev": true, - "optional": true, - "bin": { - "uglifyjs": "bin/uglifyjs" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/har-schema": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", - "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/har-validator": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", - "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", - "deprecated": "this library is no longer supported", - "dev": true, - "dependencies": { - "ajv": "^6.12.3", - "har-schema": "^2.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/hard-rejection": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/hard-rejection/-/hard-rejection-2.1.0.tgz", @@ -11534,9 +9908,10 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", "engines": { "node": ">= 0.4" }, @@ -11655,10 +10030,13 @@ } }, "node_modules/hls.js": { - "version": "1.5.17", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.5.17.tgz", - "integrity": "sha512-wA66nnYFvQa1o4DO/BFgLNRKnBTVXpNeldGRBJ2Y0SvFtdwvFKCbqa9zhHoZLoxHhZ+jYsj3aIBkWQQCPNOhMw==", - "license": "Apache-2.0" + "version": "0.14.17", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-0.14.17.tgz", + "integrity": "sha512-25A7+m6qqp6UVkuzUQ//VVh2EEOPYlOBg32ypr34bcPO7liBMOkKFvbjbCBfiPAOTA/7BSx1Dujft3Th57WyFg==", + "dependencies": { + "eventemitter3": "^4.0.3", + "url-toolkit": "^2.1.6" + } }, "node_modules/hoist-non-react-statics": { "version": "3.3.2", @@ -11668,46 +10046,48 @@ "react-is": "^16.7.0" } }, - "node_modules/hosted-git-info": { - "version": "2.8.9", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.8.9.tgz", - "integrity": "sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==", - "dev": true - }, - "node_modules/html-comment-regex": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/html-comment-regex/-/html-comment-regex-1.1.2.tgz", - "integrity": "sha512-P+M65QY2JQ5Y0G9KKdlDpo0zK+/OHptU5AaBwUfAIDJZk1MYf32Frm84EcOytfJE0t5JvkAnKlmjsXDnWzCJmQ==" - }, - "node_modules/html-element-map": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/html-element-map/-/html-element-map-1.3.1.tgz", - "integrity": "sha512-6XMlxrAFX4UEEGxctfFnmrFaaZFNf9i5fNuV5wZ3WWQ4FVaNP1aX1LkX9j2mfEx1NpjeE/rL3nmgEn23GdFmrg==", - "dev": true, - "dependencies": { - "array.prototype.filter": "^1.0.0", - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/html-encoding-sniffer": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-2.0.1.tgz", - "integrity": "sha512-D5JbOMBIR/TVZkubHT+OyT2705QvogUW4IBn6nHd756OwieSF9aDYFj4dv6HHEVGYbHaLETa3WggZYWWMyy3ZQ==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-3.0.0.tgz", + "integrity": "sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==", + "license": "MIT", "dependencies": { - "whatwg-encoding": "^1.0.5" + "whatwg-encoding": "^2.0.0" }, "engines": { - "node": ">=10" + "node": ">=12" + } + }, + "node_modules/html-encoding-sniffer/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/html-encoding-sniffer/node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" } }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/html-tags": { "version": "3.3.1", @@ -11721,29 +10101,11 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", - "dev": true, - "funding": [ - "https://github.com/fb55/htmlparser2?sponsor=1", - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" - } - }, "node_modules/http-cache-semantics": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==" + "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", + "license": "BSD-2-Clause" }, "node_modules/http-errors": { "version": "2.0.0", @@ -11785,11 +10147,12 @@ } }, "node_modules/http-proxy-agent": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-4.0.1.tgz", - "integrity": "sha512-k0zdNgqWTGA6aeIRVpvfVob4fL52dTfaehylg0Y4UvSySvOq/Y+BOyPrgpUrA7HylqvU8vIZGsRuXmspskV0Tg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", + "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", + "license": "MIT", "dependencies": { - "@tootallnate/once": "1", + "@tootallnate/once": "2", "agent-base": "6", "debug": "4" }, @@ -11797,21 +10160,6 @@ "node": ">= 6" } }, - "node_modules/http-signature": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", - "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", - "dev": true, - "dependencies": { - "assert-plus": "^1.0.0", - "jsprim": "^1.2.2", - "sshpk": "^1.7.0" - }, - "engines": { - "node": ">=0.8", - "npm": ">=1.3.7" - } - }, "node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -11825,12 +10173,13 @@ } }, "node_modules/human-signals": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-1.1.1.tgz", - "integrity": "sha512-SEQu7vl8KjNL2eoGBLF3+wAjpsNfA9XMlXAYj/3EdaNfAlxKthD1xjEQfGOUhllCGGJVNY34bRr6lPINhNjyZw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", + "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", "dev": true, + "license": "Apache-2.0", "engines": { - "node": ">=8.12.0" + "node": ">=10.17.0" } }, "node_modules/hyphenate-style-name": { @@ -11842,6 +10191,7 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -11849,32 +10199,6 @@ "node": ">=0.10.0" } }, - "node_modules/icss-replace-symbols": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz", - "integrity": "sha512-chIaY3Vh2mh2Q3RGXttaDIzeiPvaVXJ+C4DAh/w3c37SKZ/U6PGMmuicR2EQQp9bKG8zLMCl7I+PtIoOOPp8Gg==" - }, - "node_modules/icss-utils": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/icss-utils/-/icss-utils-2.1.0.tgz", - "integrity": "sha512-bsVoyn/1V4R1kYYjLcWLedozAM4FClZUdjE9nIr8uWY7xs78y9DATgwz2wGU7M+7z55KenmmTkN2DVJ7bqzjAA==", - "dependencies": { - "postcss": "^6.0.1" - } - }, - "node_modules/icss-utils/node_modules/postcss": { - "version": "6.0.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", - "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", - "dependencies": { - "chalk": "^2.4.1", - "source-map": "^0.6.1", - "supports-color": "^5.4.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -11892,9 +10216,10 @@ "license": "MIT" }, "node_modules/immutable": { - "version": "4.3.5", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.5.tgz", - "integrity": "sha512-8eabxkth9gZatlwl5TBuJnCsoTADlL6ftEr7A4qgdaTsPyreilDSnUk57SO+jfKcNtxPa22U5KK6DSeAYhpBJw==" + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.2.tgz", + "integrity": "sha512-1NU7hWZDkV7hJ4PJ9dur9gTNQ4ePNPN4k9/0YhwjzykTi/+3Q5pF93YU5QoVj8BuOnhLgaY8gs0U2pj4kSYVcw==", + "license": "MIT" }, "node_modules/import-fresh": { "version": "3.3.0", @@ -11990,11 +10315,6 @@ "node": ">=8" } }, - "node_modules/indexes-of": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/indexes-of/-/indexes-of-1.0.1.tgz", - "integrity": "sha512-bup+4tap3Hympa+JBJUG7XuOsdNQ6fxt0MHyXMKuLBKn0OqsTfvUxkUrroEX1+B2VsSHvCjiIcZVxRtYa4nllA==" - }, "node_modules/indexof": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", @@ -12133,27 +10453,18 @@ } }, "node_modules/internal-slot": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", - "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dependencies": { "es-errors": "^1.3.0", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" + "hasown": "^2.0.2", + "side-channel": "^1.1.0" }, "engines": { "node": ">= 0.4" } }, - "node_modules/interpret": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz", - "integrity": "sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA==", - "dev": true, - "engines": { - "node": ">= 0.10" - } - }, "node_modules/intl-format-cache": { "version": "2.2.9", "resolved": "https://registry.npmjs.org/intl-format-cache/-/intl-format-cache-2.2.9.tgz", @@ -12194,6 +10505,7 @@ "version": "9.0.5", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", "integrity": "sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g==", + "license": "MIT", "dependencies": { "jsbn": "1.1.0", "sprintf-js": "^1.1.3" @@ -12205,7 +10517,8 @@ "node_modules/ip-address/node_modules/sprintf-js": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.1.3.tgz", - "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==" + "integrity": "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA==", + "license": "BSD-3-Clause" }, "node_modules/irregular-plurals": { "version": "3.5.0", @@ -12217,14 +10530,6 @@ "node": ">=8" } }, - "node_modules/is-absolute-url": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-absolute-url/-/is-absolute-url-2.1.0.tgz", - "integrity": "sha512-vOx7VprsKyllwjSkLV79NIhpyLfr3jAp7VaTCMXOJHu4m0Ew1CZ2fcjASwmV1jI3BWuWHB013M48eyeldk9gYg==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-accessor-descriptor": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.1.tgz", @@ -12237,6 +10542,21 @@ "node": ">= 0.10" } }, + "node_modules/is-arguments": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", + "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-array-buffer": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", @@ -12258,22 +10578,6 @@ "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", "dev": true }, - "node_modules/is-async-function": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.0.0.tgz", - "integrity": "sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==", - "dev": true, - "peer": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-bigint": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.0.4.tgz", @@ -12315,7 +10619,8 @@ "node_modules/is-buffer": { "version": "1.1.6", "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", - "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==" + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true }, "node_modules/is-callable": { "version": "1.2.7", @@ -12328,23 +10633,16 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-ci": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-2.0.0.tgz", - "integrity": "sha512-YfJT7rkpQB0updsdHLGWrvhBJfcfzNNawYDNIyQXJz0IViGf75O8EBPKSdvw2rF+LGCsX4FZ8tcr3b19LcZq4w==", - "dependencies": { - "ci-info": "^2.0.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "license": "MIT", "dependencies": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -12408,7 +10706,6 @@ "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", "dev": true, - "optional": true, "bin": { "is-docker": "cli.js" }, @@ -12453,35 +10750,11 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "devOptional": true, "engines": { "node": ">=0.10.0" } }, - "node_modules/is-finalizationregistry": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.0.2.tgz", - "integrity": "sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.2" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/is-finite": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.1.0.tgz", - "integrity": "sha512-cdyMtqX/BOqqNBBiKlIVkytNHm49MtMlYyn1zxzvJKWmFMlGzm+ry5BBfYyeY9YmNKbRSo/o7OX9w9ale0wg3w==", - "dev": true, - "engines": { - "node": ">=0.10.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -12495,30 +10768,16 @@ "resolved": "https://registry.npmjs.org/is-generator-fn/-/is-generator-fn-2.1.0.tgz", "integrity": "sha512-cTIB4yPYL/Grw0EaSzASzg6bBy9gqCofvWN8okThAYIxKJZC+udlRAmGbM0XLeniEJSs8uEgHPGuHSe1XsOLSQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/is-generator-function": { - "version": "1.0.10", - "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.0.10.tgz", - "integrity": "sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==", - "dev": true, - "peer": true, - "dependencies": { - "has-tostringtag": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "devOptional": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -12526,17 +10785,10 @@ "node": ">=0.10.0" } }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==" - }, "node_modules/is-map": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", - "dev": true, - "peer": true, "engines": { "node": ">= 0.4" }, @@ -12597,20 +10849,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-path-inside": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-3.0.3.tgz", - "integrity": "sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==", - "dev": true, - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/is-plain-obj": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-1.1.0.tgz", "integrity": "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -12654,12 +10897,15 @@ "dev": true }, "node_modules/is-regex": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.1.4.tgz", - "integrity": "sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "license": "MIT", "dependencies": { - "call-bind": "^1.0.2", - "has-tostringtag": "^1.0.0" + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" }, "engines": { "node": ">= 0.4" @@ -12678,8 +10924,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", - "dev": true, - "peer": true, "engines": { "node": ">= 0.4" }, @@ -12706,6 +10950,7 @@ "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" }, @@ -12727,23 +10972,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-subset": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz", - "integrity": "sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==", - "dev": true - }, - "node_modules/is-svg": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-svg/-/is-svg-2.1.0.tgz", - "integrity": "sha512-Ya1giYJUkcL/94quj0+XGcmts6cETPBW1MiFz1ReJrnDJ680F52qpAEGAEGU0nq96FRGIGPx6Yo1CyPXcOoyGw==", - "dependencies": { - "html-comment-regex": "^1.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/is-symbol": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.0.4.tgz", @@ -12772,12 +11000,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-typedarray": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", - "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", - "dev": true - }, "node_modules/is-unicode-supported": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz", @@ -12791,18 +11013,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/is-utf8": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", - "integrity": "sha512-rMYPYvCzsXywIsldgLaSoPlw5PfoB/ssr7hY4pLfcodrA5M/eArza1a9VmTiNIBNMjOGr1Ow9mTyU2o69U6U9Q==", - "dev": true - }, "node_modules/is-weakmap": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", - "dev": true, - "peer": true, "engines": { "node": ">= 0.4" }, @@ -12822,14 +11036,12 @@ } }, "node_modules/is-weakset": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz", - "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==", - "dev": true, - "peer": true, + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", "dependencies": { - "call-bind": "^1.0.7", - "get-intrinsic": "^1.2.4" + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" }, "engines": { "node": ">= 0.4" @@ -12843,7 +11055,6 @@ "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", "dev": true, - "optional": true, "dependencies": { "is-docker": "^2.0.0" }, @@ -12891,47 +11102,6 @@ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "dev": true }, - "node_modules/isomorphic-fetch": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/isomorphic-fetch/-/isomorphic-fetch-2.2.1.tgz", - "integrity": "sha512-9c4TNAKYXM5PRyVcwUZrF3W09nQ+sO7+jydgs4ZGW9dhsLG2VOlISJABombdQqQRXCwuYG3sYV/puGf5rp0qmA==", - "dependencies": { - "node-fetch": "^1.0.1", - "whatwg-fetch": ">=0.10.0" - } - }, - "node_modules/isstream": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", - "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", - "dev": true - }, - "node_modules/istanbul": { - "version": "0.4.5", - "resolved": "https://registry.npmjs.org/istanbul/-/istanbul-0.4.5.tgz", - "integrity": "sha512-nMtdn4hvK0HjUlzr1DrKSUY8ychprt8dzHOgY2KXsIhHu5PuQQEOTM27gV9Xblyon7aUH/TSFIjRHEODF/FRPg==", - "deprecated": "This module is no longer maintained, try this instead:\n npm i nyc\nVisit https://istanbul.js.org/integrations for other alternatives.", - "dev": true, - "dependencies": { - "abbrev": "1.0.x", - "async": "1.x", - "escodegen": "1.8.x", - "esprima": "2.7.x", - "glob": "^5.0.15", - "handlebars": "^4.0.1", - "js-yaml": "3.x", - "mkdirp": "0.5.x", - "nopt": "3.x", - "once": "1.x", - "resolve": "1.1.x", - "supports-color": "^3.1.0", - "which": "^1.1.1", - "wordwrap": "^1.0.0" - }, - "bin": { - "istanbul": "lib/cli.js" - } - }, "node_modules/istanbul-lib-coverage": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", @@ -12962,6 +11132,7 @@ "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", @@ -12976,6 +11147,7 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -12985,6 +11157,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -12997,6 +11170,7 @@ "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0", @@ -13011,6 +11185,7 @@ "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.7.tgz", "integrity": "sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==", "dev": true, + "license": "BSD-3-Clause", "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" @@ -13019,193 +11194,14 @@ "node": ">=8" } }, - "node_modules/istanbul/node_modules/escodegen": { - "version": "1.8.1", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.8.1.tgz", - "integrity": "sha512-yhi5S+mNTOuRvyW4gWlg5W1byMaQGWWSYHXsuFZ7GBo7tpyOwi2EdzMP/QWxh9hwkD2m+wDVHJsxhRIj+v/b/A==", - "dev": true, - "dependencies": { - "esprima": "^2.7.1", - "estraverse": "^1.9.1", - "esutils": "^2.0.2", - "optionator": "^0.8.1" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=0.12.0" - }, - "optionalDependencies": { - "source-map": "~0.2.0" - } - }, - "node_modules/istanbul/node_modules/esprima": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", - "integrity": "sha512-OarPfz0lFCiW4/AV2Oy1Rp9qu0iusTKqykwTspGCZtPxmF81JR4MmIebvF1F9+UOKth2ZubLQ4XGGaU+hSn99A==", - "dev": true, - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/istanbul/node_modules/estraverse": { - "version": "1.9.3", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-1.9.3.tgz", - "integrity": "sha512-25w1fMXQrGdoquWnScXZGckOv+Wes+JDnuN/+7ex3SauFRS72r2lFDec0EKPt2YD1wUJ/IrfEex+9yp4hfSOJA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/istanbul/node_modules/glob": { - "version": "5.0.15", - "resolved": "https://registry.npmjs.org/glob/-/glob-5.0.15.tgz", - "integrity": "sha512-c9IPMazfRITpmAAKi22dK1VKxGDX9ehhqfABDriL/lzO92xcUKEJPQHrVA/2YHSNFB4iFlykVmWvwo48nr3OxA==", - "dev": true, - "dependencies": { - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "2 || 3", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - } - }, - "node_modules/istanbul/node_modules/has-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", - "integrity": "sha512-DyYHfIYwAJmjAjSSPKANxI8bFY9YtFrgkAfinBojQ8YJTOuOuav64tMUJv584SES4xl74PmuaevIyaLESHdTAA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/istanbul/node_modules/levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", - "dev": true, - "dependencies": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/istanbul/node_modules/optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dev": true, - "dependencies": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/istanbul/node_modules/prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/istanbul/node_modules/resolve": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.1.7.tgz", - "integrity": "sha512-9znBF0vBcaSN3W2j7wKvdERPwqTxSpCq+if5C0WoTCyV9n24rua28jeuQ2pL/HOf+yUe/Mef+H/5p60K0Id3bg==", - "dev": true - }, - "node_modules/istanbul/node_modules/source-map": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz", - "integrity": "sha512-CBdZ2oa/BHhS4xj5DlhjWNHcan57/5YuvfdLf17iVmIpd9KRm+DFLmC6nBNj+6Ua7Kt3TmOjDpQT1aTYOQtoUA==", - "dev": true, - "optional": true, - "dependencies": { - "amdefine": ">=0.0.4" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/istanbul/node_modules/supports-color": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", - "integrity": "sha512-Jds2VIYDrlp5ui7t8abHN2bjAu4LV/q4N2KivFPpGH0lrka0BMq/33AmECUXlKPcHigkNaqfXRENFju+rlcy+A==", - "dev": true, - "dependencies": { - "has-flag": "^1.0.0" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/istanbul/node_modules/type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", - "dev": true, - "dependencies": { - "prelude-ls": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/istanbul/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, - "node_modules/iterator.prototype": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", - "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==", - "dev": true, - "peer": true, - "dependencies": { - "define-properties": "^1.2.1", - "get-intrinsic": "^1.2.1", - "has-symbols": "^1.0.3", - "reflect.getprototypeof": "^1.0.4", - "set-function-name": "^2.0.1" - } - }, "node_modules/jackspeak": { - "version": "2.3.6", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.3.6.tgz", - "integrity": "sha512-N3yCS/NegsOBokc8GAdM8UcmfsKiSS8cipheD/nivzr700H+nsMOxJjQnvwOcRYVuFkdH0wGUvW2WbXGmrZGbQ==", + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", + "integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==", + "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/cliui": "^8.0.2" }, - "engines": { - "node": ">=14" - }, "funding": { "url": "https://github.com/sponsors/isaacs" }, @@ -13227,62 +11223,219 @@ "license": "MIT" }, "node_modules/jest": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest/-/jest-26.6.3.tgz", - "integrity": "sha512-lGS5PXGAzR4RF7V5+XObhqz2KZIDUA1yD0DG6pBVmy10eh0ZIXQImRuzocsI/N2XZ1GrLFwTS27In2i2jlpq1Q==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz", + "integrity": "sha512-NIy3oAFp9shda19hy4HK0HRTWKtPJmGdnvywu01nOqNC2vZg+Z+fvJDxpMQA88eb2I9EcafcdjYgsDthnYTvGw==", "dev": true, "license": "MIT", "dependencies": { - "@jest/core": "^26.6.3", + "@jest/core": "^29.7.0", + "@jest/types": "^29.6.3", "import-local": "^3.0.2", - "jest-cli": "^26.6.3" + "jest-cli": "^29.7.0" }, "bin": { "jest": "bin/jest.js" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, "node_modules/jest-changed-files": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-26.6.2.tgz", - "integrity": "sha512-fDS7szLcY9sCtIip8Fjry9oGf3I2ht/QT21bAHm5Dmf0mD4X3ReNUf17y+bO6fR8WgbIZTlbyG1ak/53cbRzKQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-changed-files/-/jest-changed-files-29.7.0.tgz", + "integrity": "sha512-fEArFiwf1BpQ+4bXSprcDc3/x4HSzL4al2tozwVpDFpsxALjLYdyiIK4e5Vz66GQJIbXJ82+35PtysofptNX2w==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^26.6.2", - "execa": "^4.0.0", - "throat": "^5.0.0" + "execa": "^5.0.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-changed-files/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-circus": { + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-circus/-/jest-circus-29.7.0.tgz", + "integrity": "sha512-3E1nCMgipcTkCocFwM90XXQab9bS+GMsjdpmPrlelaxwD93Ad8iVEjX/vvHPdLPnFf+L40u+5+iutRdA1N9myw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jest/environment": "^29.7.0", + "@jest/expect": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "chalk": "^4.0.0", + "co": "^4.6.0", + "dedent": "^1.0.0", + "is-generator-fn": "^2.0.0", + "jest-each": "^29.7.0", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", + "p-limit": "^3.1.0", + "pretty-format": "^29.7.0", + "pure-rand": "^6.0.0", + "slash": "^3.0.0", + "stack-utils": "^2.0.3" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + } + }, + "node_modules/jest-circus/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-circus/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/jest-circus/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/jest-circus/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/jest-circus/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/jest-circus/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-circus/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/jest-cli": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-26.6.3.tgz", - "integrity": "sha512-GF9noBSa9t08pSyl3CY4frMrqp+aQXFGFkf5hEPbh/pIUFYWMK6ZLTfbmadxJVcJrdRoChlWQsA2VkJcDFK8hg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-cli/-/jest-cli-29.7.0.tgz", + "integrity": "sha512-OVVobw2IubN/GSYsxETi+gOe7Ka59EFMR/twOU3Jb2GnKKeMGJB5SGUUrEz3SFVmJASUdZUzy83sLNNQ2gZslg==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/core": "^26.6.3", - "@jest/test-result": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/core": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", "chalk": "^4.0.0", + "create-jest": "^29.7.0", "exit": "^0.1.2", - "graceful-fs": "^4.2.4", "import-local": "^3.0.2", - "is-ci": "^2.0.0", - "jest-config": "^26.6.3", - "jest-util": "^26.6.2", - "jest-validate": "^26.6.2", - "prompts": "^2.0.1", - "yargs": "^15.4.1" + "jest-config": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "yargs": "^17.3.1" }, "bin": { "jest": "bin/jest.js" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "node-notifier": "^8.0.1 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "node-notifier": { + "optional": true + } } }, "node_modules/jest-cli/node_modules/ansi-styles": { @@ -13356,37 +11509,46 @@ } }, "node_modules/jest-config": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-26.6.3.tgz", - "integrity": "sha512-t5qdIj/bCj2j7NFVHb2nFB4aUdfucDn3JRKgrZnplb8nieAirAzRSHP8uDEd+qV6ygzg9Pz4YG7UTJf94LPSyg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-config/-/jest-config-29.7.0.tgz", + "integrity": "sha512-uXbpfeQ7R6TZBqI3/TxCU4q4ttk3u0PJeC+E0zbfSoSjq6bJ7buBPxzQPL0ifrkY4DNu4JUdk0ImlBUYi840eQ==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/core": "^7.1.0", - "@jest/test-sequencer": "^26.6.3", - "@jest/types": "^26.6.2", - "babel-jest": "^26.6.3", + "@babel/core": "^7.11.6", + "@jest/test-sequencer": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-jest": "^29.7.0", "chalk": "^4.0.0", + "ci-info": "^3.2.0", "deepmerge": "^4.2.2", - "glob": "^7.1.1", - "graceful-fs": "^4.2.4", - "jest-environment-jsdom": "^26.6.2", - "jest-environment-node": "^26.6.2", - "jest-get-type": "^26.3.0", - "jest-jasmine2": "^26.6.3", - "jest-regex-util": "^26.0.0", - "jest-resolve": "^26.6.2", - "jest-util": "^26.6.2", - "jest-validate": "^26.6.2", - "micromatch": "^4.0.2", - "pretty-format": "^26.6.2" + "glob": "^7.1.3", + "graceful-fs": "^4.2.9", + "jest-circus": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-runner": "^29.7.0", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "micromatch": "^4.0.4", + "parse-json": "^5.2.0", + "pretty-format": "^29.7.0", + "slash": "^3.0.0", + "strip-json-comments": "^3.1.1" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, "peerDependencies": { + "@types/node": "*", "ts-node": ">=9.0.0" }, "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, "ts-node": { "optional": true } @@ -13397,6 +11559,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -13412,6 +11575,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -13423,11 +11587,28 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/jest-config/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/jest-config/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -13439,13 +11620,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-config/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -13455,6 +11638,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -13463,18 +11647,19 @@ } }, "node_modules/jest-diff": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-26.6.2.tgz", - "integrity": "sha512-6m+9Z3Gv9wN0WFVasqjCL/06+EFCMTqDEUl/b87HYK2rAPTyfz4ZIuSlPhY51PIQRWx5TaxeF1qmXKe9gfN3sA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-29.7.0.tgz", + "integrity": "sha512-LMIgiIrhigmPrs03JHpxUh2yISK3vLFPkAodPeo0+BuF7wA2FoQbkEg1u8gBYBThncu7e1oEDUfIXVuTqLRUjw==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.0.0", - "diff-sequences": "^26.6.2", - "jest-get-type": "^26.3.0", - "pretty-format": "^26.6.2" + "diff-sequences": "^29.6.3", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-diff/node_modules/ansi-styles": { @@ -13482,6 +11667,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -13497,6 +11683,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -13513,6 +11700,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -13524,13 +11712,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-diff/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -13540,6 +11730,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -13548,31 +11739,33 @@ } }, "node_modules/jest-docblock": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-26.0.0.tgz", - "integrity": "sha512-RDZ4Iz3QbtRWycd8bUEPxQsTlYazfYn/h5R65Fc6gOfwozFhoImx+affzky/FFBuqISPTqjXomoIGJVKBWoo0w==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-docblock/-/jest-docblock-29.7.0.tgz", + "integrity": "sha512-q617Auw3A612guyaFgsbFeYpNP5t2aoUNLwBUbc/0kD1R4t9ixDbyFTHd1nok4epoVFpr7PmeWHrhvuV3XaJ4g==", "dev": true, + "license": "MIT", "dependencies": { "detect-newline": "^3.0.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-each": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-26.6.2.tgz", - "integrity": "sha512-Mer/f0KaATbjl8MCJ+0GEpNdqmnVmDYqCTJYTvoo7rqmRiDllmp2AYN+06F93nXcY3ur9ShIjS+CO/uD+BbH4A==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-each/-/jest-each-29.7.0.tgz", + "integrity": "sha512-gns+Er14+ZrEoC5fhOfYCY1LOHHr0TI+rQUHZS8Ttw2l7gl+80eHc/gFf2Ktkw0+SIACDTeWvpFcv3B04VembQ==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^26.6.2", + "@jest/types": "^29.6.3", "chalk": "^4.0.0", - "jest-get-type": "^26.3.0", - "jest-util": "^26.6.2", - "pretty-format": "^26.6.2" + "jest-get-type": "^29.6.3", + "jest-util": "^29.7.0", + "pretty-format": "^29.7.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-each/node_modules/ansi-styles": { @@ -13580,6 +11773,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -13595,6 +11789,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -13611,6 +11806,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -13622,13 +11818,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-each/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -13638,6 +11836,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -13645,706 +11844,115 @@ "node": ">=8" } }, - "node_modules/jest-environment-enzyme": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/jest-environment-enzyme/-/jest-environment-enzyme-6.1.2.tgz", - "integrity": "sha512-WHeBKgBYOdryuOTEoK55lJwjg7Raery1OgXHLwukI3mSYgOkm2UrCDDT+vneqVgy7F8KuRHyStfD+TC/m2b7Kg==", - "dev": true, - "dependencies": { - "jest-environment-jsdom": "^22.4.1" - }, - "peerDependencies": { - "enzyme": "3.x", - "jest": ">=22.0.0", - "react": "^0.13.0 || ^0.14.0 || ^15.0.0 || >=16.x" - } - }, - "node_modules/jest-environment-enzyme/node_modules/acorn": { - "version": "5.7.4", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.4.tgz", - "integrity": "sha512-1D++VG7BhrtvQpNbBzovKNc1FLGGEE/oGe7b9xJm/RFHMBeUaUGpluV9RLjZa47YFdPcDAenEYuq9pQPcMdLJg==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/acorn-globals": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/acorn-globals/-/acorn-globals-4.3.4.tgz", - "integrity": "sha512-clfQEh21R+D0leSbUdWf3OcfqyaCSAQ8Ryq00bofSekfr9W8u1jyYZo6ir0xu9Gtcf7BjcHJpnbZH7JOCpP60A==", - "dev": true, - "dependencies": { - "acorn": "^6.0.1", - "acorn-walk": "^6.0.1" - } - }, - "node_modules/jest-environment-enzyme/node_modules/acorn-globals/node_modules/acorn": { - "version": "6.4.2", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz", - "integrity": "sha512-XtGIhXwF8YM8bJhGxG5kXgjkEuNGLTkoYqVE+KMR+aspr4KGYmKYg7yUe3KghyQ9yheNwLnjmzh/7+gfDBmHCQ==", - "dev": true, - "bin": { - "acorn": "bin/acorn" - }, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/acorn-walk": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-6.2.0.tgz", - "integrity": "sha512-7evsyfH1cLOCdAzZAd43Cic04yKydNx0cF+7tiA19p1XnLLPU4dpCQOqpjqwokFe//vS0QqfqqjCS2JkiIs0cA==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/braces": { - "version": "1.8.5", - "resolved": "https://registry.npmjs.org/braces/-/braces-1.8.5.tgz", - "integrity": "sha512-xU7bpz2ytJl1bH9cgIurjpg/n8Gohy9GTw81heDYLJQ4RU60dlyJsa+atVF2pI0yMMvKxI9HkKwjePCj5XI1hw==", - "dev": true, - "dependencies": { - "expand-range": "^1.8.1", - "preserve": "^0.2.0", - "repeat-element": "^1.1.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/callsites": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/callsites/-/callsites-2.0.0.tgz", - "integrity": "sha512-ksWePWBloaWPxJYQ8TL0JHvtci6G5QTKwQ95RcWAa/lzoAKuAOflGdAK92hpHXjkwb8zLxoLNUoNYZgVsaJzvQ==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/jest-environment-enzyme/node_modules/ci-info": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-1.6.0.tgz", - "integrity": "sha512-vsGdkwSCDpWmP80ncATX7iea5DWQemg1UgCW5J8tqjU3lYw4FBYuj89J0CTVomA7BEfvSZd84GmHko+MxFQU2A==", - "dev": true - }, - "node_modules/jest-environment-enzyme/node_modules/cssom": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/cssom/-/cssom-0.3.8.tgz", - "integrity": "sha512-b0tGHbfegbhPJpxpiBPU2sCkigAqtM9O121le6bbOlgyV+NyGyCmVfJ6QW9eRjz8CpNfWEOYBIMIGRYkLwsIYg==", - "dev": true - }, - "node_modules/jest-environment-enzyme/node_modules/cssstyle": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-1.4.0.tgz", - "integrity": "sha512-GBrLZYZ4X4x6/QEoBnIrqb8B/f5l4+8me2dkom/j1Gtbxy0kBv6OGzKuAsGM75bkGwGAFkt56Iwg28S3XTZgSA==", - "dev": true, - "dependencies": { - "cssom": "0.3.x" - } - }, - "node_modules/jest-environment-enzyme/node_modules/data-urls": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-1.1.0.tgz", - "integrity": "sha512-YTWYI9se1P55u58gL5GkQHW4P6VJBJ5iBT+B5a7i2Tjadhv52paJG0qHX4A0OR6/t52odI64KP2YvFpkDOi3eQ==", - "dev": true, - "dependencies": { - "abab": "^2.0.0", - "whatwg-mimetype": "^2.2.0", - "whatwg-url": "^7.0.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/data-urls/node_modules/whatwg-url": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz", - "integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==", - "dev": true, - "dependencies": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - }, - "node_modules/jest-environment-enzyme/node_modules/domexception": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/domexception/-/domexception-1.0.1.tgz", - "integrity": "sha512-raigMkn7CJNNo6Ihro1fzG7wr3fHuYVytzquZKX5n0yizGsTcYgzdIUwj1X9pK0VvjeihV+XiclP+DjwbsSKug==", - "deprecated": "Use your platform's native DOMException instead", - "dev": true, - "dependencies": { - "webidl-conversions": "^4.0.2" - } - }, - "node_modules/jest-environment-enzyme/node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-environment-enzyme/node_modules/escodegen": { - "version": "1.14.3", - "resolved": "https://registry.npmjs.org/escodegen/-/escodegen-1.14.3.tgz", - "integrity": "sha512-qFcX0XJkdg+PB3xjZZG/wKSuT1PnQWx57+TVSjIMmILd2yC/6ByYElPwJnslDsuWuSAp4AwJGumarAAmJch5Kw==", - "dev": true, - "dependencies": { - "esprima": "^4.0.1", - "estraverse": "^4.2.0", - "esutils": "^2.0.2", - "optionator": "^0.8.1" - }, - "bin": { - "escodegen": "bin/escodegen.js", - "esgenerate": "bin/esgenerate.js" - }, - "engines": { - "node": ">=4.0" - }, - "optionalDependencies": { - "source-map": "~0.6.1" - } - }, - "node_modules/jest-environment-enzyme/node_modules/estraverse": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", - "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", - "dev": true, - "engines": { - "node": ">=4.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/expand-range": { - "version": "1.8.2", - "resolved": "https://registry.npmjs.org/expand-range/-/expand-range-1.8.2.tgz", - "integrity": "sha512-AFASGfIlnIbkKPQwX1yHaDjFvh/1gyKJODme52V6IORh69uEYgZp0o9C+qsIGNVEiuuhQU0CSSl++Rlegg1qvA==", - "dev": true, - "dependencies": { - "fill-range": "^2.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/html-encoding-sniffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-1.0.2.tgz", - "integrity": "sha512-71lZziiDnsuabfdYiUeWdCVyKuqwWi23L8YeIgV9jSSZHCtb6wB1BKWooH7L3tn4/FuZJMVWyNaIDr4RGmaSYw==", - "dev": true, - "dependencies": { - "whatwg-encoding": "^1.0.1" - } - }, - "node_modules/jest-environment-enzyme/node_modules/is-ci": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-1.2.1.tgz", - "integrity": "sha512-s6tfsaQaQi3JNciBH6shVqEDvhGut0SUXr31ag8Pd8BBbVVlcGfWhpPmEOoM6RJ5TFhbypvf5yyRw/VXW1IiWg==", - "dev": true, - "dependencies": { - "ci-info": "^1.5.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, - "node_modules/jest-environment-enzyme/node_modules/is-extglob": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-1.0.0.tgz", - "integrity": "sha512-7Q+VbVafe6x2T+Tu6NcOf6sRklazEPmBoB3IWk3WdGZM2iGUwU/Oe3Wtq5lSEkDTTlpp8yx+5t4pzO/i9Ty1ww==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/is-glob": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-2.0.1.tgz", - "integrity": "sha512-a1dBeB19NXsf/E0+FHqkagizel/LQw2DjSQpvQrj3zT+jYPpaUCryPnrQajXKFLCMuf4I6FhRpaGtw4lPrG6Eg==", - "dev": true, - "dependencies": { - "is-extglob": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/jest-environment-jsdom": { - "version": "22.4.3", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-22.4.3.tgz", - "integrity": "sha512-FviwfR+VyT3Datf13+ULjIMO5CSeajlayhhYQwpzgunswoaLIPutdbrnfUHEMyJCwvqQFaVtTmn9+Y8WCt6n1w==", - "dev": true, - "dependencies": { - "jest-mock": "^22.4.3", - "jest-util": "^22.4.3", - "jsdom": "^11.5.1" - } - }, - "node_modules/jest-environment-enzyme/node_modules/jest-message-util": { - "version": "22.4.3", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-22.4.3.tgz", - "integrity": "sha512-iAMeKxhB3Se5xkSjU0NndLLCHtP4n+GtCqV0bISKA5dmOXQfEbdEmYiu2qpnWBDCQdEafNDDU6Q+l6oBMd/+BA==", - "dev": true, - "dependencies": { - "@babel/code-frame": "^7.0.0-beta.35", - "chalk": "^2.0.1", - "micromatch": "^2.3.11", - "slash": "^1.0.0", - "stack-utils": "^1.0.1" - } - }, - "node_modules/jest-environment-enzyme/node_modules/jest-mock": { - "version": "22.4.3", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-22.4.3.tgz", - "integrity": "sha512-+4R6mH5M1G4NK16CKg9N1DtCaFmuxhcIqF4lQK/Q1CIotqMs/XBemfpDPeVZBFow6iyUNu6EBT9ugdNOTT5o5Q==", - "dev": true - }, - "node_modules/jest-environment-enzyme/node_modules/jest-util": { - "version": "22.4.3", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-22.4.3.tgz", - "integrity": "sha512-rfDfG8wyC5pDPNdcnAlZgwKnzHvZDu8Td2NJI/jAGKEGxJPYiE4F0ss/gSAkG4778Y23Hvbz+0GMrDJTeo7RjQ==", - "dev": true, - "dependencies": { - "callsites": "^2.0.0", - "chalk": "^2.0.1", - "graceful-fs": "^4.1.11", - "is-ci": "^1.0.10", - "jest-message-util": "^22.4.3", - "mkdirp": "^0.5.1", - "source-map": "^0.6.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/jsdom": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-11.12.0.tgz", - "integrity": "sha512-y8Px43oyiBM13Zc1z780FrfNLJCXTL40EWlty/LXUtcjykRBNgLlCjWXpfSPBl2iv+N7koQN+dvqszHZgT/Fjw==", - "dev": true, - "dependencies": { - "abab": "^2.0.0", - "acorn": "^5.5.3", - "acorn-globals": "^4.1.0", - "array-equal": "^1.0.0", - "cssom": ">= 0.3.2 < 0.4.0", - "cssstyle": "^1.0.0", - "data-urls": "^1.0.0", - "domexception": "^1.0.1", - "escodegen": "^1.9.1", - "html-encoding-sniffer": "^1.0.2", - "left-pad": "^1.3.0", - "nwsapi": "^2.0.7", - "parse5": "4.0.0", - "pn": "^1.1.0", - "request": "^2.87.0", - "request-promise-native": "^1.0.5", - "sax": "^1.2.4", - "symbol-tree": "^3.2.2", - "tough-cookie": "^2.3.4", - "w3c-hr-time": "^1.0.1", - "webidl-conversions": "^4.0.2", - "whatwg-encoding": "^1.0.3", - "whatwg-mimetype": "^2.1.0", - "whatwg-url": "^6.4.1", - "ws": "^5.2.0", - "xml-name-validator": "^3.0.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/levn": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", - "integrity": "sha512-0OO4y2iOHix2W6ujICbKIaEQXvFQHue65vUG3pb5EUomzPI90z9hsA1VsO/dbIIpC53J8gxM9Q4Oho0jrCM/yA==", - "dev": true, - "dependencies": { - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/micromatch": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-2.3.11.tgz", - "integrity": "sha512-LnU2XFEk9xxSJ6rfgAry/ty5qwUTyHYOBU0g4R6tIw5ljwgGIBmiKhRWLw5NpMOnrgUNcDJ4WMp8rl3sYVHLNA==", - "dev": true, - "dependencies": { - "arr-diff": "^2.0.0", - "array-unique": "^0.2.1", - "braces": "^1.8.2", - "expand-brackets": "^0.1.4", - "extglob": "^0.3.1", - "filename-regex": "^2.0.0", - "is-extglob": "^1.0.0", - "is-glob": "^2.0.1", - "kind-of": "^3.0.2", - "normalize-path": "^2.0.1", - "object.omit": "^2.0.0", - "parse-glob": "^3.0.4", - "regex-cache": "^0.4.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", - "dev": true, - "dependencies": { - "remove-trailing-separator": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/optionator": { - "version": "0.8.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.3.tgz", - "integrity": "sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==", - "dev": true, - "dependencies": { - "deep-is": "~0.1.3", - "fast-levenshtein": "~2.0.6", - "levn": "~0.3.0", - "prelude-ls": "~1.1.2", - "type-check": "~0.3.2", - "word-wrap": "~1.2.3" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/parse5": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-4.0.0.tgz", - "integrity": "sha512-VrZ7eOd3T1Fk4XWNXMgiGBK/z0MG48BWG2uQNU4I72fkQuKUTZpl+u9k+CxEG0twMVzSmXEEz12z5Fnw1jIQFA==", - "dev": true - }, - "node_modules/jest-environment-enzyme/node_modules/prelude-ls": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", - "integrity": "sha512-ESF23V4SKG6lVSGZgYNpbsiaAkdab6ZgOxe52p7+Kid3W3u3bxR4Vfd/o21dmN7jSt0IwgZ4v5MUd26FEtXE9w==", - "dev": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/jest-environment-enzyme/node_modules/slash": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", - "integrity": "sha512-3TYDR7xWt4dIqV2JauJr+EJeW356RXijHeUlO+8djJ+uBXPn8/2dpzBc8yQhh583sVvc9CvFAeQVgijsH+PNNg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/stack-utils": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-1.0.5.tgz", - "integrity": "sha512-KZiTzuV3CnSnSvgMRrARVCj+Ht7rMbauGDK0LdVFRGyenwdylpajAp4Q0i6SX8rEmbTpMMf6ryq2gb8pPq2WgQ==", - "dev": true, - "dependencies": { - "escape-string-regexp": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-environment-enzyme/node_modules/tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "dev": true, - "dependencies": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/jest-environment-enzyme/node_modules/tr46": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", - "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", - "dev": true, - "dependencies": { - "punycode": "^2.1.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/type-check": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", - "integrity": "sha512-ZCmOJdvOWDBYJlzAoFkC+Q0+bUyEOS1ltgp1MGU03fqHG+dbi9tBFU2Rd9QKiDZFAYrhPh2JUf7rZRIuHRKtOg==", - "dev": true, - "dependencies": { - "prelude-ls": "~1.1.2" - }, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/jest-environment-enzyme/node_modules/webidl-conversions": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", - "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", - "dev": true - }, - "node_modules/jest-environment-enzyme/node_modules/whatwg-url": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz", - "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==", - "dev": true, - "dependencies": { - "lodash.sortby": "^4.7.0", - "tr46": "^1.0.1", - "webidl-conversions": "^4.0.2" - } - }, - "node_modules/jest-environment-enzyme/node_modules/ws": { - "version": "5.2.3", - "resolved": "https://registry.npmjs.org/ws/-/ws-5.2.3.tgz", - "integrity": "sha512-jZArVERrMsKUatIdnLzqvcfydI85dvd/Fp1u/VOpfdDWQ4c9qWXe+VIeAbQ5FrDwciAkr+lzofXLz3Kuf26AOA==", - "dev": true, - "dependencies": { - "async-limiter": "~1.0.0" - } - }, "node_modules/jest-environment-jsdom": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-26.6.2.tgz", - "integrity": "sha512-jgPqCruTlt3Kwqg5/WVFyHIOJHsiAvhcp2qiR2QQstuG9yWox5+iHpU3ZrcBxW14T4fe5Z68jAfLRh7joCSP2Q==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.7.0.tgz", + "integrity": "sha512-k9iQbsf9OyOfdzWH8HDmrRT0gSIcX+FLNW7IQq94tFX0gynPwqDTW0Ho6iMVNjGz/nb+l/vW3dWM2bbLLpkbXA==", + "license": "MIT", "dependencies": { - "@jest/environment": "^26.6.2", - "@jest/fake-timers": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/jsdom": "^20.0.0", "@types/node": "*", - "jest-mock": "^26.6.2", - "jest-util": "^26.6.2", - "jsdom": "^16.4.0" + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0", + "jsdom": "^20.0.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" + }, + "peerDependencies": { + "canvas": "^2.5.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } } }, "node_modules/jest-environment-node": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-26.6.2.tgz", - "integrity": "sha512-zhtMio3Exty18dy8ee8eJ9kjnRyZC1N4C1Nt/VShN1apyXc8rWGtJ9lI7vqiWcyyXS4BVSEn9lxAM2D+07/Tag==", - "dev": true, - "dependencies": { - "@jest/environment": "^26.6.2", - "@jest/fake-timers": "^26.6.2", - "@jest/types": "^26.6.2", - "@types/node": "*", - "jest-mock": "^26.6.2", - "jest-util": "^26.6.2" - }, - "engines": { - "node": ">= 10.14.2" - } - }, - "node_modules/jest-enzyme": { - "version": "6.1.2", - "resolved": "https://registry.npmjs.org/jest-enzyme/-/jest-enzyme-6.1.2.tgz", - "integrity": "sha512-+ds7r2ru3QkNJxelQ2tnC6d33pjUSsZHPD3v4TlnHlNMuGX3UKdxm5C46yZBvJICYBvIF+RFKBhLMM4evNM95Q==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-environment-node/-/jest-environment-node-29.7.0.tgz", + "integrity": "sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==", "dev": true, "license": "MIT", "dependencies": { - "enzyme-matchers": "^6.1.2", - "enzyme-to-json": "^3.3.0", - "jest-environment-enzyme": "^6.1.2" + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-mock": "^29.7.0", + "jest-util": "^29.7.0" }, - "peerDependencies": { - "enzyme": "3.x", - "jest": ">=22.0.0" + "engines": { + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-get-type": { - "version": "26.3.0", - "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-26.3.0.tgz", - "integrity": "sha512-TpfaviN1R2pQWkIihlfEanwOXK0zcxrKEE4MlU6Tn7keoXdN6/3gK/xl0yEh8DOunn5pOVGKf8hB4R9gVh04ig==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-29.6.3.tgz", + "integrity": "sha512-zrteXnqYxfQh7l5FHyL38jL39di8H8rHoecLH3JNxH3BwOrBsNeabdap5e0I23lD4HHI8W5VFBZqG4Eaq5LNcw==", "dev": true, + "license": "MIT", "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-haste-map": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-26.6.2.tgz", - "integrity": "sha512-easWIJXIw71B2RdR8kgqpjQrbMRWQBgiBwXYEhtGUTaX+doCjBheluShdDMeR8IMfJiTqH4+zfhtg29apJf/8w==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-haste-map/-/jest-haste-map-29.7.0.tgz", + "integrity": "sha512-fP8u2pyfqx0K1rGn1R9pyE0/KTn+G7PxktWidOBTqFPLYX0b9ksaMFkhK5vrS3DVun09pckLdlx90QthlW7AmA==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^26.6.2", - "@types/graceful-fs": "^4.1.2", + "@jest/types": "^29.6.3", + "@types/graceful-fs": "^4.1.3", "@types/node": "*", "anymatch": "^3.0.3", "fb-watchman": "^2.0.0", - "graceful-fs": "^4.2.4", - "jest-regex-util": "^26.0.0", - "jest-serializer": "^26.6.2", - "jest-util": "^26.6.2", - "jest-worker": "^26.6.2", - "micromatch": "^4.0.2", - "sane": "^4.0.3", - "walker": "^1.0.7" + "graceful-fs": "^4.2.9", + "jest-regex-util": "^29.6.3", + "jest-util": "^29.7.0", + "jest-worker": "^29.7.0", + "micromatch": "^4.0.4", + "walker": "^1.0.8" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" }, "optionalDependencies": { - "fsevents": "^2.1.2" - } - }, - "node_modules/jest-jasmine2": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-jasmine2/-/jest-jasmine2-26.6.3.tgz", - "integrity": "sha512-kPKUrQtc8aYwBV7CqBg5pu+tmYXlvFlSFYn18ev4gPFtrRzB15N2gW/Roew3187q2w2eHuu0MU9TJz6w0/nPEg==", - "dev": true, - "dependencies": { - "@babel/traverse": "^7.1.0", - "@jest/environment": "^26.6.2", - "@jest/source-map": "^26.6.2", - "@jest/test-result": "^26.6.2", - "@jest/types": "^26.6.2", - "@types/node": "*", - "chalk": "^4.0.0", - "co": "^4.6.0", - "expect": "^26.6.2", - "is-generator-fn": "^2.0.0", - "jest-each": "^26.6.2", - "jest-matcher-utils": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-runtime": "^26.6.3", - "jest-snapshot": "^26.6.2", - "jest-util": "^26.6.2", - "pretty-format": "^26.6.2", - "throat": "^5.0.0" - }, - "engines": { - "node": ">= 10.14.2" - } - }, - "node_modules/jest-jasmine2/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, - "dependencies": { - "color-convert": "^2.0.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, - "node_modules/jest-jasmine2/node_modules/chalk": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", - "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", - "dev": true, - "dependencies": { - "ansi-styles": "^4.1.0", - "supports-color": "^7.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/jest-jasmine2/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/jest-jasmine2/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true - }, - "node_modules/jest-jasmine2/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/jest-jasmine2/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" + "fsevents": "^2.3.2" } }, "node_modules/jest-leak-detector": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-26.6.2.tgz", - "integrity": "sha512-i4xlXpsVSMeKvg2cEKdfhh0H39qlJlP5Ex1yQxwF9ubahboQYMgTtz5oML35AVA3B4Eu+YsmwaiKVev9KCvLxg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-leak-detector/-/jest-leak-detector-29.7.0.tgz", + "integrity": "sha512-kYA8IJcSYtST2BY9I+SMC32nDpBT3J2NvWJx8+JCuCdl/CR1I4EKUJROiP8XtCcxqgTTBGJNdbB1A8XRKbTetw==", "dev": true, + "license": "MIT", "dependencies": { - "jest-get-type": "^26.3.0", - "pretty-format": "^26.6.2" + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-matcher-utils": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-26.6.2.tgz", - "integrity": "sha512-llnc8vQgYcNqDrqRDXWwMr9i7rS5XFiCwvh6DTP7Jqa2mqpcCBBlpCbn+trkG0KNhPu/h8rzyBkriOtBstvWhw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-matcher-utils/-/jest-matcher-utils-29.7.0.tgz", + "integrity": "sha512-sBkD+Xi9DtcChsI3L3u0+N0opgPYnCRPtGcQYrgXmR+hmt/fYfWAL0xRXYU8eWOdfuLgBe0YCW3AFtnRLagq/g==", "dev": true, + "license": "MIT", "dependencies": { "chalk": "^4.0.0", - "jest-diff": "^26.6.2", - "jest-get-type": "^26.3.0", - "pretty-format": "^26.6.2" + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "pretty-format": "^29.7.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-matcher-utils/node_modules/ansi-styles": { @@ -14352,6 +11960,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -14367,6 +11976,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -14383,6 +11993,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -14394,13 +12005,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-matcher-utils/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -14410,6 +12023,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -14418,28 +12032,30 @@ } }, "node_modules/jest-message-util": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-26.6.2.tgz", - "integrity": "sha512-rGiLePzQ3AzwUshu2+Rn+UMFk0pHN58sOG+IaJbk5Jxuqo3NYO1U2/MIR4S1sKgsoYSXSzdtSa0TgrmtUwEbmA==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-message-util/-/jest-message-util-29.7.0.tgz", + "integrity": "sha512-GBEV4GRADeP+qtB2+6u61stea8mGcOT4mCtrYISZwfu9/ISHFJ/5zOMXYbpBE9RsS5+Gb63DW4FgmnKJ79Kf6w==", + "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.0.0", - "@jest/types": "^26.6.2", + "@babel/code-frame": "^7.12.13", + "@jest/types": "^29.6.3", "@types/stack-utils": "^2.0.0", "chalk": "^4.0.0", - "graceful-fs": "^4.2.4", - "micromatch": "^4.0.2", - "pretty-format": "^26.6.2", + "graceful-fs": "^4.2.9", + "micromatch": "^4.0.4", + "pretty-format": "^29.7.0", "slash": "^3.0.0", - "stack-utils": "^2.0.2" + "stack-utils": "^2.0.3" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-message-util/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -14454,6 +12070,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -14469,6 +12086,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -14479,12 +12097,14 @@ "node_modules/jest-message-util/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" }, "node_modules/jest-message-util/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", "engines": { "node": ">=8" } @@ -14493,6 +12113,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -14501,15 +12122,17 @@ } }, "node_modules/jest-mock": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-26.6.2.tgz", - "integrity": "sha512-YyFjePHHp1LzpzYcmgqkJ0nm0gg/lJx2aZFzFy1S6eUqNjXsOqTK10zNRff2dNfssgokjkG65OlWNcIlgd3zew==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-mock/-/jest-mock-29.7.0.tgz", + "integrity": "sha512-ITOMZn+UkYS4ZFh83xYAOzWStloNzJFO2s8DWrE4lhtGD+AorgnbkiKERe4wQVBydIGPx059g6riW5Btp6Llnw==", + "license": "MIT", "dependencies": { - "@jest/types": "^26.6.2", - "@types/node": "*" + "@jest/types": "^29.6.3", + "@types/node": "*", + "jest-util": "^29.7.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-pnp-resolver": { @@ -14517,6 +12140,7 @@ "resolved": "https://registry.npmjs.org/jest-pnp-resolver/-/jest-pnp-resolver-1.2.3.tgz", "integrity": "sha512-+3NpwQEnRoIBtx4fyhblQDPgJI0H1IEIkX7ShLUjPGA7TtUTvI1oiKi3SR4oBR0hQhQR80l4WAe5RrXBwWMA8w==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" }, @@ -14530,45 +12154,48 @@ } }, "node_modules/jest-regex-util": { - "version": "26.0.0", - "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-26.0.0.tgz", - "integrity": "sha512-Gv3ZIs/nA48/Zvjrl34bf+oD76JHiGDUxNOVgUjh3j890sblXryjY4rss71fPtD/njchl6PSE2hIhvyWa1eT0A==", + "version": "29.6.3", + "resolved": "https://registry.npmjs.org/jest-regex-util/-/jest-regex-util-29.6.3.tgz", + "integrity": "sha512-KJJBsRCyyLNWCNBOvZyRDnAIfUiRJ8v+hOBQYGn8gDyF3UegwiP4gwRR3/SDa42g1YbVycTidUF3rKjyLFDWbg==", "dev": true, + "license": "MIT", "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-resolve": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-26.6.2.tgz", - "integrity": "sha512-sOxsZOq25mT1wRsfHcbtkInS+Ek7Q8jCHUB0ZUTP0tc/c41QHriU/NunqMfCUWsL4H3MHpvQD4QR9kSYhS7UvQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve/-/jest-resolve-29.7.0.tgz", + "integrity": "sha512-IOVhZSrg+UvVAshDSDtHyFCCBUl/Q3AAJv8iZ6ZjnZ74xzvwuzLXid9IIIPgTnY62SJjfuupMKZsZQRsCvxEgA==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^26.6.2", "chalk": "^4.0.0", - "graceful-fs": "^4.2.4", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", "jest-pnp-resolver": "^1.2.2", - "jest-util": "^26.6.2", - "read-pkg-up": "^7.0.1", - "resolve": "^1.18.1", + "jest-util": "^29.7.0", + "jest-validate": "^29.7.0", + "resolve": "^1.20.0", + "resolve.exports": "^2.0.0", "slash": "^3.0.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-resolve-dependencies": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-26.6.3.tgz", - "integrity": "sha512-pVwUjJkxbhe4RY8QEWzN3vns2kqyuldKpxlxJlzEYfKSvY6/bMvxoFrYYzUO1Gx28yKWN37qyV7rIoIp2h8fTg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-resolve-dependencies/-/jest-resolve-dependencies-29.7.0.tgz", + "integrity": "sha512-un0zD/6qxJ+S0et7WxeI3H5XSe9lTBBR7bOHCHXkKR6luG5mwDDlIzVQ0V5cZCuoTgEdcdwzTghYkTWfubi+nA==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^26.6.2", - "jest-regex-util": "^26.0.0", - "jest-snapshot": "^26.6.2" + "jest-regex-util": "^29.6.3", + "jest-snapshot": "^29.7.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-resolve/node_modules/ansi-styles": { @@ -14576,6 +12203,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -14591,6 +12219,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -14607,6 +12236,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -14618,13 +12248,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-resolve/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -14634,6 +12266,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -14642,34 +12275,36 @@ } }, "node_modules/jest-runner": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-26.6.3.tgz", - "integrity": "sha512-atgKpRHnaA2OvByG/HpGA4g6CSPS/1LK0jK3gATJAoptC1ojltpmVlYC3TYgdmGp+GLuhzpH30Gvs36szSL2JQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runner/-/jest-runner-29.7.0.tgz", + "integrity": "sha512-fsc4N6cPCAahybGBfTRcq5wFR6fpLznMg47sY5aDpsoejOcVYFb07AHuSnR0liMcPTgBsA3ZJL6kFOjPdoNipQ==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/console": "^26.6.2", - "@jest/environment": "^26.6.2", - "@jest/test-result": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/console": "^29.7.0", + "@jest/environment": "^29.7.0", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", - "emittery": "^0.7.1", - "exit": "^0.1.2", - "graceful-fs": "^4.2.4", - "jest-config": "^26.6.3", - "jest-docblock": "^26.0.0", - "jest-haste-map": "^26.6.2", - "jest-leak-detector": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-resolve": "^26.6.2", - "jest-runtime": "^26.6.3", - "jest-util": "^26.6.2", - "jest-worker": "^26.6.2", - "source-map-support": "^0.5.6", - "throat": "^5.0.0" + "emittery": "^0.13.1", + "graceful-fs": "^4.2.9", + "jest-docblock": "^29.7.0", + "jest-environment-node": "^29.7.0", + "jest-haste-map": "^29.7.0", + "jest-leak-detector": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-resolve": "^29.7.0", + "jest-runtime": "^29.7.0", + "jest-util": "^29.7.0", + "jest-watcher": "^29.7.0", + "jest-worker": "^29.7.0", + "p-limit": "^3.1.0", + "source-map-support": "0.5.13" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-runner/node_modules/ansi-styles": { @@ -14677,6 +12312,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -14692,6 +12328,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -14708,6 +12345,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -14719,22 +12357,52 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-runner/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, + "node_modules/jest-runner/node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jest-runner/node_modules/source-map-support": { + "version": "0.5.13", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.13.tgz", + "integrity": "sha512-SHSKFHadjVA5oR4PPqhtAVdcBWwRYVd6g6cAXnIbRiIwc2EhPrTuKUBdSLvlEKyIP3GCf89fltvcZiP9MMFA1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/jest-runner/node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -14743,44 +12411,37 @@ } }, "node_modules/jest-runtime": { - "version": "26.6.3", - "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-26.6.3.tgz", - "integrity": "sha512-lrzyR3N8sacTAMeonbqpnSka1dHNux2uk0qqDXVkMv2c/A3wYnvQ4EXuI013Y6+gSKSCxdaczvf4HF0mVXHRdw==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-runtime/-/jest-runtime-29.7.0.tgz", + "integrity": "sha512-gUnLjgwdGqW7B4LvOIkbKs9WGbn+QLqRQQ9juC6HndeDiezIwhDP+mhMwHWCEcfQ5RUXa6OPnFF8BJh5xegwwQ==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/console": "^26.6.2", - "@jest/environment": "^26.6.2", - "@jest/fake-timers": "^26.6.2", - "@jest/globals": "^26.6.2", - "@jest/source-map": "^26.6.2", - "@jest/test-result": "^26.6.2", - "@jest/transform": "^26.6.2", - "@jest/types": "^26.6.2", - "@types/yargs": "^15.0.0", + "@jest/environment": "^29.7.0", + "@jest/fake-timers": "^29.7.0", + "@jest/globals": "^29.7.0", + "@jest/source-map": "^29.6.3", + "@jest/test-result": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "@types/node": "*", "chalk": "^4.0.0", - "cjs-module-lexer": "^0.6.0", + "cjs-module-lexer": "^1.0.0", "collect-v8-coverage": "^1.0.0", - "exit": "^0.1.2", "glob": "^7.1.3", - "graceful-fs": "^4.2.4", - "jest-config": "^26.6.3", - "jest-haste-map": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-mock": "^26.6.2", - "jest-regex-util": "^26.0.0", - "jest-resolve": "^26.6.2", - "jest-snapshot": "^26.6.2", - "jest-util": "^26.6.2", - "jest-validate": "^26.6.2", + "graceful-fs": "^4.2.9", + "jest-haste-map": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-mock": "^29.7.0", + "jest-regex-util": "^29.6.3", + "jest-resolve": "^29.7.0", + "jest-snapshot": "^29.7.0", + "jest-util": "^29.7.0", "slash": "^3.0.0", - "strip-bom": "^4.0.0", - "yargs": "^15.4.1" - }, - "bin": { - "jest-runtime": "bin/jest-runtime.js" + "strip-bom": "^4.0.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-runtime/node_modules/ansi-styles": { @@ -14788,6 +12449,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -14803,6 +12465,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -14819,6 +12482,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -14830,13 +12494,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-runtime/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -14846,6 +12512,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -14853,44 +12520,36 @@ "node": ">=8" } }, - "node_modules/jest-serializer": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-serializer/-/jest-serializer-26.6.2.tgz", - "integrity": "sha512-S5wqyz0DXnNJPd/xfIzZ5Xnp1HrJWBczg8mMfMpN78OJ5eDxXyf+Ygld9wX1DnUWbIbhM1YDY95NjR4CBXkb2g==", - "dev": true, - "dependencies": { - "@types/node": "*", - "graceful-fs": "^4.2.4" - }, - "engines": { - "node": ">= 10.14.2" - } - }, "node_modules/jest-snapshot": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-26.6.2.tgz", - "integrity": "sha512-OLhxz05EzUtsAmOMzuupt1lHYXCNib0ECyuZ/PZOx9TrZcC8vL0x+DUG3TL+GLX3yHG45e6YGjIm0XwDc3q3og==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-snapshot/-/jest-snapshot-29.7.0.tgz", + "integrity": "sha512-Rm0BMWtxBcioHr1/OX5YCP8Uov4riHvKPknOGs804Zg9JGZgmIBkbtlxJC/7Z4msKYVbIJtfU+tKb8xlYNfdkw==", "dev": true, + "license": "MIT", "dependencies": { - "@babel/types": "^7.0.0", - "@jest/types": "^26.6.2", - "@types/babel__traverse": "^7.0.4", - "@types/prettier": "^2.0.0", + "@babel/core": "^7.11.6", + "@babel/generator": "^7.7.2", + "@babel/plugin-syntax-jsx": "^7.7.2", + "@babel/plugin-syntax-typescript": "^7.7.2", + "@babel/types": "^7.3.3", + "@jest/expect-utils": "^29.7.0", + "@jest/transform": "^29.7.0", + "@jest/types": "^29.6.3", + "babel-preset-current-node-syntax": "^1.0.0", "chalk": "^4.0.0", - "expect": "^26.6.2", - "graceful-fs": "^4.2.4", - "jest-diff": "^26.6.2", - "jest-get-type": "^26.3.0", - "jest-haste-map": "^26.6.2", - "jest-matcher-utils": "^26.6.2", - "jest-message-util": "^26.6.2", - "jest-resolve": "^26.6.2", + "expect": "^29.7.0", + "graceful-fs": "^4.2.9", + "jest-diff": "^29.7.0", + "jest-get-type": "^29.6.3", + "jest-matcher-utils": "^29.7.0", + "jest-message-util": "^29.7.0", + "jest-util": "^29.7.0", "natural-compare": "^1.4.0", - "pretty-format": "^26.6.2", - "semver": "^7.3.2" + "pretty-format": "^29.7.0", + "semver": "^7.5.3" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-snapshot/node_modules/ansi-styles": { @@ -14898,6 +12557,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -14913,6 +12573,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -14929,6 +12590,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -14940,37 +12602,25 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-snapshot/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/jest-snapshot/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/jest-snapshot/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -14983,6 +12633,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -14990,32 +12641,28 @@ "node": ">=8" } }, - "node_modules/jest-snapshot/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/jest-util": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-26.6.2.tgz", - "integrity": "sha512-MDW0fKfsn0OI7MS7Euz6h8HNDXVQ0gaM9uW6RjfDmd1DAFcaxX9OqIakHIqhbnmF08Cf2DLDG+ulq8YQQ0Lp0Q==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-util/-/jest-util-29.7.0.tgz", + "integrity": "sha512-z6EbKajIpqGKU56y5KBUgy1dt1ihhQJgWzUlZHArA/+X2ad7Cb5iF+AK1EWVL/Bo7Rz9uurpqw6SiBCefUbCGA==", + "license": "MIT", "dependencies": { - "@jest/types": "^26.6.2", + "@jest/types": "^29.6.3", "@types/node": "*", "chalk": "^4.0.0", - "graceful-fs": "^4.2.4", - "is-ci": "^2.0.0", - "micromatch": "^4.0.2" + "ci-info": "^3.2.0", + "graceful-fs": "^4.2.9", + "picomatch": "^2.2.3" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-util/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -15030,6 +12677,7 @@ "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -15041,10 +12689,26 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/jest-util/node_modules/ci-info": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", + "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/sibiraj-s" + } + ], + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/jest-util/node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -15055,12 +12719,14 @@ "node_modules/jest-util/node_modules/color-name": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" }, "node_modules/jest-util/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "license": "MIT", "engines": { "node": ">=8" } @@ -15069,6 +12735,7 @@ "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -15077,20 +12744,21 @@ } }, "node_modules/jest-validate": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-26.6.2.tgz", - "integrity": "sha512-NEYZ9Aeyj0i5rQqbq+tpIOom0YS1u2MVu6+euBsvpgIme+FOfRmoC4R5p0JiAUpaFvFy24xgrpMknarR/93XjQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-validate/-/jest-validate-29.7.0.tgz", + "integrity": "sha512-ZB7wHqaRGVw/9hST/OuFUReG7M8vKeq0/J2egIGLdvjHCmYqGARhzXmtgi+gVeZ5uXFF219aOc3Ls2yLg27tkw==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/types": "^26.6.2", - "camelcase": "^6.0.0", + "@jest/types": "^29.6.3", + "camelcase": "^6.2.0", "chalk": "^4.0.0", - "jest-get-type": "^26.3.0", + "jest-get-type": "^29.6.3", "leven": "^3.1.0", - "pretty-format": "^26.6.2" + "pretty-format": "^29.7.0" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-validate/node_modules/ansi-styles": { @@ -15098,6 +12766,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -15113,6 +12782,7 @@ "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", "dev": true, + "license": "MIT", "engines": { "node": ">=10" }, @@ -15125,6 +12795,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -15141,6 +12812,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -15152,13 +12824,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-validate/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -15168,6 +12842,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -15176,21 +12851,23 @@ } }, "node_modules/jest-watcher": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-26.6.2.tgz", - "integrity": "sha512-WKJob0P/Em2csiVthsI68p6aGKTIcsfjH9Gsx1f0A3Italz43e3ho0geSAVsmj09RWOELP1AZ/DXyJgOgDKxXQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-watcher/-/jest-watcher-29.7.0.tgz", + "integrity": "sha512-49Fg7WXkU3Vl2h6LbLtMQ/HyB6rXSIX7SqvBLQmssRBGN9I0PNvPmAmCWSOY6SOvrjhI/F7/bGAv9RtnsPA03g==", "dev": true, + "license": "MIT", "dependencies": { - "@jest/test-result": "^26.6.2", - "@jest/types": "^26.6.2", + "@jest/test-result": "^29.7.0", + "@jest/types": "^29.6.3", "@types/node": "*", "ansi-escapes": "^4.2.1", "chalk": "^4.0.0", - "jest-util": "^26.6.2", + "emittery": "^0.13.1", + "jest-util": "^29.7.0", "string-length": "^4.0.1" }, "engines": { - "node": ">= 10.14.2" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-watcher/node_modules/ansi-styles": { @@ -15198,6 +12875,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -15213,6 +12891,7 @@ "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" @@ -15229,6 +12908,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -15240,13 +12920,15 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/jest-watcher/node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } @@ -15256,6 +12938,7 @@ "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, @@ -15264,17 +12947,19 @@ } }, "node_modules/jest-worker": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-26.6.2.tgz", - "integrity": "sha512-KWYVV1c4i+jbMpaBC+U++4Va0cp8OisU185o73T1vo99hqi7w8tSJfUXYswwqqrjzwxa6KpRK54WhPvwf5w6PQ==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.7.0.tgz", + "integrity": "sha512-eIz2msL/EzL9UFTFFx7jBTkeZfku0yUAyZZZmJ93H2TYEiroIx2PQjEXcwYtYl8zXCxb+PAmA2hLIt/6ZEkPHw==", "dev": true, + "license": "MIT", "dependencies": { "@types/node": "*", + "jest-util": "^29.7.0", "merge-stream": "^2.0.0", - "supports-color": "^7.0.0" + "supports-color": "^8.0.0" }, "engines": { - "node": ">= 10.13.0" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/jest-worker/node_modules/has-flag": { @@ -15282,20 +12967,25 @@ "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, "node_modules/jest-worker/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", "dev": true, + "license": "MIT", "dependencies": { "has-flag": "^4.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" } }, "node_modules/jquery": { @@ -15316,11 +13006,6 @@ "jquery": ">=1.8" } }, - "node_modules/js-base64": { - "version": "2.6.4", - "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.6.4.tgz", - "integrity": "sha512-pZe//GGmwJndub7ZghVHz7vjb2LgC1m8B07Au3eYqeqv9emhESByMXxaEgkUkEqJe87oBbSniGYoQNIBklc7IQ==" - }, "node_modules/js-cookie": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", @@ -15351,43 +13036,44 @@ "node_modules/jsbn": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-1.1.0.tgz", - "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" + "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==", + "license": "MIT" }, "node_modules/jsdom": { - "version": "16.7.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-16.7.0.tgz", - "integrity": "sha512-u9Smc2G1USStM+s/x1ru5Sxrl6mPYCbByG1U/hUmqaVsm4tbNyS7CicOSRyuGQYZhTu0h84qkZZQ/I+dzizSVw==", + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-20.0.3.tgz", + "integrity": "sha512-SYhBvTh89tTfCD/CRdSOm13mOBa42iTaTyfyEWBdKcGdPxPtLFBXuHR8XHb33YNYaP+lLbmSvBTsnoesCNJEsQ==", + "license": "MIT", "dependencies": { - "abab": "^2.0.5", - "acorn": "^8.2.4", - "acorn-globals": "^6.0.0", - "cssom": "^0.4.4", + "abab": "^2.0.6", + "acorn": "^8.8.1", + "acorn-globals": "^7.0.0", + "cssom": "^0.5.0", "cssstyle": "^2.3.0", - "data-urls": "^2.0.0", - "decimal.js": "^10.2.1", - "domexception": "^2.0.1", + "data-urls": "^3.0.2", + "decimal.js": "^10.4.2", + "domexception": "^4.0.0", "escodegen": "^2.0.0", - "form-data": "^3.0.0", - "html-encoding-sniffer": "^2.0.1", - "http-proxy-agent": "^4.0.1", - "https-proxy-agent": "^5.0.0", + "form-data": "^4.0.0", + "html-encoding-sniffer": "^3.0.0", + "http-proxy-agent": "^5.0.0", + "https-proxy-agent": "^5.0.1", "is-potential-custom-element-name": "^1.0.1", - "nwsapi": "^2.2.0", - "parse5": "6.0.1", - "saxes": "^5.0.1", + "nwsapi": "^2.2.2", + "parse5": "^7.1.1", + "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^4.0.0", - "w3c-hr-time": "^1.0.2", - "w3c-xmlserializer": "^2.0.0", - "webidl-conversions": "^6.1.0", - "whatwg-encoding": "^1.0.5", - "whatwg-mimetype": "^2.3.0", - "whatwg-url": "^8.5.0", - "ws": "^7.4.6", - "xml-name-validator": "^3.0.0" + "tough-cookie": "^4.1.2", + "w3c-xmlserializer": "^4.0.0", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0", + "whatwg-url": "^11.0.0", + "ws": "^8.11.0", + "xml-name-validator": "^4.0.0" }, "engines": { - "node": ">=10" + "node": ">=14" }, "peerDependencies": { "canvas": "^2.5.0" @@ -15399,9 +13085,10 @@ } }, "node_modules/jsdom/node_modules/form-data": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-3.0.1.tgz", - "integrity": "sha512-RHkBKtLWUVwd7SqRIvCZMEvAMoGUp0XU+seQiZejj0COz3RI3hWP4sCv3gZWWLjJTd7rGwcsF5eKZGii0r/hbg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", + "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -15411,20 +13098,58 @@ "node": ">= 6" } }, - "node_modules/jsdom/node_modules/parse5": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-6.0.1.tgz", - "integrity": "sha512-Ofn/CTFzRGTTxwpNEs9PP93gXShHcTq255nzRYSKe8AkVpZY7e1fpmTfOyoIvjP5HG7Z2ZM7VS9PPhQGW2pOpw==" + "node_modules/jsdom/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/jsdom/node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/jsdom/node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "license": "Apache-2.0", + "engines": { + "node": ">=12" + } }, "node_modules/jsesc": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-2.5.2.tgz", - "integrity": "sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "license": "MIT", "bin": { "jsesc": "bin/jsesc" }, "engines": { - "node": ">=4" + "node": ">=6" } }, "node_modules/jshint": { @@ -15583,12 +13308,6 @@ "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==" }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "dev": true - }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -15611,24 +13330,11 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/json-stable-stringify-without-jsonify": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", - "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", - "dev": true, - "peer": true - }, "node_modules/json-stable-stringify/node_modules/isarray": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" }, - "node_modules/json-stringify-safe": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true - }, "node_modules/json2mq": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", @@ -15681,37 +13387,6 @@ "node": ">=0.10.0" } }, - "node_modules/jsprim": { - "version": "1.4.2", - "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", - "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", - "dev": true, - "dependencies": { - "assert-plus": "1.0.0", - "extsprintf": "1.3.0", - "json-schema": "0.4.0", - "verror": "1.10.0" - }, - "engines": { - "node": ">=0.6.0" - } - }, - "node_modules/jsx-ast-utils": { - "version": "3.3.5", - "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", - "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", - "dev": true, - "peer": true, - "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flat": "^1.3.1", - "object.assign": "^4.1.4", - "object.values": "^1.1.6" - }, - "engines": { - "node": ">=4.0" - } - }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -15765,6 +13440,13 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/just-extend": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-6.2.0.tgz", + "integrity": "sha512-cYofQu2Xpom82S6qD778jBDpwvvy39s1l/hrYij2u9AMdQcGRpaBu6kY4mVhuno5kJVi1DAz4aiphA2WI1/OAw==", + "dev": true, + "license": "MIT" + }, "node_modules/karma": { "version": "0.13.22", "resolved": "https://registry.npmjs.org/karma/-/karma-0.13.22.tgz", @@ -15803,12 +13485,12 @@ } }, "node_modules/karma-chrome-launcher": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-0.2.3.tgz", - "integrity": "sha512-AiMVR7eY9MKLF3EVwgB08TyiHCBIUXAypgxcWJeOSUHB7QBvB2ebUr8tl0C0YwPS2Ce+oBAbR/SQkG46aLfJAA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/karma-chrome-launcher/-/karma-chrome-launcher-3.2.0.tgz", + "integrity": "sha512-rE9RkUPI7I9mAxByQWkGJFXfFD6lE4gC5nPuZdobf/QdTEJI6EU4yIay/cfU/xV4ZxlM5JiTv7zWYgA64NpS5Q==", "dev": true, + "license": "MIT", "dependencies": { - "fs-access": "^1.0.0", "which": "^1.2.1" } }, @@ -15817,6 +13499,7 @@ "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", "dev": true, + "license": "ISC", "dependencies": { "isexe": "^2.0.0" }, @@ -15825,214 +13508,49 @@ } }, "node_modules/karma-coverage": { - "version": "0.5.5", - "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-0.5.5.tgz", - "integrity": "sha512-nC6B3DdP0IhNVUH25kx7ztVBT5belTC4MQ2tDhvvGMUhrpUPA3vUipBC9XqE9WqfHQDjRfQdn4z7Y0iKC6axBA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/karma-coverage/-/karma-coverage-2.2.1.tgz", + "integrity": "sha512-yj7hbequkQP2qOSb20GuNSIyE//PgJWHwC2IydLE6XRtsnaflv+/OSGNssPjobYUlhVVagy99TQpqUt3vAUG7A==", "dev": true, + "license": "MIT", "dependencies": { - "dateformat": "^1.0.6", - "istanbul": "^0.4.0", - "minimatch": "^3.0.0", - "source-map": "^0.5.1" - } - }, - "node_modules/karma-coverage/node_modules/camelcase": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", - "integrity": "sha512-DLIsRzJVBQu72meAKPkWQOLcujdXT32hwdfnkI1frSiSRMK1MofjKHf+MEx0SB6fjEFXL8fBDv1dKymBlOp4Qw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma-coverage/node_modules/camelcase-keys": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", - "integrity": "sha512-bA/Z/DERHKqoEOrp+qeGKw1QlvEQkGZSc0XaY6VnTxZr+Kv1G5zFwttpjv8qxZ/sBPT4nthwZaAcsAZTJlSKXQ==", - "dev": true, - "dependencies": { - "camelcase": "^2.0.0", - "map-obj": "^1.0.0" + "istanbul-lib-coverage": "^3.2.0", + "istanbul-lib-instrument": "^5.1.0", + "istanbul-lib-report": "^3.0.0", + "istanbul-lib-source-maps": "^4.0.1", + "istanbul-reports": "^3.0.5", + "minimatch": "^3.0.4" }, "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma-coverage/node_modules/dateformat": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-1.0.12.tgz", - "integrity": "sha512-5sFRfAAmbHdIts+eKjR9kYJoF0ViCMVX9yqLu5A7S/v+nd077KgCITOMiirmyCBiZpKLDXbBOkYm6tu7rX/TKg==", - "dev": true, - "dependencies": { - "get-stdin": "^4.0.1", - "meow": "^3.3.0" - }, - "bin": { - "dateformat": "bin/cli.js" - }, - "engines": { - "node": "*" - } - }, - "node_modules/karma-coverage/node_modules/find-up": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", - "integrity": "sha512-jvElSjyuo4EMQGoTwo1uJU5pQMwTW5lS1x05zzfJuTIyLR3zwO27LYrxNg+dlvKpGOuGy/MzBdXh80g0ve5+HA==", - "dev": true, - "dependencies": { - "path-exists": "^2.0.0", - "pinkie-promise": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma-coverage/node_modules/indent-string": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", - "integrity": "sha512-aqwDFWSgSgfRaEwao5lg5KEcVd/2a+D1rvoG7NdilmYz0NwRk6StWpWdz/Hpk34MKPpx7s8XxUqimfcQK6gGlg==", - "dev": true, - "dependencies": { - "repeating": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma-coverage/node_modules/map-obj": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", - "integrity": "sha512-7N/q3lyZ+LVCp7PzuxrJr4KMbBE2hW7BT7YNia330OFxIf4d3r5zVpicP2650l7CPN6RM9zOJRl3NGpqSiw3Eg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma-coverage/node_modules/meow": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", - "integrity": "sha512-TNdwZs0skRlpPpCUK25StC4VH+tP5GgeY1HQOOGP+lQ2xtdkN2VtT/5tiX9k3IWpkBPV9b3LsAWXn4GGi/PrSA==", - "dev": true, - "dependencies": { - "camelcase-keys": "^2.0.0", - "decamelize": "^1.1.2", - "loud-rejection": "^1.0.0", - "map-obj": "^1.0.1", - "minimist": "^1.1.3", - "normalize-package-data": "^2.3.4", - "object-assign": "^4.0.1", - "read-pkg-up": "^1.0.1", - "redent": "^1.0.0", - "trim-newlines": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma-coverage/node_modules/path-exists": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", - "integrity": "sha512-yTltuKuhtNeFJKa1PiRzfLAU5182q1y4Eb4XCJ3PBqyzEDkAZRzBrKKBct682ls9reBVHf9udYLN5Nd+K1B9BQ==", - "dev": true, - "dependencies": { - "pinkie-promise": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma-coverage/node_modules/path-type": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", - "integrity": "sha512-S4eENJz1pkiQn9Znv33Q+deTOKmbl+jj1Fl+qiP/vYezj+S8x+J3Uo0ISrx/QoEvIlOaDWJhPaRd1flJ9HXZqg==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.1.2", - "pify": "^2.0.0", - "pinkie-promise": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma-coverage/node_modules/read-pkg": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", - "integrity": "sha512-7BGwRHqt4s/uVbuyoeejRn4YmFnYZiFl4AuaeXHlgZf3sONF0SOGlxs2Pw8g6hCKupo08RafIO5YXFNOKTfwsQ==", - "dev": true, - "dependencies": { - "load-json-file": "^1.0.0", - "normalize-package-data": "^2.3.2", - "path-type": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma-coverage/node_modules/read-pkg-up": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", - "integrity": "sha512-WD9MTlNtI55IwYUS27iHh9tK3YoIVhxis8yKhLpTqWtml739uXc9NWTpxoHkfZf3+DkCCsXox94/VWZniuZm6A==", - "dev": true, - "dependencies": { - "find-up": "^1.0.0", - "read-pkg": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma-coverage/node_modules/redent": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz", - "integrity": "sha512-qtW5hKzGQZqKoh6JNSD+4lfitfPKGz42e6QwiRmPM5mmKtR0N41AbJRYu0xJi7nhOJ4WDgRkKvAk6tw4WIwR4g==", - "dev": true, - "dependencies": { - "indent-string": "^2.1.0", - "strip-indent": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma-coverage/node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma-coverage/node_modules/strip-indent": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz", - "integrity": "sha512-I5iQq6aFMM62fBEAIB/hXzwJD6EEZ0xEGCX2t7oXqaKPIRgt4WruAQ285BISgdkP+HLGWyeGmNJcpIwFeRYRUA==", - "dev": true, - "dependencies": { - "get-stdin": "^4.0.1" - }, - "bin": { - "strip-indent": "cli.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/karma-coverage/node_modules/trim-newlines": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", - "integrity": "sha512-Nm4cF79FhSTzrLKGDMi3I4utBtFv8qKy4sq1enftf2gMdpqI8oVQTAfySkTz5r49giVzDj88SVZXP4CeYQwjaw==", - "dev": true, - "engines": { - "node": ">=0.10.0" + "node": ">=10.0.0" } }, "node_modules/karma-firefox-launcher": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/karma-firefox-launcher/-/karma-firefox-launcher-0.1.7.tgz", - "integrity": "sha512-jS+2RpaVUlEojyWJWsL5MVSxW0h6dRiQ0bpT19bYmLejkI3qCENWU6+xkCOc8d3/K1l6h2mkz+XCi2KQqOyncQ==", - "dev": true + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/karma-firefox-launcher/-/karma-firefox-launcher-2.1.3.tgz", + "integrity": "sha512-LMM2bseebLbYjODBOVt7TCPP9OI2vZIXCavIXhkO9m+10Uj5l7u/SKoeRmYx8FYHTVGZSpk6peX+3BMHC1WwNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-wsl": "^2.2.0", + "which": "^3.0.0" + } + }, + "node_modules/karma-firefox-launcher/node_modules/which": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz", + "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/which.js" + }, + "engines": { + "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + } }, "node_modules/karma-jasmine": { "version": "0.3.8", @@ -16069,24 +13587,28 @@ } }, "node_modules/karma-junit-reporter": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/karma-junit-reporter/-/karma-junit-reporter-1.2.0.tgz", - "integrity": "sha512-FeuLOKlXNtJhIQK3oQASbO5QOib762CEHV8+L9wwTQpiZJgp7xKg3sNno66rL5bQPV2soG6fJdAFWqqnMJuh2w==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/karma-junit-reporter/-/karma-junit-reporter-2.0.1.tgz", + "integrity": "sha512-VtcGfE0JE4OE1wn0LK8xxDKaTP7slN8DO3I+4xg6gAi1IoAHAXOJ1V9G/y45Xg6sxdxPOR3THCFtDlAfBo9Afw==", "dev": true, "license": "MIT", "dependencies": { "path-is-absolute": "^1.0.0", - "xmlbuilder": "8.2.2" + "xmlbuilder": "12.0.0" + }, + "engines": { + "node": ">= 8" }, "peerDependencies": { "karma": ">=0.9" } }, "node_modules/karma-requirejs": { - "version": "0.2.6", - "resolved": "https://registry.npmjs.org/karma-requirejs/-/karma-requirejs-0.2.6.tgz", - "integrity": "sha512-8T7U+QwCy36XIYvC1obbWnN766kCck6hcJ7ehr6cgSLq9SnsvqWUETexHbkOPQ2SXnabCb6lbLDNUk3yCPbbrA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/karma-requirejs/-/karma-requirejs-1.1.0.tgz", + "integrity": "sha512-MHTOYKdwwJBkvYid0TaYvBzOnFH3TDtzo6ie5E4o9SaUSXXsfMRLa/whUz6efVIgTxj1xnKYasNn/XwEgJeB/Q==", "dev": true, + "license": "MIT", "peerDependencies": { "karma": ">=0.9", "requirejs": "^2.1.0" @@ -16116,18 +13638,26 @@ } }, "node_modules/karma-spec-reporter": { - "version": "0.0.36", - "resolved": "https://registry.npmjs.org/karma-spec-reporter/-/karma-spec-reporter-0.0.36.tgz", - "integrity": "sha512-11bvOl1x6ryKZph7kmbmMpbi8vsngEGxGOoeTlIcDaH3ab3j8aPJnZ+r+K/SS0sBSGy5VGkGYO2+hLct7hw/6w==", + "version": "0.0.20", + "resolved": "https://registry.npmjs.org/karma-spec-reporter/-/karma-spec-reporter-0.0.20.tgz", + "integrity": "sha512-pl+KmLNwnu802F/q9cZx5n20FuxA0ebwM3uMuy4Qh+GGfviy4EFK8I5Bl2cWogSRBUC2Fhg5oOsUFVlv/j5tuA==", "dev": true, - "license": "MIT", "dependencies": { - "colors": "1.4.0" + "colors": "~0.6.0" }, "peerDependencies": { "karma": ">=0.9" } }, + "node_modules/karma-spec-reporter/node_modules/colors": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/colors/-/colors-0.6.2.tgz", + "integrity": "sha512-OsSVtHK8Ir8r3+Fxw/b4jS1ZLPXkV6ZxDRJQzeD7qo0SqMXWrHDM71DgYzPMHY8SFJ0Ao+nNU2p1MmwdzKqPrw==", + "dev": true, + "engines": { + "node": ">=0.1.90" + } + }, "node_modules/karma-webpack": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/karma-webpack/-/karma-webpack-5.0.1.tgz", @@ -16228,6 +13758,7 @@ "resolved": "https://registry.npmjs.org/kleur/-/kleur-3.0.3.tgz", "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -16240,64 +13771,16 @@ "license": "MIT", "peer": true }, - "node_modules/language-subtag-registry": { - "version": "0.3.22", - "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.22.tgz", - "integrity": "sha512-tN0MCzyWnoz/4nHS6uxdlFWoUZT7ABptwKPQ52Ea7URk6vll88bWBVhodtnlfEuCcKWNGoc+uGbw1cwa9IKh/w==", - "dev": true, - "peer": true - }, - "node_modules/language-tags": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", - "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", - "dev": true, - "peer": true, - "dependencies": { - "language-subtag-registry": "^0.3.20" - }, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/lazy-cache": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", - "integrity": "sha512-RE2g0b5VGZsOCFOCgP7omTRYFqydmZkBwl5oNnQ1lDYC57uyO9KqNnNVxT7COSHTxrRCWVcAVOcbjk+tvh/rgQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/left-pad": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz", - "integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==", - "deprecated": "use String.prototype.padStart()", - "dev": true - }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", "integrity": "sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, - "node_modules/levn": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", - "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", - "dev": true, - "peer": true, - "dependencies": { - "prelude-ls": "^1.2.1", - "type-check": "~0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/lie": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", @@ -16314,46 +13797,6 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, - "node_modules/load-json-file": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", - "integrity": "sha512-cy7ZdNRXdablkXYNI049pthVeXFurRyb9+hA/dZzerZ0pGTx42z+y+ssxBaVV2l70t1muq5IdKhn4UtcoGUY9A==", - "dev": true, - "dependencies": { - "graceful-fs": "^4.1.2", - "parse-json": "^2.2.0", - "pify": "^2.0.0", - "pinkie-promise": "^2.0.0", - "strip-bom": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/load-json-file/node_modules/parse-json": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", - "integrity": "sha512-QR/GGaKCkhwk1ePQNYDRKYZ3mwU9ypsKhB0XyFnLQdomyEqk3e8wpW3V5Jp88zbxK4n5ST1nqo+g9juTpownhQ==", - "dev": true, - "dependencies": { - "error-ex": "^1.2.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/load-json-file/node_modules/strip-bom": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", - "integrity": "sha512-kwrX1y7czp1E69n2ajbG65mIo9dqvJ+8aBQXOGVxqwvNbsXdFM6Lq37dLAY3mknUwru8CfcCbfOLL/gMo+fi3g==", - "dev": true, - "dependencies": { - "is-utf8": "^0.2.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/loader-runner": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", @@ -16408,109 +13851,23 @@ "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==" }, - "node_modules/lodash._baseisequal": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/lodash._baseisequal/-/lodash._baseisequal-3.0.7.tgz", - "integrity": "sha512-U+3GsNEZj9ebI03ncLC2pLmYVjgtYZEwdkAPO7UGgtGvAz36JVFPAQUufpSaVL93Cz5arc6JGRKZRhaOhyVJYA==", - "dev": true, - "dependencies": { - "lodash.isarray": "^3.0.0", - "lodash.istypedarray": "^3.0.0", - "lodash.keys": "^3.0.0" - } - }, - "node_modules/lodash._bindcallback": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/lodash._bindcallback/-/lodash._bindcallback-3.0.1.tgz", - "integrity": "sha512-2wlI0JRAGX8WEf4Gm1p/mv/SZ+jLijpj0jyaE/AXeuQphzCgD8ZQW4oSpoN8JAopujOFGU3KMuq7qfHBWlGpjQ==", - "dev": true - }, - "node_modules/lodash._getnative": { - "version": "3.9.1", - "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz", - "integrity": "sha512-RrL9VxMEPyDMHOd9uFbvMe8X55X16/cGM5IgOKgRElQZutpX89iS6vwl64duTV1/16w5JY7tuFNXqoekmh1EmA==", - "dev": true - }, "node_modules/lodash._reinterpolate": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", "integrity": "sha512-xYHt68QRoYGjeeM/XOE1uJtvXQAgvszfBhjV4yvsQH0u2i9I6cI6c6/eG4Hh3UAOVn0y/xAXwmTzEay49Q//HA==", "license": "MIT" }, - "node_modules/lodash.camelcase": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", - "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" - }, "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, - "node_modules/lodash.escape": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", - "integrity": "sha512-nXEOnb/jK9g0DYMr1/Xvq6l5xMD7GDG55+GSYIYmS0G4tBk/hURD4JR9WCavs04t33WmJx9kCyp9vJ+mr4BOUw==", - "dev": true - }, - "node_modules/lodash.flattendeep": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", - "integrity": "sha512-uHaJFihxmJcEX3kT4I23ABqKKalJ/zDrDg0lsFtc1h+3uw49SIJ5beyhx5ExVRti3AvKoOJngIj7xz3oylPdWQ==", - "dev": true - }, - "node_modules/lodash.isarguments": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", - "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==", - "dev": true - }, - "node_modules/lodash.isarray": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz", - "integrity": "sha512-JwObCrNJuT0Nnbuecmqr5DgtuBppuCvGD9lxjFpAzwnVtdGoDQ1zig+5W8k5/6Gcn0gZ3936HDAlGd28i7sOGQ==", - "dev": true - }, - "node_modules/lodash.isequal": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", - "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", - "dev": true - }, - "node_modules/lodash.istypedarray": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/lodash.istypedarray/-/lodash.istypedarray-3.0.6.tgz", - "integrity": "sha512-lGWJ6N8AA3KSv+ZZxlTdn4f6A7kMfpJboeyvbFdE7IU9YAgweODqmOgdUHOA+c6lVWeVLysdaxciFXi+foVsWw==", - "dev": true - }, - "node_modules/lodash.keys": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", - "integrity": "sha512-CuBsapFjcubOGMn3VD+24HOAPxM79tH+V6ivJL3CHYjtrawauDJHUk//Yew9Hvc6e9rbCrURGk8z6PC+8WJBfQ==", + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", "dev": true, - "dependencies": { - "lodash._getnative": "^3.0.0", - "lodash.isarguments": "^3.0.0", - "lodash.isarray": "^3.0.0" - } - }, - "node_modules/lodash.memoize": { - "version": "4.1.2", - "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", - "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==" - }, - "node_modules/lodash.merge": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", - "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", - "dev": true, - "peer": true - }, - "node_modules/lodash.sortby": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", - "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", - "dev": true + "license": "MIT" }, "node_modules/lodash.template": { "version": "4.5.0", @@ -16537,11 +13894,6 @@ "integrity": "sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==", "dev": true }, - "node_modules/lodash.uniq": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", - "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==" - }, "node_modules/log-symbols": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.0.tgz", @@ -16582,20 +13934,6 @@ "semver": "bin/semver" } }, - "node_modules/lolex": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/lolex/-/lolex-1.6.0.tgz", - "integrity": "sha512-/bpxDL56TG5LS5zoXxKqA6Ro5tkOS5M8cm/7yQcwLIKIcM2HR5fjjNCaIhJNv96SEk4hNGSafYMZK42Xv5fihQ==", - "dev": true - }, - "node_modules/longest": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", - "integrity": "sha512-k+yt5n3l48JU4k8ftnKG6V7u32wyH2NfKzeMto9F/QRE0amxy/LayxwlvjjkZEIzqR+19IrtFO8p5kB9QaYUFg==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -16607,19 +13945,6 @@ "loose-envify": "cli.js" } }, - "node_modules/loud-rejection": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", - "integrity": "sha512-RPNliZOFkqFumDhvYqOaNY4Uz9oJM2K9tC6JWsJJsNdhuONW4LQHRBpb0qf4pJApVffI5N39SwzWZJuEhfd7eQ==", - "dev": true, - "dependencies": { - "currently-unhandled": "^0.4.1", - "signal-exit": "^3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -16628,6 +13953,14 @@ "yallist": "^3.0.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/mailto-link": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/mailto-link/-/mailto-link-1.0.0.tgz", @@ -16644,6 +13977,7 @@ "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", "dev": true, + "license": "MIT", "dependencies": { "semver": "^7.5.3" }, @@ -16654,26 +13988,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/make-dir/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/make-dir/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", "dev": true, - "dependencies": { - "lru-cache": "^6.0.0" - }, + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -16681,31 +14001,26 @@ "node": ">=10" } }, - "node_modules/make-dir/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "dev": true - }, "node_modules/make-fetch-happen": { - "version": "13.0.0", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-13.0.0.tgz", - "integrity": "sha512-7ThobcL8brtGo9CavByQrQi+23aIfgYU++wg4B87AIS8Rb2ZBt/MEaDqzA00Xwv/jUjAjYkLHjVolYuTLKda2A==", + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-14.0.3.tgz", + "integrity": "sha512-QMjGbFTP0blj97EeidG5hk/QhKQ3T4ICckQGLgz38QF7Vgbk6e6FTARN8KhKxyBbWn8R0HU+bnw8aSoFPD4qtQ==", + "license": "ISC", "dependencies": { - "@npmcli/agent": "^2.0.0", - "cacache": "^18.0.0", + "@npmcli/agent": "^3.0.0", + "cacache": "^19.0.1", "http-cache-semantics": "^4.1.1", - "is-lambda": "^1.0.1", "minipass": "^7.0.2", - "minipass-fetch": "^3.0.0", + "minipass-fetch": "^4.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", + "negotiator": "^1.0.0", + "proc-log": "^5.0.0", "promise-retry": "^2.0.1", - "ssri": "^10.0.0" + "ssri": "^12.0.0" }, "engines": { - "node": "^16.14.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/makeerror": { @@ -16758,10 +14073,14 @@ "css-mediaquery": "^0.1.2" } }, - "node_modules/math-expression-evaluator": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/math-expression-evaluator/-/math-expression-evaluator-1.4.0.tgz", - "integrity": "sha512-4vRUvPyxdO8cWULGTh9dZWL2tZK6LDBvj+OGHBER7poH9Qdt7kXEoj20wiz4lQUbUXQZFjPbe5mVDo9nutizCw==" + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } }, "node_modules/math-random": { "version": "1.0.4", @@ -16795,12 +14114,6 @@ "node": ">= 0.6" } }, - "node_modules/memory-fs": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.2.0.tgz", - "integrity": "sha512-+y4mDxU4rvXXu5UDSGCGNiesFmwCHuefGMoPCO1WYucNYj7DsLqrFaa2fXVI0H+NNiPTwwzKwspn9yTZqUGqng==", - "dev": true - }, "node_modules/meow": { "version": "13.2.0", "resolved": "https://registry.npmjs.org/meow/-/meow-13.2.0.tgz", @@ -16902,6 +14215,7 @@ "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } @@ -16910,25 +14224,10 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", - "dev": true, "engines": { "node": ">=4" } }, - "node_modules/mini-create-react-context": { - "version": "0.3.3", - "resolved": "https://registry.npmjs.org/mini-create-react-context/-/mini-create-react-context-0.3.3.tgz", - "integrity": "sha512-TtF6hZE59SGmS4U8529qB+jJFeW6asTLDIpPgvPLSCsooAwJS7QprHIFTqv9/Qh3NdLwQxFYgiHX5lqb6jqzPA==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "dependencies": { - "@babel/runtime": "^7.12.1", - "tiny-warning": "^1.0.3" - }, - "peerDependencies": { - "prop-types": "^15.0.0", - "react": "^0.14.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" - } - }, "node_modules/mini-css-extract-plugin": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/mini-css-extract-plugin/-/mini-css-extract-plugin-2.9.0.tgz", @@ -16992,9 +14291,10 @@ } }, "node_modules/minipass": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.0.4.tgz", - "integrity": "sha512-jYofLM5Dam9279rdkWzqHozUo4ybjdZmCsDHePy5V/PbBcVMiSZR97gmAy45aqi8CK1lG2ECd356FU86avfwUQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", + "license": "ISC", "engines": { "node": ">=16 || 14 >=14.17" } @@ -17003,6 +14303,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", + "license": "ISC", "dependencies": { "minipass": "^7.0.3" }, @@ -17011,16 +14312,17 @@ } }, "node_modules/minipass-fetch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.4.tgz", - "integrity": "sha512-jHAqnA728uUpIaFm7NWsCnqKT6UqZz7GcI/bDpPATuwYyKwJwW0remxSCxUlKiEty+eopHGa3oc8WxgQ1FFJqg==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-4.0.1.tgz", + "integrity": "sha512-j7U11C5HXigVuutxebFadoYBbd7VSdZWggSe64NVdvWNBqGAiXPL2QVCehjmw7lY1oF9gOllYbORh+hiNgfPgQ==", + "license": "MIT", "dependencies": { "minipass": "^7.0.3", "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" + "minizlib": "^3.0.1" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" }, "optionalDependencies": { "encoding": "^0.1.13" @@ -17030,6 +14332,7 @@ "version": "1.0.5", "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", + "license": "ISC", "dependencies": { "minipass": "^3.0.0" }, @@ -17041,6 +14344,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -17051,12 +14355,14 @@ "node_modules/minipass-flush/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" }, "node_modules/minipass-pipeline": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", + "license": "ISC", "dependencies": { "minipass": "^3.0.0" }, @@ -17068,6 +14374,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -17078,12 +14385,14 @@ "node_modules/minipass-pipeline/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" }, "node_modules/minipass-sized": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", + "license": "ISC", "dependencies": { "minipass": "^3.0.0" }, @@ -17095,6 +14404,7 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", "dependencies": { "yallist": "^4.0.0" }, @@ -17105,35 +14415,80 @@ "node_modules/minipass-sized/node_modules/yallist": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" }, "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.1.tgz", + "integrity": "sha512-umcy022ILvb5/3Djuu8LWeqUa8D68JaBzlttKeMWen48SjabqS3iY5w/vzeMzMUNhLDifyhbOwKDSznB1vvrwg==", + "license": "MIT", "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" + "minipass": "^7.0.4", + "rimraf": "^5.0.5" }, "engines": { - "node": ">= 8" + "node": ">= 18" } }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "node_modules/minizlib/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", "dependencies": { - "yallist": "^4.0.0" + "balanced-match": "^1.0.0" + } + }, + "node_modules/minizlib/node_modules/glob": { + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" + }, + "bin": { + "glob": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/minizlib/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minizlib/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "node_modules/minizlib/node_modules/rimraf": { + "version": "5.0.10", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.10.tgz", + "integrity": "sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==", + "license": "ISC", + "dependencies": { + "glob": "^10.3.7" + }, + "bin": { + "rimraf": "dist/esm/bin.mjs" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } }, "node_modules/mixin-deep": { "version": "1.3.2", @@ -17202,9 +14557,9 @@ } }, "node_modules/moment-timezone": { - "version": "0.5.46", - "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.46.tgz", - "integrity": "sha512-ZXm9b36esbe7OmdABqIWJuBBiLLwAjrN7CE+7sYdCCx82Nabt1wHDj8TVseS59QIlfFPbOoiBPm6ca9BioG4hw==", + "version": "0.5.47", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.47.tgz", + "integrity": "sha512-UbNt/JAWS0m/NJOebR0QMRHBk0hu03r5dx9GK8Cs0AS3I81yDcOc9k+DytPItgVvBP7J6Mf6U2n3BPAacAV9oA==", "license": "MIT", "dependencies": { "moment": "^2.29.4" @@ -17213,12 +14568,6 @@ "node": "*" } }, - "node_modules/moo": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", - "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", - "dev": true - }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -17231,18 +14580,10 @@ "integrity": "sha512-EbrziT4s8cWPmzr47eYVW3wimS4HsvlnV5ri1xw1aR6JQo/OrJX5rkl32K/QQHdxeabJETtfeaROGhd8W7uBgg==", "dev": true }, - "node_modules/nan": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.19.0.tgz", - "integrity": "sha512-nO1xXxfh/RWNxfd/XPfbIfFk5vgLsAxUR9y5O0cHMJu/AW9U95JLXqthYHjEp+8gQ5p96K9jUp8nbVOxCdRbtw==", - "dev": true, - "optional": true - }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", - "dev": true, + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", @@ -17305,52 +14646,17 @@ "node": ">=0.10.0" } }, - "node_modules/native-promise-only": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/native-promise-only/-/native-promise-only-0.8.1.tgz", - "integrity": "sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==", - "dev": true - }, "node_modules/natural-compare": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", "dev": true }, - "node_modules/natural-compare-lite": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/natural-compare-lite/-/natural-compare-lite-1.4.0.tgz", - "integrity": "sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==", - "dev": true, - "license": "MIT", - "peer": true - }, - "node_modules/nearley": { - "version": "2.20.1", - "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", - "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", - "dev": true, - "dependencies": { - "commander": "^2.19.0", - "moo": "^0.5.0", - "railroad-diagrams": "^1.0.0", - "randexp": "0.4.6" - }, - "bin": { - "nearley-railroad": "bin/nearley-railroad.js", - "nearley-test": "bin/nearley-test.js", - "nearley-unparse": "bin/nearley-unparse.js", - "nearleyc": "bin/nearleyc.js" - }, - "funding": { - "type": "individual", - "url": "https://nearley.js.org/#give-to-nearley" - } - }, "node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", "engines": { "node": ">= 0.6" } @@ -17366,85 +14672,96 @@ "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "dev": true }, - "node_modules/nice-try": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==", - "dev": true - }, - "node_modules/node-fetch": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz", - "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==", + "node_modules/nise": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/nise/-/nise-6.1.1.tgz", + "integrity": "sha512-aMSAzLVY7LyeM60gvBS423nBmIPP+Wy7St7hsb+8/fc1HmeoHJfLO8CKse4u3BtOZvQLJghYPI2i/1WZrEj5/g==", + "dev": true, + "license": "BSD-3-Clause", "dependencies": { - "encoding": "^0.1.11", - "is-stream": "^1.0.1" + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.1", + "@sinonjs/text-encoding": "^0.7.3", + "just-extend": "^6.2.0", + "path-to-regexp": "^8.1.0" } }, - "node_modules/node-fetch/node_modules/is-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", - "engines": { - "node": ">=0.10.0" + "node_modules/nise/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" } }, + "node_modules/nise/node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "license": "MIT", + "optional": true + }, "node_modules/node-gyp": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-10.0.1.tgz", - "integrity": "sha512-gg3/bHehQfZivQVfqIyy8wTdSymF9yTyP4CJifK73imyNMU8AIGQE2pUa7dNWfmMeG9cDVF2eehiRMv0LC1iAg==", + "version": "11.1.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-11.1.0.tgz", + "integrity": "sha512-/+7TuHKnBpnMvUQnsYEb0JOozDZqarQbfNuSGLXIjhStMT0fbw7IdSqWgopOP5xhRZE+lsbIvAHcekddruPZgQ==", + "license": "MIT", "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "glob": "^10.3.10", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^13.0.0", - "nopt": "^7.0.0", - "proc-log": "^3.0.0", + "make-fetch-happen": "^14.0.3", + "nopt": "^8.0.0", + "proc-log": "^5.0.0", "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^4.0.0" + "tar": "^7.4.3", + "which": "^5.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": "^16.14.0 || >=18.0.0" - } - }, - "node_modules/node-gyp/node_modules/abbrev": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-2.0.0.tgz", - "integrity": "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ==", - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/node-gyp/node_modules/brace-expansion": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" } }, "node_modules/node-gyp/node_modules/glob": { - "version": "10.3.10", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.10.tgz", - "integrity": "sha512-fa46+tv1Ak0UPK1TOy/pZrIybNNt4HCv7SDzwyfiOZkvZLEbjsZkJBPtDHVshZjbecAoAGSC20MjLDG/qr679g==", + "version": "10.4.5", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", + "integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==", + "license": "ISC", "dependencies": { "foreground-child": "^3.1.0", - "jackspeak": "^2.3.5", - "minimatch": "^9.0.1", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0", - "path-scurry": "^1.10.1" + "jackspeak": "^3.1.2", + "minimatch": "^9.0.4", + "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", + "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, "funding": { "url": "https://github.com/sponsors/isaacs" } @@ -17453,25 +14770,16 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", + "license": "ISC", "engines": { "node": ">=16" } }, - "node_modules/node-gyp/node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, "node_modules/node-gyp/node_modules/minimatch": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.3.tgz", - "integrity": "sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==", + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -17482,27 +14790,11 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/node-gyp/node_modules/nopt": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.0.tgz", - "integrity": "sha512-CVDtwCdhYIvnAzFoJ6NJ6dX3oga9/HyciQDnG1vQDjSLMeKLJ4A93ZqYKDrgYSr1FBY5/hMYC+2VCi24pgpkGA==", - "dependencies": { - "abbrev": "^2.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/node-gyp/node_modules/semver": { - "version": "7.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz", - "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "license": "ISC", "bin": { "semver": "bin/semver.js" }, @@ -17511,9 +14803,10 @@ } }, "node_modules/node-gyp/node_modules/which": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz", - "integrity": "sha512-GlaYyEb07DPxYCKhKzplCWBJtvxZcZMrL+4UkrTSJHHPyZU4mYYTv3qaOe77H7EODLSSopAUFAc6W8U4yqvscg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", + "integrity": "sha512-JEdGzHwwkrbWoGOlIHqQ5gtprKGOenpDHpxE9zVR1bWbOtYRyPPHMe9FaP6x61CmNaTThSkb0DAJte5jD+DmzQ==", + "license": "ISC", "dependencies": { "isexe": "^3.1.1" }, @@ -17521,14 +14814,9 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, - "node_modules/node-gyp/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, "node_modules/node-int64": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz", @@ -17541,6 +14829,7 @@ "integrity": "sha512-oJP/9NAdd9+x2Q+rfphB2RJCHjod70RcRLjosiPMMu5gjIfwVnOUGq2nbTjTUbmy0DJ/tFIVT30+Qe3nzl4TJg==", "dev": true, "optional": true, + "peer": true, "dependencies": { "growly": "^1.3.0", "is-wsl": "^2.2.0", @@ -17556,6 +14845,7 @@ "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", "dev": true, "optional": true, + "peer": true, "dependencies": { "yallist": "^4.0.0" }, @@ -17569,6 +14859,7 @@ "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==", "dev": true, "optional": true, + "peer": true, "dependencies": { "lru-cache": "^6.0.0" }, @@ -17584,7 +14875,8 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "node_modules/node-releases": { "version": "2.0.18", @@ -17593,76 +14885,25 @@ "license": "MIT" }, "node_modules/nopt": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", - "integrity": "sha512-4GUt3kSEYmk4ITxzB/b9vaIDfUVWN/Ml1Fwl11IlnIG2iaJ9O6WXZ9SrYM9NLI8OCBieN2Y8SWC2oJV0RQ7qYg==", - "dev": true, + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-8.1.0.tgz", + "integrity": "sha512-ieGu42u/Qsa4TFktmaKEwM6MQH0pOWnaB3htzh0JRtx84+Mebc0cbZYN5bC+6WTZ4+77xrL9Pn5m7CV6VIkV7A==", + "license": "ISC", "dependencies": { - "abbrev": "1" + "abbrev": "^3.0.0" }, "bin": { "nopt": "bin/nopt.js" - } - }, - "node_modules/normalize-package-data": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.5.0.tgz", - "integrity": "sha512-/5CMN3T0R4XTj4DcGaexo+roZSdSFW/0AOOTROrjxzCG1wrWXEsGbRKevjlIL+ZDE4sZlJr5ED4YW0yqmkK+eA==", - "dev": true, - "dependencies": { - "hosted-git-info": "^2.1.4", - "resolve": "^1.10.0", - "semver": "2 || 3 || 4 || 5", - "validate-npm-package-license": "^3.0.1" - } - }, - "node_modules/normalize-package-data/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "bin": { - "semver": "bin/semver" + }, + "engines": { + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-range": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", - "integrity": "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/normalize-url": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-1.9.1.tgz", - "integrity": "sha512-A48My/mtCklowHBlI8Fq2jFWK4tX4lJ5E6ytFsSOq1fzpvT0SQSgKhSg7lN5c2uYFOrUAOQp6zhhJnpp1eMloQ==", - "dependencies": { - "object-assign": "^4.0.1", - "prepend-http": "^1.0.0", - "query-string": "^4.1.0", - "sort-keys": "^1.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/normalize-url/node_modules/query-string": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/query-string/-/query-string-4.3.4.tgz", - "integrity": "sha512-O2XLNDBIg1DnTOa+2XrIwSiXEV8h2KImXUnjhhn2+UsvZ+Es2uyd5CCRTNQlDGbzUQOW3aYCBx9rVA6dzsiY7Q==", - "dependencies": { - "object-assign": "^4.1.0", - "strict-uri-encode": "^1.0.0" - }, + "dev": true, "engines": { "node": ">=0.10.0" } @@ -17672,6 +14913,7 @@ "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", "dev": true, + "license": "MIT", "dependencies": { "path-key": "^3.0.0" }, @@ -17679,32 +14921,6 @@ "node": ">=8" } }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/null-check": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/null-check/-/null-check-1.0.0.tgz", - "integrity": "sha512-j8ZNHg19TyIQOWCGeeQJBuu6xZYIEurf8M1Qsfd8mFrGEfIZytbw18YjKWg+LcO25NowXGZXZpKAx+Ui3TFfDw==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/num2fraction": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz", - "integrity": "sha512-Y1wZESM7VUThYY+4W+X4ySH2maqcA+p7UR+w8VWNWVAd6lwuXXWz/w/Cz43J/dI2I+PS6wD5N+bJUF+gjWvIqg==" - }, "node_modules/number-is-nan": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", @@ -17719,15 +14935,6 @@ "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.7.tgz", "integrity": "sha512-ub5E4+FBPKwAZx0UwIQOjYWGHTEq5sPqHQNRN8Z9e4A7u3Tj1weLJsL59yH9vmvqEtBHaOmT6cYQKIZOxp35FQ==" }, - "node_modules/oauth-sign": { - "version": "0.9.0", - "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", - "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", - "dev": true, - "engines": { - "node": "*" - } - }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -17799,9 +15006,12 @@ "integrity": "sha512-NahvP2vZcy1ZiiYah30CEPw0FpDcSkSePJBMpzl5EQgCmISijiGuJm3SPYp7U+Lf2TljyaIw3E5EgkEx/TNEVA==" }, "node_modules/object-inspect": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.1.tgz", - "integrity": "sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -17880,57 +15090,6 @@ "node": ">= 0.4" } }, - "node_modules/object.fromentries": { - "version": "2.0.8", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", - "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/object.groupby": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", - "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/object.hasown": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.4.tgz", - "integrity": "sha512-FZ9LZt9/RHzGySlBARE3VF+gE26TxR38SdmqOqliuTnl9wrKulaQs+4dee1V+Io8VfxqzAfHu6YuRgUy8OHoTg==", - "dev": true, - "peer": true, - "dependencies": { - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/object.omit": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/object.omit/-/object.omit-2.0.1.tgz", @@ -17965,23 +15124,6 @@ "node": ">=0.10.0" } }, - "node_modules/object.values": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", - "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", - "dev": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-object-atoms": "^1.0.0" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/on-finished": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", @@ -18007,6 +15149,7 @@ "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", "dev": true, + "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" }, @@ -18042,24 +15185,6 @@ "node": ">=0.4.0" } }, - "node_modules/optionator": { - "version": "0.9.3", - "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.3.tgz", - "integrity": "sha512-JjCoypp+jKn1ttEFExxhetCKeJt9zhAgAve5FXHixTvFDW/5aEktX9bufBKLRRMdU7bNtpLfcGu94B3cdEJgjg==", - "dev": true, - "peer": true, - "dependencies": { - "@aashutoshrathi/word-wrap": "^1.2.3", - "deep-is": "^0.1.3", - "fast-levenshtein": "^2.0.6", - "levn": "^0.4.1", - "prelude-ls": "^1.2.1", - "type-check": "^0.4.0" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/options": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/options/-/options-0.0.6.tgz", @@ -18087,27 +15212,6 @@ "node": ">=0.10.0" } }, - "node_modules/p-each-series": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/p-each-series/-/p-each-series-2.2.0.tgz", - "integrity": "sha512-ycIL2+1V32th+8scbpTvyHNaHe02z0sjgh91XXjAk+ZeXoPN4Z46DVUnzdso0aX4KckKw0FNNFHdjZ2UsZvxiA==", - "dev": true, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/p-finally": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", - "integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==", - "dev": true, - "engines": { - "node": ">=4" - } - }, "node_modules/p-limit": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", @@ -18136,14 +15240,12 @@ } }, "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dependencies": { - "aggregate-error": "^3.0.0" - }, + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.3.tgz", + "integrity": "sha512-VkndIv2fIB99swvQoA65bm+fsmt6UNdGeIB0oxBs+WhAhdh08QA04JXpI7rbB9r08/nkbysKoya9rtDERYOYMA==", + "license": "MIT", "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" @@ -18158,6 +15260,12 @@ "node": ">=6" } }, + "node_modules/package-json-from-dist": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz", + "integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==", + "license": "BlueOak-1.0.0" + }, "node_modules/pako": { "version": "1.0.11", "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", @@ -18249,7 +15357,6 @@ "version": "7.1.2", "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", - "dev": true, "dependencies": { "entities": "^4.4.0" }, @@ -18257,19 +15364,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", - "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", - "dev": true, - "dependencies": { - "domhandler": "^5.0.2", - "parse5": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/parsejson": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/parsejson/-/parsejson-0.0.3.tgz", @@ -18352,15 +15446,16 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==" }, "node_modules/path-scurry": { - "version": "1.10.1", - "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.1.tgz", - "integrity": "sha512-MkhCqzzBEpPvxxQ71Md0b1Kk51W01lrYvlMzSUaIzNsODdd7mqhiimSZlr+VegAz5Z6Vzt9Xg2ttE//XBhH3EQ==", + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", + "integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==", + "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^9.1.1 || ^10.0.0", + "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" }, "engines": { - "node": ">=16 || 14 >=14.17" + "node": ">=16 || 14 >=14.18" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -18375,9 +15470,9 @@ } }, "node_modules/path-to-regexp": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", - "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", + "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", "dependencies": { "isarray": "0.0.1" } @@ -18391,12 +15486,6 @@ "node": ">=8" } }, - "node_modules/performance-now": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", - "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", - "dev": true - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -18414,45 +15503,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/picturefill": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/picturefill/-/picturefill-3.0.3.tgz", - "integrity": "sha512-JDdx+3i4fs2pkqwWZJgGEM2vFWsq+01YsQFT9CKPGuv2Q0xSdrQZoxi9XwyNARTgxiOdgoAwWQRluLRe/JQX2g==", - "deprecated": "Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.", - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pinkie": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", - "integrity": "sha512-MnUuEycAemtSaeFSjXKW/aroV7akBbY+Sv+RkyqFjgAe73F+MR0TBWKBRDkmfWq/HiFmdavfZ1G7h4SPZXaCSg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pinkie-promise": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", - "integrity": "sha512-0Gni6D4UcLTbv9c57DfxDGdr41XfgUjqWZu492f0cIGr16zDU06BWP/RAEvOuo7CQ0CNjHaLlM59YJJFm3NWlw==", - "dev": true, - "dependencies": { - "pinkie": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/pirates": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.6.tgz", @@ -19077,12 +16127,6 @@ "integrity": "sha512-TH+BeeL6Ct98C7as35JbZLf8lgsRzlNJb5gklRIGHKaPkGl1esOKBc5ALUMd+q08Sr6tiEKM+Icbsxg5vuhMKQ==", "dev": true }, - "node_modules/pn": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/pn/-/pn-1.1.0.tgz", - "integrity": "sha512-2qHaIQr2VLRFoxe2nASzsV6ef4yOOH+Fi9FBOVH6cqeSgUnoyySPZkxzLuzd+RYOQTRpROA0ztTMqxROKSb/nA==", - "dev": true - }, "node_modules/point-in-polygon": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/point-in-polygon/-/point-in-polygon-0.0.0.tgz", @@ -19128,335 +16172,12 @@ "node": ">= 0.4" } }, - "node_modules/postcss": { - "version": "5.2.18", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz", - "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==", - "dependencies": { - "chalk": "^1.1.3", - "js-base64": "^2.1.9", - "source-map": "^0.5.6", - "supports-color": "^3.2.3" - }, - "engines": { - "node": ">=0.12" - } - }, - "node_modules/postcss-calc": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/postcss-calc/-/postcss-calc-5.3.1.tgz", - "integrity": "sha512-iBcptYFq+QUh9gzP7ta2btw50o40s4uLI4UDVgd5yRAZtUDWc5APdl5yQDd2h/TyiZNbJrv0HiYhT102CMgN7Q==", - "dependencies": { - "postcss": "^5.0.2", - "postcss-message-helpers": "^2.0.0", - "reduce-css-calc": "^1.2.6" - } - }, - "node_modules/postcss-colormin": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/postcss-colormin/-/postcss-colormin-2.2.2.tgz", - "integrity": "sha512-XXitQe+jNNPf+vxvQXIQ1+pvdQKWKgkx8zlJNltcMEmLma1ypDRDQwlLt+6cP26fBreihNhZxohh1rcgCH2W5w==", - "dependencies": { - "colormin": "^1.0.5", - "postcss": "^5.0.13", - "postcss-value-parser": "^3.2.3" - } - }, - "node_modules/postcss-convert-values": { - "version": "2.6.1", - "resolved": "https://registry.npmjs.org/postcss-convert-values/-/postcss-convert-values-2.6.1.tgz", - "integrity": "sha512-SE7mf25D3ORUEXpu3WUqQqy0nCbMuM5BEny+ULE/FXdS/0UMA58OdzwvzuHJRpIFlk1uojt16JhaEogtP6W2oA==", - "dependencies": { - "postcss": "^5.0.11", - "postcss-value-parser": "^3.1.2" - } - }, - "node_modules/postcss-discard-comments": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/postcss-discard-comments/-/postcss-discard-comments-2.0.4.tgz", - "integrity": "sha512-yGbyBDo5FxsImE90LD8C87vgnNlweQkODMkUZlDVM/CBgLr9C5RasLGJxxh9GjVOBeG8NcCMatoqI1pXg8JNXg==", - "dependencies": { - "postcss": "^5.0.14" - } - }, - "node_modules/postcss-discard-duplicates": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/postcss-discard-duplicates/-/postcss-discard-duplicates-2.1.0.tgz", - "integrity": "sha512-+lk5W1uqO8qIUTET+UETgj9GWykLC3LOldr7EehmymV0Wu36kyoHimC4cILrAAYpHQ+fr4ypKcWcVNaGzm0reA==", - "dependencies": { - "postcss": "^5.0.4" - } - }, - "node_modules/postcss-discard-empty": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/postcss-discard-empty/-/postcss-discard-empty-2.1.0.tgz", - "integrity": "sha512-IBFoyrwk52dhF+5z/ZAbzq5Jy7Wq0aLUsOn69JNS+7YeuyHaNzJwBIYE0QlUH/p5d3L+OON72Fsexyb7OK/3og==", - "dependencies": { - "postcss": "^5.0.14" - } - }, - "node_modules/postcss-discard-overridden": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/postcss-discard-overridden/-/postcss-discard-overridden-0.1.1.tgz", - "integrity": "sha512-IyKoDL8QNObOiUc6eBw8kMxBHCfxUaERYTUe2QF8k7j/xiirayDzzkmlR6lMQjrAM1p1DDRTvWrS7Aa8lp6/uA==", - "dependencies": { - "postcss": "^5.0.16" - } - }, - "node_modules/postcss-discard-unused": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/postcss-discard-unused/-/postcss-discard-unused-2.2.3.tgz", - "integrity": "sha512-nCbFNfqYAbKCw9J6PSJubpN9asnrwVLkRDFc4KCwyUEdOtM5XDE/eTW3OpqHrYY1L4fZxgan7LLRAAYYBzwzrg==", - "dependencies": { - "postcss": "^5.0.14", - "uniqs": "^2.0.0" - } - }, - "node_modules/postcss-filter-plugins": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/postcss-filter-plugins/-/postcss-filter-plugins-2.0.3.tgz", - "integrity": "sha512-T53GVFsdinJhgwm7rg1BzbeBRomOg9y5MBVhGcsV0CxurUdVj1UlPdKtn7aqYA/c/QVkzKMjq2bSV5dKG5+AwQ==", - "dependencies": { - "postcss": "^5.0.4" - } - }, "node_modules/postcss-media-query-parser": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/postcss-media-query-parser/-/postcss-media-query-parser-0.2.3.tgz", "integrity": "sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==", "dev": true }, - "node_modules/postcss-merge-idents": { - "version": "2.1.7", - "resolved": "https://registry.npmjs.org/postcss-merge-idents/-/postcss-merge-idents-2.1.7.tgz", - "integrity": "sha512-9DHmfCZ7/hNHhIKnNkz4CU0ejtGen5BbTRJc13Z2uHfCedeCUsK2WEQoAJRBL+phs68iWK6Qf8Jze71anuysWA==", - "dependencies": { - "has": "^1.0.1", - "postcss": "^5.0.10", - "postcss-value-parser": "^3.1.1" - } - }, - "node_modules/postcss-merge-longhand": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/postcss-merge-longhand/-/postcss-merge-longhand-2.0.2.tgz", - "integrity": "sha512-ma7YvxjdLQdifnc1HFsW/AW6fVfubGyR+X4bE3FOSdBVMY9bZjKVdklHT+odknKBB7FSCfKIHC3yHK7RUAqRPg==", - "dependencies": { - "postcss": "^5.0.4" - } - }, - "node_modules/postcss-merge-rules": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/postcss-merge-rules/-/postcss-merge-rules-2.1.2.tgz", - "integrity": "sha512-Wgg2FS6W3AYBl+5L9poL6ZUISi5YzL+sDCJfM7zNw/Q1qsyVQXXZ2cbVui6mu2cYJpt1hOKCGj1xA4mq/obz/Q==", - "dependencies": { - "browserslist": "^1.5.2", - "caniuse-api": "^1.5.2", - "postcss": "^5.0.4", - "postcss-selector-parser": "^2.2.2", - "vendors": "^1.0.0" - } - }, - "node_modules/postcss-merge-rules/node_modules/browserslist": { - "version": "1.7.7", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-1.7.7.tgz", - "integrity": "sha512-qHJblDE2bXVRYzuDetv/wAeHOJyO97+9wxC1cdCtyzgNuSozOyRCiiLaCR1f71AN66lQdVVBipWm63V+a7bPOw==", - "deprecated": "Browserslist 2 could fail on reading Browserslist >3.0 config used in other tools.", - "dependencies": { - "caniuse-db": "^1.0.30000639", - "electron-to-chromium": "^1.2.7" - }, - "bin": { - "browserslist": "cli.js" - } - }, - "node_modules/postcss-message-helpers": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/postcss-message-helpers/-/postcss-message-helpers-2.0.0.tgz", - "integrity": "sha512-tPLZzVAiIJp46TBbpXtrUAKqedXSyW5xDEo1sikrfEfnTs+49SBZR/xDdqCiJvSSbtr615xDsaMF3RrxS2jZlA==" - }, - "node_modules/postcss-minify-font-values": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/postcss-minify-font-values/-/postcss-minify-font-values-1.0.5.tgz", - "integrity": "sha512-vFSPzrJhNe6/8McOLU13XIsERohBJiIFFuC1PolgajOZdRWqRgKITP/A4Z/n4GQhEmtbxmO9NDw3QLaFfE1dFQ==", - "dependencies": { - "object-assign": "^4.0.1", - "postcss": "^5.0.4", - "postcss-value-parser": "^3.0.2" - } - }, - "node_modules/postcss-minify-gradients": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/postcss-minify-gradients/-/postcss-minify-gradients-1.0.5.tgz", - "integrity": "sha512-DZhT0OE+RbVqVyGsTIKx84rU/5cury1jmwPa19bViqYPQu499ZU831yMzzsyC8EhiZVd73+h5Z9xb/DdaBpw7Q==", - "dependencies": { - "postcss": "^5.0.12", - "postcss-value-parser": "^3.3.0" - } - }, - "node_modules/postcss-minify-params": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/postcss-minify-params/-/postcss-minify-params-1.2.2.tgz", - "integrity": "sha512-hhJdMVgP8vasrHbkKAk+ab28vEmPYgyuDzRl31V3BEB3QOR3L5TTIVEWLDNnZZ3+fiTi9d6Ker8GM8S1h8p2Ow==", - "dependencies": { - "alphanum-sort": "^1.0.1", - "postcss": "^5.0.2", - "postcss-value-parser": "^3.0.2", - "uniqs": "^2.0.0" - } - }, - "node_modules/postcss-minify-selectors": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/postcss-minify-selectors/-/postcss-minify-selectors-2.1.1.tgz", - "integrity": "sha512-e13vxPBSo3ZaPne43KVgM+UETkx3Bs4/Qvm6yXI9HQpQp4nyb7HZ0gKpkF+Wn2x+/dbQ+swNpCdZSbMOT7+TIA==", - "dependencies": { - "alphanum-sort": "^1.0.2", - "has": "^1.0.1", - "postcss": "^5.0.14", - "postcss-selector-parser": "^2.0.0" - } - }, - "node_modules/postcss-modules-extract-imports": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/postcss-modules-extract-imports/-/postcss-modules-extract-imports-1.2.1.tgz", - "integrity": "sha512-6jt9XZwUhwmRUhb/CkyJY020PYaPJsCyt3UjbaWo6XEbH/94Hmv6MP7fG2C5NDU/BcHzyGYxNtHvM+LTf9HrYw==", - "dependencies": { - "postcss": "^6.0.1" - } - }, - "node_modules/postcss-modules-extract-imports/node_modules/postcss": { - "version": "6.0.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", - "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", - "dependencies": { - "chalk": "^2.4.1", - "source-map": "^0.6.1", - "supports-color": "^5.4.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/postcss-modules-local-by-default": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/postcss-modules-local-by-default/-/postcss-modules-local-by-default-1.2.0.tgz", - "integrity": "sha512-X4cquUPIaAd86raVrBwO8fwRfkIdbwFu7CTfEOjiZQHVQwlHRSkTgH5NLDmMm5+1hQO8u6dZ+TOOJDbay1hYpA==", - "dependencies": { - "css-selector-tokenizer": "^0.7.0", - "postcss": "^6.0.1" - } - }, - "node_modules/postcss-modules-local-by-default/node_modules/postcss": { - "version": "6.0.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", - "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", - "dependencies": { - "chalk": "^2.4.1", - "source-map": "^0.6.1", - "supports-color": "^5.4.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/postcss-modules-scope": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/postcss-modules-scope/-/postcss-modules-scope-1.1.0.tgz", - "integrity": "sha512-LTYwnA4C1He1BKZXIx1CYiHixdSe9LWYVKadq9lK5aCCMkoOkFyZ7aigt+srfjlRplJY3gIol6KUNefdMQJdlw==", - "dependencies": { - "css-selector-tokenizer": "^0.7.0", - "postcss": "^6.0.1" - } - }, - "node_modules/postcss-modules-scope/node_modules/postcss": { - "version": "6.0.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", - "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", - "dependencies": { - "chalk": "^2.4.1", - "source-map": "^0.6.1", - "supports-color": "^5.4.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/postcss-modules-values": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/postcss-modules-values/-/postcss-modules-values-1.3.0.tgz", - "integrity": "sha512-i7IFaR9hlQ6/0UgFuqM6YWaCfA1Ej8WMg8A5DggnH1UGKJvTV/ugqq/KaULixzzOi3T/tF6ClBXcHGCzdd5unA==", - "dependencies": { - "icss-replace-symbols": "^1.1.0", - "postcss": "^6.0.1" - } - }, - "node_modules/postcss-modules-values/node_modules/postcss": { - "version": "6.0.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", - "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", - "dependencies": { - "chalk": "^2.4.1", - "source-map": "^0.6.1", - "supports-color": "^5.4.0" - }, - "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/postcss-normalize-charset": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/postcss-normalize-charset/-/postcss-normalize-charset-1.1.1.tgz", - "integrity": "sha512-RKgjEks83l8w4yEhztOwNZ+nLSrJ+NvPNhpS+mVDzoaiRHZQVoG7NF2TP5qjwnaN9YswUhj6m1E0S0Z+WDCgEQ==", - "dependencies": { - "postcss": "^5.0.5" - } - }, - "node_modules/postcss-normalize-url": { - "version": "3.0.8", - "resolved": "https://registry.npmjs.org/postcss-normalize-url/-/postcss-normalize-url-3.0.8.tgz", - "integrity": "sha512-WqtWG6GV2nELsQEFES0RzfL2ebVwmGl/M8VmMbshKto/UClBo+mznX8Zi4/hkThdqx7ijwv+O8HWPdpK7nH/Ig==", - "dependencies": { - "is-absolute-url": "^2.0.0", - "normalize-url": "^1.4.0", - "postcss": "^5.0.14", - "postcss-value-parser": "^3.2.3" - } - }, - "node_modules/postcss-ordered-values": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/postcss-ordered-values/-/postcss-ordered-values-2.2.3.tgz", - "integrity": "sha512-5RB1IUZhkxDCfa5fx/ogp/A82mtq+r7USqS+7zt0e428HJ7+BHCxyeY39ClmkkUtxdOd3mk8gD6d9bjH2BECMg==", - "dependencies": { - "postcss": "^5.0.4", - "postcss-value-parser": "^3.0.1" - } - }, - "node_modules/postcss-reduce-idents": { - "version": "2.4.0", - "resolved": "https://registry.npmjs.org/postcss-reduce-idents/-/postcss-reduce-idents-2.4.0.tgz", - "integrity": "sha512-0+Ow9e8JLtffjumJJFPqvN4qAvokVbdQPnijUDSOX8tfTwrILLP4ETvrZcXZxAtpFLh/U0c+q8oRMJLr1Kiu4w==", - "dependencies": { - "postcss": "^5.0.4", - "postcss-value-parser": "^3.0.2" - } - }, - "node_modules/postcss-reduce-initial": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/postcss-reduce-initial/-/postcss-reduce-initial-1.0.1.tgz", - "integrity": "sha512-jJFrV1vWOPCQsIVitawGesRgMgunbclERQ/IRGW7r93uHrVzNQQmHQ7znsOIjJPZ4yWMzs5A8NFhp3AkPHPbDA==", - "dependencies": { - "postcss": "^5.0.4" - } - }, - "node_modules/postcss-reduce-transforms": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/postcss-reduce-transforms/-/postcss-reduce-transforms-1.0.4.tgz", - "integrity": "sha512-lGgRqnSuAR5i5uUg1TA33r9UngfTadWxOyL2qx1KuPoCQzfmtaHjp9PuwX7yVyRxG3BWBzeFUaS5uV9eVgnEgQ==", - "dependencies": { - "has": "^1.0.1", - "postcss": "^5.0.8", - "postcss-value-parser": "^3.0.1" - } - }, "node_modules/postcss-resolve-nested-selector": { "version": "0.1.6", "resolved": "https://registry.npmjs.org/postcss-resolve-nested-selector/-/postcss-resolve-nested-selector-0.1.6.tgz", @@ -19464,147 +16185,6 @@ "dev": true, "license": "MIT" }, - "node_modules/postcss-selector-parser": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-2.2.3.tgz", - "integrity": "sha512-3pqyakeGhrO0BQ5+/tGTfvi5IAUAhHRayGK8WFSu06aEv2BmHoXw/Mhb+w7VY5HERIuC+QoUI7wgrCcq2hqCVA==", - "dependencies": { - "flatten": "^1.0.2", - "indexes-of": "^1.0.1", - "uniq": "^1.0.1" - } - }, - "node_modules/postcss-svgo": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/postcss-svgo/-/postcss-svgo-2.1.6.tgz", - "integrity": "sha512-y5AdQdgBoF4rbpdbeWAJuxE953g/ylRfVNp6mvAi61VCN/Y25Tu9p5mh3CyI42WbTRIiwR9a1GdFtmDnNPeskQ==", - "dependencies": { - "is-svg": "^2.0.0", - "postcss": "^5.0.14", - "postcss-value-parser": "^3.2.3", - "svgo": "^0.7.0" - } - }, - "node_modules/postcss-unique-selectors": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/postcss-unique-selectors/-/postcss-unique-selectors-2.0.2.tgz", - "integrity": "sha512-WZX8r1M0+IyljoJOJleg3kYm10hxNYF9scqAT7v/xeSX1IdehutOM85SNO0gP9K+bgs86XERr7Ud5u3ch4+D8g==", - "dependencies": { - "alphanum-sort": "^1.0.1", - "postcss": "^5.0.4", - "uniqs": "^2.0.0" - } - }, - "node_modules/postcss-value-parser": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.1.tgz", - "integrity": "sha512-pISE66AbVkp4fDQ7VHBwRNXzAAKJjw4Vw7nWI/+Q3vuly7SNfgYXvm6i5IgFylHGK5sP/xHAbB7N49OS4gWNyQ==" - }, - "node_modules/postcss-zindex": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/postcss-zindex/-/postcss-zindex-2.2.0.tgz", - "integrity": "sha512-uhRZ2hRgj0lorxm9cr62B01YzpUe63h0RXMXQ4gWW3oa2rpJh+FJAiEAytaFCPU/VgaBS+uW2SJ1XKyDNz1h4w==", - "dependencies": { - "has": "^1.0.1", - "postcss": "^5.0.4", - "uniqs": "^2.0.0" - } - }, - "node_modules/postcss/node_modules/ansi-regex": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", - "integrity": "sha512-TIGnTpdo+E3+pCyAluZvtED5p5wCqLdezCyhPZzKPcxvFplEt4i+W7OONCKgeZFT3+y5NZZfOOS/Bdcanm1MYA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postcss/node_modules/ansi-styles": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", - "integrity": "sha512-kmCevFghRiWM7HB5zTPULl4r9bVFSWjz62MhqizDGUrq2NWuNMQyuv4tHHoKJHs69M/MF64lEcHdYIocrdWQYA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postcss/node_modules/chalk": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", - "integrity": "sha512-U3lRVLMSlsCfjqYPbLyVv11M9CPW4I728d6TCKMAOJueEeB9/8o+eSsMnxPJD+Q+K909sdESg7C+tIkoH6on1A==", - "dependencies": { - "ansi-styles": "^2.2.1", - "escape-string-regexp": "^1.0.2", - "has-ansi": "^2.0.0", - "strip-ansi": "^3.0.0", - "supports-color": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postcss/node_modules/chalk/node_modules/supports-color": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", - "integrity": "sha512-KKNVtd6pCYgPIKU4cp2733HWYCpplQhddZLBUryaAHou723x+FRzQ5Df824Fj+IyyuiQTRoub4SnIFfIcrp70g==", - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/postcss/node_modules/has-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", - "integrity": "sha512-DyYHfIYwAJmjAjSSPKANxI8bFY9YtFrgkAfinBojQ8YJTOuOuav64tMUJv584SES4xl74PmuaevIyaLESHdTAA==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postcss/node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postcss/node_modules/strip-ansi": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", - "integrity": "sha512-VhumSSbBqDTP8p2ZLKj40UjBCV4+v8bUSEpUb4KjRgWk9pbqGF4REFj6KEagidb2f/M6AzC0EmFyDNGaw9OCzg==", - "dependencies": { - "ansi-regex": "^2.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/postcss/node_modules/supports-color": { - "version": "3.2.3", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", - "integrity": "sha512-Jds2VIYDrlp5ui7t8abHN2bjAu4LV/q4N2KivFPpGH0lrka0BMq/33AmECUXlKPcHigkNaqfXRENFju+rlcy+A==", - "dependencies": { - "has-flag": "^1.0.0" - }, - "engines": { - "node": ">=0.8.0" - } - }, - "node_modules/prelude-ls": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", - "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", - "dev": true, - "peer": true, - "engines": { - "node": ">= 0.8.0" - } - }, - "node_modules/prepend-http": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/prepend-http/-/prepend-http-1.0.4.tgz", - "integrity": "sha512-PhmXi5XmoyKw1Un4E+opM2KcsJInDvKyuOumcjjw3waw86ZNjHwVUOOWLc4bCzLdcKNaWBH9e99sbWzDQsVaYg==", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/preserve": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/preserve/-/preserve-0.2.0.tgz", @@ -19615,60 +16195,44 @@ } }, "node_modules/pretty-format": { - "version": "26.6.2", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-26.6.2.tgz", - "integrity": "sha512-7AeGuCYNGmycyQbCqd/3PWH4eOoX/OiCa0uphp57NVTeAGdJGaAliecxwBDHYQCIvrW7aDBZCYeNTP/WX69mkg==", + "version": "29.7.0", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", + "integrity": "sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==", + "license": "MIT", "dependencies": { - "@jest/types": "^26.6.2", - "ansi-regex": "^5.0.0", - "ansi-styles": "^4.0.0", - "react-is": "^17.0.1" + "@jest/schemas": "^29.6.3", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" }, "engines": { - "node": ">= 10" + "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "4.3.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", - "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dependencies": { - "color-convert": "^2.0.1" - }, + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "license": "MIT", "engines": { - "node": ">=8" + "node": ">=10" }, "funding": { "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/pretty-format/node_modules/color-convert": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", - "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dependencies": { - "color-name": "~1.1.4" - }, - "engines": { - "node": ">=7.0.0" - } - }, - "node_modules/pretty-format/node_modules/color-name": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", - "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" - }, "node_modules/pretty-format/node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==" + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" }, "node_modules/proc-log": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz", - "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-5.0.0.tgz", + "integrity": "sha512-Azwzvl90HaF0aCz1JrDdXQykFakSSNPaPoiZ9fm5qJIMHioDZEi7OAdRwSm6rSoPtY3Qutnm3L7ogmg3dc+wbQ==", + "license": "ISC", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/process-nextick-args": { @@ -19686,18 +16250,11 @@ "node": ">=0.4.0" } }, - "node_modules/promise": { - "version": "7.3.1", - "resolved": "https://registry.npmjs.org/promise/-/promise-7.3.1.tgz", - "integrity": "sha512-nolQXZ/4L+bP/UGlkfaIujX9BKxGwmQ9OT4mOt5yvy8iK1h3wqTEJCijzGANTCCl9nWjY41juyAn2K3Q1hLLTg==", - "dependencies": { - "asap": "~2.0.3" - } - }, "node_modules/promise-retry": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", + "license": "MIT", "dependencies": { "err-code": "^2.0.2", "retry": "^0.12.0" @@ -19711,6 +16268,7 @@ "resolved": "https://registry.npmjs.org/prompts/-/prompts-2.4.2.tgz", "integrity": "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==", "dev": true, + "license": "MIT", "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" @@ -19720,13 +16278,14 @@ } }, "node_modules/prop-types": { - "version": "15.6.0", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.6.0.tgz", - "integrity": "sha512-H16NHdiZ8szYSKNkCpmKmS8BCogxyABjJ1AqQknIY2iTpy1xC04egoBAzjKm+WU2pbuNxFonw921dnxR0QYAdw==", + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", "dependencies": { - "fbjs": "^0.8.16", - "loose-envify": "^1.3.1", - "object-assign": "^4.1.1" + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" } }, "node_modules/prop-types-exact": { @@ -19762,16 +16321,32 @@ "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", "integrity": "sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==" }, - "node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", - "dev": true, - "dependencies": { - "end-of-stream": "^1.1.0", - "once": "^1.3.1" + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "license": "MIT", + "engines": { + "node": ">=6" } }, + "node_modules/pure-rand": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-6.1.0.tgz", + "integrity": "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, "node_modules/q": { "version": "0.9.7", "resolved": "https://registry.npmjs.org/q/-/q-0.9.7.tgz", @@ -19783,12 +16358,12 @@ } }, "node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dev": true, "dependencies": { - "side-channel": "^1.0.4" + "side-channel": "^1.0.6" }, "engines": { "node": ">=0.6" @@ -19833,34 +16408,6 @@ } ] }, - "node_modules/raf": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", - "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", - "dev": true, - "dependencies": { - "performance-now": "^2.1.0" - } - }, - "node_modules/railroad-diagrams": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", - "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", - "dev": true - }, - "node_modules/randexp": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", - "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", - "dev": true, - "dependencies": { - "discontinuous-range": "1.0.0", - "ret": "~0.1.10" - }, - "engines": { - "node": ">=0.12" - } - }, "node_modules/randomatic": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/randomatic/-/randomatic-3.1.1.tgz", @@ -19967,16 +16514,6 @@ "csstype": "^3.0.2" } }, - "node_modules/react-bootstrap/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/react-bootstrap/node_modules/react-transition-group": { "version": "4.4.5", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", @@ -20017,16 +16554,6 @@ "react": "^16.14.0" } }, - "node_modules/react-dom/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/react-dropzone": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-4.3.0.tgz", @@ -20058,16 +16585,6 @@ "react": "^15.0.0 || ^16.0.0" } }, - "node_modules/react-focus-lock/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/react-focus-on": { "version": "3.9.2", "resolved": "https://registry.npmjs.org/react-focus-on/-/react-focus-on-3.9.2.tgz", @@ -20105,16 +16622,6 @@ "node": ">=10" } }, - "node_modules/react-focus-on/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/react-focus-on/node_modules/react-focus-lock": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/react-focus-lock/-/react-focus-lock-2.11.2.tgz", @@ -20202,43 +16709,30 @@ "csstype": "^3.0.2" } }, - "node_modules/react-overlays/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/react-proptype-conditional-require": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/react-proptype-conditional-require/-/react-proptype-conditional-require-1.0.4.tgz", "integrity": "sha512-nopsRn7KnGgazBe2c3H2+Kf+Csp6PGDRLiBkYEDMKY8o/EIgft/WnIm/OnAKTawZiLnJXHAqhpFBddvs6NiXlw==" }, "node_modules/react-redux": { - "version": "5.0.7", - "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-5.0.7.tgz", - "integrity": "sha512-5VI8EV5hdgNgyjfmWzBbdrqUkrVRKlyTKk1sGH3jzM2M2Mhj/seQgPXaz6gVAj2lz/nz688AdTqMO18Lr24Zhg==", + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-5.1.2.tgz", + "integrity": "sha512-Ns1G0XXc8hDyH/OcBHOxNgQx9ayH3SPxBnFCOidGKSle8pKihysQw2rG/PmciUQRoclhVBO8HMhiRmGXnDja9Q==", + "license": "MIT", "dependencies": { - "hoist-non-react-statics": "^2.5.0", - "invariant": "^2.0.0", - "lodash": "^4.17.5", - "lodash-es": "^4.17.5", + "@babel/runtime": "^7.1.2", + "hoist-non-react-statics": "^3.3.0", + "invariant": "^2.2.4", "loose-envify": "^1.1.0", - "prop-types": "^15.6.0" + "prop-types": "^15.6.1", + "react-is": "^16.6.0", + "react-lifecycles-compat": "^3.0.0" }, "peerDependencies": { "react": "^0.14.0 || ^15.0.0-0 || ^16.0.0-0", "redux": "^2.0.0 || ^3.0.0 || ^4.0.0-0" } }, - "node_modules/react-redux/node_modules/hoist-non-react-statics": { - "version": "2.5.5", - "resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz", - "integrity": "sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==" - }, "node_modules/react-remove-scroll": { "version": "2.5.9", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.5.9.tgz", @@ -20300,26 +16794,16 @@ "react": "^16.3.0" } }, - "node_modules/react-responsive/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/react-router": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.1.2.tgz", - "integrity": "sha512-yjEuMFy1ONK246B+rsa0cUam5OeAQ8pyclRDgpxuSCrAlJ1qN9uZ5IgyKC7gQg0w8OM50NXHEegPh/ks9YuR2A==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-5.3.4.tgz", + "integrity": "sha512-Ys9K+ppnJah3QuaRiLxk+jDWOR1MekYQrlytiXxC1RyfbdsZkS5pvKAzCCr031xHixZwpnsYNT5xysdFHQaYsA==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.1.2", + "@babel/runtime": "^7.12.13", "history": "^4.9.0", "hoist-non-react-statics": "^3.1.0", "loose-envify": "^1.3.1", - "mini-create-react-context": "^0.3.0", "path-to-regexp": "^1.7.0", "prop-types": "^15.6.2", "react-is": "^16.6.0", @@ -20331,15 +16815,16 @@ } }, "node_modules/react-router-dom": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.1.2.tgz", - "integrity": "sha512-7BPHAaIwWpZS074UKaw1FjVdZBSVWEk8IuDXdB+OkLb8vd/WRQIpA4ag9WQk61aEfQs47wHyjWUoUGGZxpQXew==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-5.3.4.tgz", + "integrity": "sha512-m4EqFMHv/Ih4kpcBCONHbkT68KoAeHN4p3lAGoNryfHi0dMy0kCzEZakiKRsvg5wHZ/JLrLW8o8KomWiz/qbYQ==", + "license": "MIT", "dependencies": { - "@babel/runtime": "^7.1.2", + "@babel/runtime": "^7.12.13", "history": "^4.9.0", "loose-envify": "^1.3.1", "prop-types": "^15.6.2", - "react-router": "5.1.2", + "react-router": "5.3.4", "tiny-invariant": "^1.0.2", "tiny-warning": "^1.0.0" }, @@ -20347,30 +16832,10 @@ "react": ">=15" } }, - "node_modules/react-router-dom/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/react-router/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/react-slick": { - "version": "0.30.2", - "resolved": "https://registry.npmjs.org/react-slick/-/react-slick-0.30.2.tgz", - "integrity": "sha512-XvQJi7mRHuiU3b9irsqS9SGIgftIfdV5/tNcURTb5LdIokRA5kIIx3l4rlq2XYHfxcSntXapoRg/GxaVOM1yfg==", + "version": "0.30.3", + "resolved": "https://registry.npmjs.org/react-slick/-/react-slick-0.30.3.tgz", + "integrity": "sha512-B4x0L9GhkEWUMApeHxr/Ezp2NncpGc+5174R02j+zFiWuYboaq98vmxwlpafZfMjZic1bjdIqqmwLDcQY0QaFA==", "license": "MIT", "dependencies": { "classnames": "^2.2.5", @@ -20380,8 +16845,8 @@ "resize-observer-polyfill": "^1.5.0" }, "peerDependencies": { - "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0", - "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0" + "react": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^0.14.0 || ^15.0.1 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "node_modules/react-style-singleton": { @@ -20434,18 +16899,6 @@ "react": "^16.14.0" } }, - "node_modules/react-test-renderer/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, - "license": "MIT", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, "node_modules/react-transition-group": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-2.9.0.tgz", @@ -20461,76 +16914,6 @@ "react-dom": ">=15.0.0" } }, - "node_modules/react-transition-group/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/react/node_modules/prop-types": { - "version": "15.8.1", - "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", - "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dependencies": { - "loose-envify": "^1.4.0", - "object-assign": "^4.1.1", - "react-is": "^16.13.1" - } - }, - "node_modules/read-pkg": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", - "integrity": "sha512-Ug69mNOpfvKDAc2Q8DRpMjjzdtrnv9HcSMX+4VsZxD1aZ6ZzrIE7rlzXBtWTyhULSMKg076AW6WR5iZpD0JiOg==", - "dev": true, - "dependencies": { - "@types/normalize-package-data": "^2.4.0", - "normalize-package-data": "^2.5.0", - "parse-json": "^5.0.0", - "type-fest": "^0.6.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg-up": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-7.0.1.tgz", - "integrity": "sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==", - "dev": true, - "dependencies": { - "find-up": "^4.1.0", - "read-pkg": "^5.2.0", - "type-fest": "^0.8.1" - }, - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/read-pkg-up/node_modules/type-fest": { - "version": "0.8.1", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.8.1.tgz", - "integrity": "sha512-4dbzIzqvjtgiM5rw1k5rEHtBANKmdudhGyBEajN01fEyhaAIhsoKNy6y7+IN93IfpFtwY9iqi7kD+xwKhQsNJA==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/read-pkg/node_modules/type-fest": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-0.6.0.tgz", - "integrity": "sha512-q+MB8nYR1KDLrgr4G5yemftpMC7/QLqVndBmEEdqzmNj5dcFOO4Oo8qlwZE3ULT3+Zim1F8Kq4cBnikNhlCMlg==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/readable-stream": { "version": "1.0.34", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", @@ -20904,27 +17287,16 @@ "node": ">= 10.13.0" } }, - "node_modules/reduce-css-calc": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/reduce-css-calc/-/reduce-css-calc-1.3.0.tgz", - "integrity": "sha512-0dVfwYVOlf/LBA2ec4OwQ6p3X9mYxn/wOl2xTcLwjnPYrkgEfPx3VI4eGCH3rQLlPISG5v9I9bkZosKsNRTRKA==", + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", "dependencies": { - "balanced-match": "^0.4.2", - "math-expression-evaluator": "^1.2.14", - "reduce-function-call": "^1.0.1" - } - }, - "node_modules/reduce-css-calc/node_modules/balanced-match": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-0.4.2.tgz", - "integrity": "sha512-STw03mQKnGUYtoNjmowo4F2cRmIIxYEGiMsjjwla/u5P1lxadj/05WkNaFjNiKTgJkj8KiXbgAiRTmcQRwQNtg==" - }, - "node_modules/reduce-function-call": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/reduce-function-call/-/reduce-function-call-1.0.3.tgz", - "integrity": "sha512-Hl/tuV2VDgWgCSEeWMLwxLZqX7OK59eU1guxXsRKTAyeYimivsKdtcV4fu3r710tpG5GmDKDhQ0HSZLExnNmyQ==", - "dependencies": { - "balanced-match": "^1.0.0" + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/redux": { @@ -20952,28 +17324,6 @@ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.2.0.tgz", "integrity": "sha512-OOFWh9mt/7i94QPq4IAxhSIUyfIJJRnk6pe1IwgXethQik3kyg1wuxVZZlW9QOmL5rP/MrwzV+Cb+/HBKlvM8Q==" }, - "node_modules/reflect.getprototypeof": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", - "integrity": "sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.1", - "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "globalthis": "^1.0.3", - "which-builtin-type": "^1.1.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/reflect.ownkeys": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/reflect.ownkeys/-/reflect.ownkeys-0.2.0.tgz", @@ -20982,12 +17332,14 @@ "node_modules/regenerate": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", - "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==" + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "license": "MIT" }, "node_modules/regenerate-unicode-properties": { - "version": "10.1.1", - "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.1.1.tgz", - "integrity": "sha512-X007RyZLsCJVVrjgEFVpLUTZwyOZk3oiL75ZcuYjlIWd6rNJtOjkBwQc5AsRrpbKVkxN6sklw/k/9m2jJYOf8Q==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", + "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", + "license": "MIT", "dependencies": { "regenerate": "^1.4.2" }, @@ -21051,14 +17403,15 @@ } }, "node_modules/regexpu-core": { - "version": "5.3.2", - "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", - "integrity": "sha512-RAM5FlZz+Lhmo7db9L298p2vHP5ZywrVXmVXpmAD9GuL5MPH6t9ROw1iA/wfHkQ76Qe7AaPF0nGuim96/IrQMQ==", + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.1.1.tgz", + "integrity": "sha512-k67Nb9jvwJcJmVpw0jPttR1/zVfnKf8Km0IPatrU/zJ5XeG3+Slx0xLXs9HByJSzXzrlz5EDvN6yLNMDc2qdnw==", + "license": "MIT", "dependencies": { - "@babel/regjsgen": "^0.8.0", "regenerate": "^1.4.2", - "regenerate-unicode-properties": "^10.1.0", - "regjsparser": "^0.9.1", + "regenerate-unicode-properties": "^10.2.0", + "regjsgen": "^0.8.0", + "regjsparser": "^0.11.0", "unicode-match-property-ecmascript": "^2.0.0", "unicode-match-property-value-ecmascript": "^2.1.0" }, @@ -21066,25 +17419,24 @@ "node": ">=4" } }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "license": "MIT" + }, "node_modules/regjsparser": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.9.1.tgz", - "integrity": "sha512-dQUtn90WanSNl+7mQKcXAgZxvUe7Z0SqXlgzv0za4LwiUhyzBC58yQO3liFoUgu8GiJVInAhJjkj1N0EtQ5nkQ==", + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.11.2.tgz", + "integrity": "sha512-3OGZZ4HoLJkkAZx/48mTXJNlmqTGOzc0o9OWQPuWpkOlXXPbyN6OafCcoXUnBqE2D3f/T5L+pWc1kdEmnfnRsA==", + "license": "BSD-2-Clause", "dependencies": { - "jsesc": "~0.5.0" + "jsesc": "~3.0.2" }, "bin": { "regjsparser": "bin/parser" } }, - "node_modules/regjsparser/node_modules/jsesc": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", - "integrity": "sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==", - "bin": { - "jsesc": "bin/jsesc" - } - }, "node_modules/remove-trailing-separator": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", @@ -21109,165 +17461,12 @@ "node": ">=0.10" } }, - "node_modules/repeating": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", - "integrity": "sha512-ZqtSMuVybkISo2OWvqvm7iHSWngvdaW3IpsT9/uP8v4gMi591LY6h35wdOfvQdWCKFWZWm2Y1Opp4kV7vQKT6A==", - "dev": true, - "dependencies": { - "is-finite": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/request": { - "version": "2.88.2", - "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", - "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", - "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", - "dev": true, - "dependencies": { - "aws-sign2": "~0.7.0", - "aws4": "^1.8.0", - "caseless": "~0.12.0", - "combined-stream": "~1.0.6", - "extend": "~3.0.2", - "forever-agent": "~0.6.1", - "form-data": "~2.3.2", - "har-validator": "~5.1.3", - "http-signature": "~1.2.0", - "is-typedarray": "~1.0.0", - "isstream": "~0.1.2", - "json-stringify-safe": "~5.0.1", - "mime-types": "~2.1.19", - "oauth-sign": "~0.9.0", - "performance-now": "^2.1.0", - "qs": "~6.5.2", - "safe-buffer": "^5.1.2", - "tough-cookie": "~2.5.0", - "tunnel-agent": "^0.6.0", - "uuid": "^3.3.2" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/request-promise-core": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/request-promise-core/-/request-promise-core-1.1.4.tgz", - "integrity": "sha512-TTbAfBBRdWD7aNNOoVOBH4pN/KigV6LyapYNNlAPA8JwbovRti1E88m3sYAwsLi5ryhPKsE9APwnjFTgdUjTpw==", - "dev": true, - "dependencies": { - "lodash": "^4.17.19" - }, - "engines": { - "node": ">=0.10.0" - }, - "peerDependencies": { - "request": "^2.34" - } - }, - "node_modules/request-promise-native": { - "version": "1.0.9", - "resolved": "https://registry.npmjs.org/request-promise-native/-/request-promise-native-1.0.9.tgz", - "integrity": "sha512-wcW+sIUiWnKgNY0dqCpOZkUbF/I+YPi+f09JZIDa39Ec+q82CpSYniDp+ISgTTbKmnpJWASeJBPZmoxH84wt3g==", - "deprecated": "request-promise-native has been deprecated because it extends the now deprecated request package, see https://github.com/request/request/issues/3142", - "dev": true, - "dependencies": { - "request-promise-core": "1.1.4", - "stealthy-require": "^1.1.1", - "tough-cookie": "^2.3.3" - }, - "engines": { - "node": ">=0.12.0" - }, - "peerDependencies": { - "request": "^2.34" - } - }, - "node_modules/request-promise-native/node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/request-promise-native/node_modules/tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "dev": true, - "dependencies": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/request/node_modules/form-data": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", - "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", - "dev": true, - "dependencies": { - "asynckit": "^0.4.0", - "combined-stream": "^1.0.6", - "mime-types": "^2.1.12" - }, - "engines": { - "node": ">= 0.12" - } - }, - "node_modules/request/node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/request/node_modules/qs": { - "version": "6.5.3", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.3.tgz", - "integrity": "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA==", - "dev": true, - "engines": { - "node": ">=0.6" - } - }, - "node_modules/request/node_modules/tough-cookie": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", - "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", - "dev": true, - "dependencies": { - "psl": "^1.1.28", - "punycode": "^2.1.1" - }, - "engines": { - "node": ">=0.8" - } - }, - "node_modules/request/node_modules/uuid": { - "version": "3.4.0", - "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", - "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", - "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", - "dev": true, - "bin": { - "uuid": "bin/uuid" - } - }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", "dev": true, + "license": "MIT", "engines": { "node": ">=0.10.0" } @@ -21280,12 +17479,6 @@ "node": ">=0.10.0" } }, - "node_modules/require-main-filename": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", - "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", - "dev": true - }, "node_modules/require-uncached": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz", @@ -21390,6 +17583,16 @@ "deprecated": "https://github.com/lydell/resolve-url#deprecated", "dev": true }, + "node_modules/resolve.exports": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.2.tgz", + "integrity": "sha512-X2UW6Nw3n/aMgDVy+0rSqgHlv39WZAlZrXCdnbyEiKm17DSqHX4MmQMaST3FbeWR5FTuRcUwYAziZajji0Y7mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/restore-cursor": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz", @@ -21425,6 +17628,7 @@ "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", "engines": { "node": ">= 4" } @@ -21439,17 +17643,6 @@ "node": ">=0.10.0" } }, - "node_modules/right-align": { - "version": "0.1.3", - "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", - "integrity": "sha512-yqINtL/G7vs2v+dFIZmFUDbnVyFUJFKd6gK22Kgo6R4jfJGFtisKyncWDDULgjfqf4ASQuIQyjJ7XZ+3aWpsAg==", - "dependencies": { - "align-text": "^0.1.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/rimraf": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", @@ -21465,62 +17658,50 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/rst-selector-parser": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/rst-selector-parser/-/rst-selector-parser-2.2.3.tgz", - "integrity": "sha512-nDG1rZeP6oFTLN6yNDV/uiAvs1+FS/KlrEwh7+y7dpuApDBy6bI2HTBcc0/V8lv9OTqfyD34eF7au2pm8aBbhA==", - "dev": true, - "dependencies": { - "lodash.flattendeep": "^4.4.0", - "nearley": "^2.7.10" - } - }, - "node_modules/rsvp": { - "version": "4.8.5", - "resolved": "https://registry.npmjs.org/rsvp/-/rsvp-4.8.5.tgz", - "integrity": "sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA==", - "dev": true, - "engines": { - "node": "6.* || >= 7.*" - } - }, "node_modules/rtlcss": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-2.6.2.tgz", - "integrity": "sha512-06LFAr+GAPo+BvaynsXRfoYTJvSaWRyOhURCQ7aeI1MKph9meM222F+Zkt3bDamyHHJuGi3VPtiRkpyswmQbGA==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/rtlcss/-/rtlcss-4.3.0.tgz", + "integrity": "sha512-FI+pHEn7Wc4NqKXMXFM+VAYKEj/mRIcW4h24YVwVtyjI+EqGrLc2Hx/Ny0lrZ21cBWU2goLy36eqMcNj3AQJig==", "license": "MIT", "dependencies": { - "@choojs/findup": "^0.2.1", - "chalk": "^2.4.2", - "mkdirp": "^0.5.1", - "postcss": "^6.0.23", - "strip-json-comments": "^2.0.0" + "escalade": "^3.1.1", + "picocolors": "^1.0.0", + "postcss": "^8.4.21", + "strip-json-comments": "^3.1.1" }, "bin": { "rtlcss": "bin/rtlcss.js" + }, + "engines": { + "node": ">=12.0.0" } }, "node_modules/rtlcss/node_modules/postcss": { - "version": "6.0.23", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-6.0.23.tgz", - "integrity": "sha512-soOk1h6J3VMTZtVeVpv15/Hpdl2cBLX3CAw4TAbkpTJiNPk9YP/zWcD1ND+xEtvyuuvKzbxliTOIyvkSeSJ6ag==", + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], "license": "MIT", "dependencies": { - "chalk": "^2.4.1", - "source-map": "^0.6.1", - "supports-color": "^5.4.0" + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" }, "engines": { - "node": ">=4.0.0" - } - }, - "node_modules/rtlcss/node_modules/strip-json-comments": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", - "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", - "license": "MIT", - "engines": { - "node": ">=0.10.0" + "node": "^10 || ^12 || >=14" } }, "node_modules/run-async": { @@ -21632,447 +17813,6 @@ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, - "node_modules/samsam": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/samsam/-/samsam-1.3.0.tgz", - "integrity": "sha512-1HwIYD/8UlOtFS3QO3w7ey+SdSDFE4HRNLZoZRYVQefrOY3l17epswImeB1ijgJFQJodIaHcwkp3r/myBjFVbg==", - "deprecated": "This package has been deprecated in favour of @sinonjs/samsam", - "dev": true - }, - "node_modules/sane": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/sane/-/sane-4.1.0.tgz", - "integrity": "sha512-hhbzAgTIX8O7SHfp2c8/kREfEn4qO/9q8C9beyY6+tvZ87EpoZ3i1RIEvp27YBswnNbY9mWd6paKVmKbAgLfZA==", - "deprecated": "some dependency vulnerabilities fixed, support for node < 10 dropped, and newer ECMAScript syntax/features added", - "dev": true, - "dependencies": { - "@cnakazawa/watch": "^1.0.3", - "anymatch": "^2.0.0", - "capture-exit": "^2.0.0", - "exec-sh": "^0.3.2", - "execa": "^1.0.0", - "fb-watchman": "^2.0.0", - "micromatch": "^3.1.4", - "minimist": "^1.1.1", - "walker": "~1.0.5" - }, - "bin": { - "sane": "src/cli.js" - }, - "engines": { - "node": "6.* || 8.* || >= 10.*" - } - }, - "node_modules/sane/node_modules/anymatch": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", - "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", - "dev": true, - "dependencies": { - "micromatch": "^3.1.4", - "normalize-path": "^2.1.1" - } - }, - "node_modules/sane/node_modules/arr-diff": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", - "integrity": "sha512-YVIQ82gZPGBebQV/a8dar4AitzCQs0jjXwMPZllpXMaGjXPYVUawSxQrRsjhjupyVxEvbHgUmIhKVlND+j02kA==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/array-unique": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", - "integrity": "sha512-SleRWjh9JUud2wH1hPs9rZBZ33H6T9HOiL0uwGnGx9FpE6wKGyfWugmbkEOIs6qWrZhg0LWeLziLrEwQJhs5mQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/braces": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", - "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", - "dev": true, - "dependencies": { - "arr-flatten": "^1.1.0", - "array-unique": "^0.3.2", - "extend-shallow": "^2.0.1", - "fill-range": "^4.0.0", - "isobject": "^3.0.1", - "repeat-element": "^1.1.2", - "snapdragon": "^0.8.1", - "snapdragon-node": "^2.0.1", - "split-string": "^3.0.2", - "to-regex": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/braces/node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", - "dev": true, - "dependencies": { - "nice-try": "^1.0.4", - "path-key": "^2.0.1", - "semver": "^5.5.0", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - }, - "engines": { - "node": ">=4.8" - } - }, - "node_modules/sane/node_modules/debug": { - "version": "2.6.9", - "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", - "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", - "dev": true, - "dependencies": { - "ms": "2.0.0" - } - }, - "node_modules/sane/node_modules/execa": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", - "integrity": "sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==", - "dev": true, - "dependencies": { - "cross-spawn": "^6.0.0", - "get-stream": "^4.0.0", - "is-stream": "^1.1.0", - "npm-run-path": "^2.0.0", - "p-finally": "^1.0.0", - "signal-exit": "^3.0.0", - "strip-eof": "^1.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/sane/node_modules/expand-brackets": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", - "integrity": "sha512-w/ozOKR9Obk3qoWeY/WDi6MFta9AoMR+zud60mdnbniMcBxRuFJyDt2LdX/14A1UABeqk+Uk+LDfUpvoGKppZA==", - "dev": true, - "dependencies": { - "debug": "^2.3.3", - "define-property": "^0.2.5", - "extend-shallow": "^2.0.1", - "posix-character-classes": "^0.1.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/expand-brackets/node_modules/define-property": { - "version": "0.2.5", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", - "integrity": "sha512-Rr7ADjQZenceVOAKop6ALkkRAmH1A4Gx9hV/7ZujPUN2rkATqFO0JZLZInbAjpZYoJ1gUx8MRMQVkYemcbMSTA==", - "dev": true, - "dependencies": { - "is-descriptor": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/expand-brackets/node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/expand-brackets/node_modules/is-descriptor": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.7.tgz", - "integrity": "sha512-C3grZTvObeN1xud4cRWl366OMXZTj0+HGyk4hvfpx4ZHt1Pb60ANSXqCK7pdOTeUQpRzECBSTphqvD7U+l22Eg==", - "dev": true, - "dependencies": { - "is-accessor-descriptor": "^1.0.1", - "is-data-descriptor": "^1.0.1" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/sane/node_modules/extglob": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", - "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", - "dev": true, - "dependencies": { - "array-unique": "^0.3.2", - "define-property": "^1.0.0", - "expand-brackets": "^2.1.4", - "extend-shallow": "^2.0.1", - "fragment-cache": "^0.2.1", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/extglob/node_modules/define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", - "integrity": "sha512-cZTYKFWspt9jZsMscWo8sc/5lbPC9Q0N5nBLgb+Yd915iL3udB1uFgS3B8YCx66UVHq018DAVFoee7x+gxggeA==", - "dev": true, - "dependencies": { - "is-descriptor": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/extglob/node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/fill-range": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", - "integrity": "sha512-VcpLTWqWDiTerugjj8e3+esbg+skS3M9e54UuR3iCeIDMXCLTsAH8hTSzDQU/X6/6t3eYkOKoZSef2PlU6U1XQ==", - "dev": true, - "dependencies": { - "extend-shallow": "^2.0.1", - "is-number": "^3.0.0", - "repeat-string": "^1.6.1", - "to-regex-range": "^2.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/fill-range/node_modules/extend-shallow": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", - "integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==", - "dev": true, - "dependencies": { - "is-extendable": "^0.1.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/get-stream": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", - "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", - "dev": true, - "dependencies": { - "pump": "^3.0.0" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/sane/node_modules/is-number": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", - "integrity": "sha512-4cboCqIpliH+mAvFNegjZQ4kgKc3ZUhQVr3HvWbSh5q3WH2v82ct+T2Y1hdU5Gdtorx/cLifQjqCbL7bpznLTg==", - "dev": true, - "dependencies": { - "kind-of": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/is-number/node_modules/kind-of": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", - "integrity": "sha512-NOW9QQXMoZGg/oqnVNoNTTIFEIid1627WCffUBJEdMxYApq7mNE7CpzucIPc+ZQg25Phej7IJSmX3hO+oblOtQ==", - "dev": true, - "dependencies": { - "is-buffer": "^1.1.5" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/is-stream": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", - "integrity": "sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/isobject": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", - "integrity": "sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/micromatch": { - "version": "3.1.10", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", - "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", - "dev": true, - "dependencies": { - "arr-diff": "^4.0.0", - "array-unique": "^0.3.2", - "braces": "^2.3.1", - "define-property": "^2.0.2", - "extend-shallow": "^3.0.2", - "extglob": "^2.0.4", - "fragment-cache": "^0.2.1", - "kind-of": "^6.0.2", - "nanomatch": "^1.2.9", - "object.pick": "^1.3.0", - "regex-not": "^1.0.0", - "snapdragon": "^0.8.1", - "to-regex": "^3.0.2" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/ms": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", - "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", - "dev": true - }, - "node_modules/sane/node_modules/normalize-path": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", - "integrity": "sha512-3pKJwH184Xo/lnH6oyP1q2pMd7HcypqqmRs91/6/i2CGtWwIKGCkOOMTm/zXbgTEWHw1uNpNi/igc3ePOYHb6w==", - "dev": true, - "dependencies": { - "remove-trailing-separator": "^1.0.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/npm-run-path": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", - "integrity": "sha512-lJxZYlT4DW/bRUtFh1MQIWqmLwQfAxnqWG4HhEdjMlkrJYnJn0Jrr2u3mgxqaWsdiBc76TYkTG/mhrnYTuzfHw==", - "dev": true, - "dependencies": { - "path-key": "^2.0.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/sane/node_modules/path-key": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", - "integrity": "sha512-fEHGKCSmUSDPv4uoj8AlD+joPlq3peND+HRYyxFz4KPw4z926S/b8rIuFs2FYJg3BwsxJf6A9/3eIdLaYC+9Dw==", - "dev": true, - "engines": { - "node": ">=4" - } - }, - "node_modules/sane/node_modules/repeat-string": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", - "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", - "dev": true, - "engines": { - "node": ">=0.10" - } - }, - "node_modules/sane/node_modules/semver": { - "version": "5.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", - "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", - "dev": true, - "bin": { - "semver": "bin/semver" - } - }, - "node_modules/sane/node_modules/shebang-command": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", - "integrity": "sha512-EV3L1+UQWGor21OmnvojK36mhg+TyIKDh3iFBKBohr5xeXIhNBcx8oWdgkTEEQ+BEFFYdLRuqMfd5L84N1V5Vg==", - "dev": true, - "dependencies": { - "shebang-regex": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/shebang-regex": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", - "integrity": "sha512-wpoSFAxys6b2a2wHZ1XpDSgD7N9iVjg29Ph9uV/uaP9Ex/KXlkTZTeddxDPSYQpgvzKLGJke2UU0AzoGCjNIvQ==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/to-regex-range": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", - "integrity": "sha512-ZZWNfCjUokXXDGXFpZehJIkZqq91BcULFq/Pi7M5i4JnxXdhMKAK682z8bCW3o8Hj1wuuzoKcW3DfVzaP6VuNg==", - "dev": true, - "dependencies": { - "is-number": "^3.0.0", - "repeat-string": "^1.6.1" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sane/node_modules/which": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", - "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "which": "bin/which" - } - }, "node_modules/sanitize-html": { "version": "1.27.5", "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-1.27.5.tgz", @@ -22193,12 +17933,13 @@ } }, "node_modules/sass": { - "version": "1.72.0", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.72.0.tgz", - "integrity": "sha512-Gpczt3WA56Ly0Mn8Sl21Vj94s1axi9hDIzDFn9Ph9x3C3p4nNyvsqJoQyVXKou6cBlfFWEgRW4rT8Tb4i3XnVA==", + "version": "1.82.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.82.0.tgz", + "integrity": "sha512-j4GMCTa8elGyN9A7x7bEglx0VgSpNUG4W4wNedQ33wSMdnkqQCT8HTwOaVSV4e6yQovcu/3Oc4coJP/l0xhL2Q==", + "license": "MIT", "dependencies": { - "chokidar": ">=3.0.0 <4.0.0", - "immutable": "^4.0.0", + "chokidar": "^4.0.0", + "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "bin": { @@ -22206,12 +17947,16 @@ }, "engines": { "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" } }, "node_modules/sass-loader": { - "version": "14.1.1", - "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-14.1.1.tgz", - "integrity": "sha512-QX8AasDg75monlybel38BZ49JP5Z+uSKfKwF2rO7S74BywaRmGQMUBw9dtkS+ekyM/QnP+NOrRYq8ABMZ9G8jw==", + "version": "16.0.4", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-16.0.4.tgz", + "integrity": "sha512-LavLbgbBGUt3wCiYzhuLLu65+fWXaXLmq7YxivLhEqmiupCFZ5sKUAipK3do6V80YSU0jvSxNhEdT13IXNr3rg==", + "license": "MIT", "dependencies": { "neo-async": "^2.6.2" }, @@ -22247,109 +17992,44 @@ } } }, - "node_modules/sass/node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/sass/node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dependencies": { - "fill-range": "^7.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/sass/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", + "license": "MIT", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 14.16.0" }, "funding": { "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/sass/node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/sass/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/sass/node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" } }, "node_modules/sass/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dependencies": { - "picomatch": "^2.2.1" - }, + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", + "license": "MIT", "engines": { - "node": ">=8.10.0" + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, - "node_modules/sax": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" - }, "node_modules/saxes": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", - "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "license": "ISC", "dependencies": { "xmlchars": "^2.2.0" }, "engines": { - "node": ">=10" + "node": ">=v12.22.7" } }, "node_modules/scheduler": { @@ -22416,10 +18096,20 @@ "integrity": "sha512-qGVDoreyYiP1pkQnbnFAUIS5AjenNwwQBdl7zeos9etl+hYKWahjRTfzAZZYBv5xNHx7vNKCmaLDQZ6Fr2AEXg==" }, "node_modules/selenium-webdriver": { - "version": "4.26.0", - "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.26.0.tgz", - "integrity": "sha512-nA7jMRIPV17mJmAiTDBWN96Sy0Uxrz5CCLb7bLVV6PpL417SyBMPc2Zo/uoREc2EOHlzHwHwAlFtgmSngSY4WQ==", + "version": "4.30.0", + "resolved": "https://registry.npmjs.org/selenium-webdriver/-/selenium-webdriver-4.30.0.tgz", + "integrity": "sha512-3DGtQI/xyAg05SrqzzpFaXRWYL+Kku3fsikCoBaxApKzhBMUX5UiHdPb2je2qKMf2PjJiEFaj0L5xELHYRbYMA==", "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/SeleniumHQ" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/selenium" + } + ], "license": "Apache-2.0", "dependencies": { "@bazel/runfiles": "^6.3.1", @@ -22428,7 +18118,7 @@ "ws": "^8.18.0" }, "engines": { - "node": ">= 14.21.0" + "node": ">= 18.20.5" } }, "node_modules/selenium-webdriver/node_modules/tmp": { @@ -22441,28 +18131,6 @@ "node": ">=14.14" } }, - "node_modules/selenium-webdriver/node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -22479,12 +18147,6 @@ "randombytes": "^2.1.0" } }, - "node_modules/set-blocking": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", - "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", - "dev": true - }, "node_modules/set-function-length": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", @@ -22566,7 +18228,8 @@ "node_modules/setimmediate": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", - "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==" + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "dev": true }, "node_modules/setprototypeof": { "version": "1.2.0", @@ -22622,17 +18285,69 @@ "resolved": "https://registry.npmjs.org/shellwords/-/shellwords-0.1.1.tgz", "integrity": "sha512-vFwSUfQvqybiICwZY5+DAWIPLKsWO31Q91JSKl3UYv+K5c2QRPzn0qzec6QPu1Qc9eHYItiP3NdJqNVqetYAww==", "dev": true, - "optional": true + "optional": true, + "peer": true }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" @@ -22653,31 +18368,63 @@ "integrity": "sha512-Mc/gH3RvlKvB/gkp9XwgDKEWrSYyefIJPGG8Jk1suZms/rISdUuVEMx5O1WBnTWaScvxXDvGJrZQWblUmQHjkQ==" }, "node_modules/sinon": { - "version": "2.4.1", - "resolved": "https://registry.npmjs.org/sinon/-/sinon-2.4.1.tgz", - "integrity": "sha512-vFTrO9Wt0ECffDYIPSP/E5bBugt0UjcBQOfQUMh66xzkyPEnhl/vM2LRZi2ajuTdkH07sA6DzrM6KvdvGIH8xw==", - "deprecated": "16.1.1", + "version": "19.0.2", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-19.0.2.tgz", + "integrity": "sha512-euuToqM+PjO4UgXeLETsfQiuoyPXlqFezr6YZDFwHR3t4qaX0fZUe1MfPMznTL5f8BWrVS89KduLdMUsxFCO6g==", "dev": true, "license": "BSD-3-Clause", "dependencies": { - "diff": "^3.1.0", - "formatio": "1.2.0", - "lolex": "^1.6.0", - "native-promise-only": "^0.8.1", - "path-to-regexp": "^1.7.0", - "samsam": "^1.1.3", - "text-encoding": "0.6.4", - "type-detect": "^4.0.0" + "@sinonjs/commons": "^3.0.1", + "@sinonjs/fake-timers": "^13.0.2", + "@sinonjs/samsam": "^8.0.1", + "diff": "^7.0.0", + "nise": "^6.1.1", + "supports-color": "^7.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/sinon" + } + }, + "node_modules/sinon/node_modules/@sinonjs/fake-timers": { + "version": "13.0.5", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-13.0.5.tgz", + "integrity": "sha512-36/hTbH2uaWuGVERyC6da9YwGWnzUZXuPro/F2LfsdOsLnCojz/iSH8MxUt/FD2S5XBSVPhmArFUXcpCQ2Hkiw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@sinonjs/commons": "^3.0.1" + } + }, + "node_modules/sinon/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/sinon/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" }, "engines": { - "node": ">=0.1.103" + "node": ">=8" } }, "node_modules/sisteransi": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/slash": { "version": "3.0.0", @@ -22741,6 +18488,7 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", + "license": "MIT", "engines": { "node": ">= 6.0.0", "npm": ">= 3.0.0" @@ -23017,9 +18765,10 @@ } }, "node_modules/socks": { - "version": "2.8.1", - "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.1.tgz", - "integrity": "sha512-B6w7tkwNid7ToxjZ08rQMT8M9BJAf8DKx8Ft4NivzH0zBUfd6jldGcisJn/RLgxcX3FPNDdNQCUEMMT79b+oCQ==", + "version": "2.8.4", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", + "integrity": "sha512-D3YaD0aRxR3mEcqnidIs7ReYJFVzWdd6fXJYUM8ixcQcJRGTka/b3saV0KflYhyVJXKhb947GndU35SxYNResQ==", + "license": "MIT", "dependencies": { "ip-address": "^9.0.5", "smart-buffer": "^4.2.0" @@ -23030,45 +18779,28 @@ } }, "node_modules/socks-proxy-agent": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.2.tgz", - "integrity": "sha512-8zuqoLv1aP/66PHF5TqwJ7Czm3Yv32urJQHrVyhD7mmA6d61Zv8cIXQYPTWwmg6qlupnPvs/QKDmfa4P/qct2g==", + "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", + "license": "MIT", "dependencies": { - "agent-base": "^7.0.2", + "agent-base": "^7.1.2", "debug": "^4.3.4", - "socks": "^2.7.1" + "socks": "^2.8.3" }, "engines": { "node": ">= 14" } }, "node_modules/socks-proxy-agent/node_modules/agent-base": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.0.tgz", - "integrity": "sha512-o/zjMZRhJxny7OyEF+Op8X+efiELC7k7yOjMzgfzVqOzXqkBkWI79YoTdOtsuWd5BWhAGAuOY/Xa6xpiaWXiNg==", - "dependencies": { - "debug": "^4.3.4" - }, + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.3.tgz", + "integrity": "sha512-jRR5wdylq8CkOe6hei19GGZnxM6rBGwFl3Bg0YItGDimvjGtAvdZk4Pu6Cl4u4Igsws4a1fd1Vq3ezrhn4KmFw==", + "license": "MIT", "engines": { "node": ">= 14" } }, - "node_modules/sort-keys": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/sort-keys/-/sort-keys-1.1.2.tgz", - "integrity": "sha512-vzn8aSqKgytVik0iwdBEi+zevbTYZogewTUM6dtpmGwEcdzbub/TX4bCzRhebDCRC3QzXgJsLRKB2V/Oof7HXg==", - "dependencies": { - "is-plain-obj": "^1.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/source-list-map": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.1.tgz", - "integrity": "sha512-qnQ7gVMxGNxsiL4lEuJwe/To8UnK7fAnmbGEEH8RpLouuKbeEm0lhbQVFIrNSuB+G7tVrAlVsZgETT5nljf+Iw==" - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -23163,7 +18895,8 @@ "node_modules/sprintf-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", - "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==" + "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", + "dev": true }, "node_modules/squirejs": { "version": "0.1.0", @@ -23171,52 +18904,23 @@ "integrity": "sha512-543P1IPQUUb7ty3hVruyOwpZWrkF8jUvNwJfxnqBxWDb6i+cbEcdQJWvQQnAm48nMcVdgVey/5xvRtbimN7ZWA==", "dev": true }, - "node_modules/sshpk": { - "version": "1.18.0", - "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", - "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", - "dev": true, - "dependencies": { - "asn1": "~0.2.3", - "assert-plus": "^1.0.0", - "bcrypt-pbkdf": "^1.0.0", - "dashdash": "^1.12.0", - "ecc-jsbn": "~0.1.1", - "getpass": "^0.1.1", - "jsbn": "~0.1.0", - "safer-buffer": "^2.0.2", - "tweetnacl": "~0.14.0" - }, - "bin": { - "sshpk-conv": "bin/sshpk-conv", - "sshpk-sign": "bin/sshpk-sign", - "sshpk-verify": "bin/sshpk-verify" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/sshpk/node_modules/jsbn": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", - "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", - "dev": true - }, "node_modules/ssri": { - "version": "10.0.5", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz", - "integrity": "sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-12.0.0.tgz", + "integrity": "sha512-S7iGNosepx9RadX82oimUkvr0Ct7IjJbEbs4mJcTxst8um95J3sDYU1RBEOvdu6oL1Wek2ODI5i4MAw+dZ6cAQ==", + "license": "ISC", "dependencies": { "minipass": "^7.0.3" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", "integrity": "sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ==", + "license": "MIT", "dependencies": { "escape-string-regexp": "^2.0.0" }, @@ -23228,6 +18932,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "license": "MIT", "engines": { "node": ">=8" } @@ -23279,13 +18984,16 @@ "node": ">= 0.6" } }, - "node_modules/stealthy-require": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/stealthy-require/-/stealthy-require-1.1.1.tgz", - "integrity": "sha512-ZnWpYnYugiOVEY5GkcuJK1io5V8QmNYChG62gSit9pQVGErXtrKuPC55ITaVSukmMta5qpMU7vqLt2Lnni4f/g==", - "dev": true, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, "engines": { - "node": ">=0.10.0" + "node": ">= 0.4" } }, "node_modules/strict-uri-encode": { @@ -23315,6 +19023,7 @@ "resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz", "integrity": "sha512-+l6rNN5fYHNhZZy41RXsYptCjA2Igmq4EG7kZAYFQI1E1VTXarr6ZPXBg6eq7Y6eK4FEhY6AJlyuFIb/v/S0VQ==", "dev": true, + "license": "MIT", "dependencies": { "char-regex": "^1.0.2", "strip-ansi": "^6.0.0" @@ -23405,33 +19114,6 @@ "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" }, - "node_modules/string.prototype.matchall": { - "version": "4.0.11", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz", - "integrity": "sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==", - "dev": true, - "peer": true, - "dependencies": { - "call-bind": "^1.0.7", - "define-properties": "^1.2.1", - "es-abstract": "^1.23.2", - "es-errors": "^1.3.0", - "es-object-atoms": "^1.0.0", - "get-intrinsic": "^1.2.4", - "gopd": "^1.0.1", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.7", - "regexp.prototype.flags": "^1.5.2", - "set-function-name": "^2.0.2", - "side-channel": "^1.0.6" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/string.prototype.trim": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", @@ -23506,34 +19188,36 @@ "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-4.0.0.tgz", "integrity": "sha512-3xurFv5tEgii33Zi8Jtp55wEIILR9eh34FAW00PZf+JnSsTmV/ioewSgQl97JHvgjoRGwPShsWm+IdrxB35d0w==", "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/strip-eof": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", - "integrity": "sha512-7FCwGGmx8mD5xQd3RPUvnSpUXHM3BWuzjtpD4TXsfcZ9EL4azvVVUscFYwD9nx8Kh+uCBC00XBtAykoMHwTh8Q==", - "dev": true, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", "dev": true, + "license": "MIT", "engines": { "node": ">=6" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, - "peer": true, "engines": { "node": ">=8" }, @@ -23542,30 +19226,19 @@ } }, "node_modules/style-loader": { - "version": "0.23.1", - "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-0.23.1.tgz", - "integrity": "sha512-XK+uv9kWwhZMZ1y7mysB+zoihsEj4wneFWAS5qoiLwzW0WzSqMrrsIy+a3zkQJq0ipFtBpX5W3MqyRIBF/WFGg==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/style-loader/-/style-loader-4.0.0.tgz", + "integrity": "sha512-1V4WqhhZZgjVAVJyt7TdDPZoPBPNHbekX4fWnCJL1yQukhCeZhJySUL+gL9y6sNdN95uEOS83Y55SqHcP7MzLA==", "license": "MIT", - "dependencies": { - "loader-utils": "^1.1.0", - "schema-utils": "^1.0.0" - }, "engines": { - "node": ">= 0.12.0" - } - }, - "node_modules/style-loader/node_modules/schema-utils": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-1.0.0.tgz", - "integrity": "sha512-i27Mic4KovM/lnGsy8whRCHhc7VicJajAjTrYg11K9zfZXnYIt4k5F+kZkwjnrhKzLic/HLU4j11mjsz2G/75g==", - "license": "MIT", - "dependencies": { - "ajv": "^6.1.0", - "ajv-errors": "^1.0.0", - "ajv-keywords": "^3.1.0" + "node": ">= 18.12.0" }, - "engines": { - "node": ">= 4" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.27.0" } }, "node_modules/style-search": { @@ -24106,40 +19779,6 @@ "node": ">=4" } }, - "node_modules/supports-hyperlinks": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/supports-hyperlinks/-/supports-hyperlinks-2.3.0.tgz", - "integrity": "sha512-RpsAZlpWcDwOPQA22aCH4J0t7L8JmAvsCxfOSEwm7cQs3LshN36QaTkwd70DnBOXDWGssw2eUoc8CaRWT0XunA==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0", - "supports-color": "^7.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-hyperlinks/node_modules/has-flag": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", - "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, - "node_modules/supports-hyperlinks/node_modules/supports-color": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", - "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", - "dev": true, - "dependencies": { - "has-flag": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/supports-preserve-symlinks-flag": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", @@ -24167,59 +19806,6 @@ "integrity": "sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==", "dev": true }, - "node_modules/svgo": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/svgo/-/svgo-0.7.2.tgz", - "integrity": "sha512-jT/g9FFMoe9lu2IT6HtAxTA7RR2XOrmcrmCtGnyB/+GQnV6ZjNn+KOHZbZ35yL81+1F/aB6OeEsJztzBQ2EEwA==", - "deprecated": "This SVGO version is no longer supported. Upgrade to v2.x.x.", - "dependencies": { - "coa": "~1.0.1", - "colors": "~1.1.2", - "csso": "~2.3.1", - "js-yaml": "~3.7.0", - "mkdirp": "~0.5.1", - "sax": "~1.2.1", - "whet.extend": "~0.9.9" - }, - "bin": { - "svgo": "bin/svgo" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/svgo/node_modules/colors": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/colors/-/colors-1.1.2.tgz", - "integrity": "sha512-ENwblkFQpqqia6b++zLD/KUWafYlVY/UNnAp7oz7LY7E924wmpye416wBOmvv/HMWzl8gL1kJlfvId/1Dg176w==", - "engines": { - "node": ">=0.1.90" - } - }, - "node_modules/svgo/node_modules/esprima": { - "version": "2.7.3", - "resolved": "https://registry.npmjs.org/esprima/-/esprima-2.7.3.tgz", - "integrity": "sha512-OarPfz0lFCiW4/AV2Oy1Rp9qu0iusTKqykwTspGCZtPxmF81JR4MmIebvF1F9+UOKth2ZubLQ4XGGaU+hSn99A==", - "bin": { - "esparse": "bin/esparse.js", - "esvalidate": "bin/esvalidate.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/svgo/node_modules/js-yaml": { - "version": "3.7.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.7.0.tgz", - "integrity": "sha512-eIlkGty7HGmntbV6P/ZlAsoncFLGsNoM27lkTzS+oneY/EiNhj+geqD9ezg/ip+SW6Var0BJU2JtV0vEUZpWVQ==", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^2.6.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" - } - }, "node_modules/symbol-observable": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-1.2.0.tgz", @@ -24276,91 +19862,45 @@ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", "dev": true }, - "node_modules/tapable": { - "version": "0.1.10", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.1.10.tgz", - "integrity": "sha512-jX8Et4hHg57mug1/079yitEKWGB3LCwoxByLsNim89LABq8NqgiX+6iYVOsq0vX8uJHkU+DZ5fnq95f800bEsQ==", - "dev": true, - "engines": { - "node": ">=0.6" - } - }, "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", + "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", + "license": "ISC", "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" + "@isaacs/fs-minipass": "^4.0.0", + "chownr": "^3.0.0", + "minipass": "^7.1.2", + "minizlib": "^3.0.1", + "mkdirp": "^3.0.1", + "yallist": "^5.0.0" }, "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "engines": { - "node": ">=8" + "node": ">=18" } }, "node_modules/tar/node_modules/mkdirp": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", - "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", + "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", + "license": "MIT", "bin": { - "mkdirp": "bin/cmd.js" + "mkdirp": "dist/cjs/src/bin.js" }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, "node_modules/tar/node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" - }, - "node_modules/terminal-link": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/terminal-link/-/terminal-link-2.1.1.tgz", - "integrity": "sha512-un0FmiRUQNr5PJqy9kP7c40F5BOfpGlYTrxonDChEZB7pzZxRNp/bt+ymiy9/npwXya9KH99nJ/GXFIiUkYGFQ==", - "dev": true, - "dependencies": { - "ansi-escapes": "^4.2.1", - "supports-hyperlinks": "^2.0.0" - }, + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=18" } }, "node_modules/terser": { @@ -24479,25 +20019,12 @@ "node": ">=8" } }, - "node_modules/text-encoding": { - "version": "0.6.4", - "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", - "integrity": "sha512-hJnc6Qg3dWoOMkqP53F0dzRIgtmsAge09kxUIqGrEUS4qr5rWLckGYaQAVr+opBrIMRErGgy6f5aPnyPpyGRfg==", - "deprecated": "no longer maintained", - "dev": true - }, "node_modules/text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", "integrity": "sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==", "dev": true }, - "node_modules/throat": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/throat/-/throat-5.0.0.tgz", - "integrity": "sha512-fcwX4mndzpLQKBS1DVYhGAcYaYt7vsHNIvQV+WXMvnow5cgjPphq5CaayLaGsjRdSCKZFNGt7/GYAuXaNOiYCA==", - "dev": true - }, "node_modules/through": { "version": "2.3.8", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", @@ -24571,14 +20098,6 @@ "integrity": "sha512-LhVdShQD/4Mk4zXNroIQZJC+Ap3zgLcDuwEdcmLv9CCO73NWockQDwyUnW/m8VX/EElfL6FcYx7EeutN4HJA6A==", "dev": true }, - "node_modules/to-fast-properties": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", - "integrity": "sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==", - "engines": { - "node": ">=4" - } - }, "node_modules/to-object-path": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", @@ -24665,67 +20184,16 @@ "node": ">=6" } }, - "node_modules/tough-cookie/node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "engines": { - "node": ">=6" - } - }, "node_modules/tr46": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-2.1.0.tgz", - "integrity": "sha512-15Ih7phfcdP5YxqiB+iDtLoaTz4Nd35+IiAv0kQ5FNKHzXgdWqPoTIqEDDJmXceQt4JZk6lVPT8lnDlPpGDppw==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-3.0.0.tgz", + "integrity": "sha512-l7FvfAHlcmulp8kr+flpQZmVwtu7nfRV7NZujtN0OqES8EL4O4e0qqzL0DC5gAvx/ZC/9lk6rhcUwYvkBnBnYA==", + "license": "MIT", "dependencies": { "punycode": "^2.1.1" }, "engines": { - "node": ">=8" - } - }, - "node_modules/tr46/node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "engines": { - "node": ">=6" - } - }, - "node_modules/tsconfig-paths": { - "version": "3.15.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", - "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", - "dev": true, - "peer": true, - "dependencies": { - "@types/json5": "^0.0.29", - "json5": "^1.0.2", - "minimist": "^1.2.6", - "strip-bom": "^3.0.0" - } - }, - "node_modules/tsconfig-paths/node_modules/json5": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", - "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", - "dev": true, - "peer": true, - "dependencies": { - "minimist": "^1.2.0" - }, - "bin": { - "json5": "lib/cli.js" - } - }, - "node_modules/tsconfig-paths/node_modules/strip-bom": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", - "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", - "dev": true, - "peer": true, - "engines": { - "node": ">=4" + "node": ">=12" } }, "node_modules/tslib": { @@ -24733,68 +20201,12 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, - "node_modules/tsutils": { - "version": "3.21.0", - "resolved": "https://registry.npmjs.org/tsutils/-/tsutils-3.21.0.tgz", - "integrity": "sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "tslib": "^1.8.1" - }, - "engines": { - "node": ">= 6" - }, - "peerDependencies": { - "typescript": ">=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta" - } - }, - "node_modules/tsutils/node_modules/tslib": { - "version": "1.14.1", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz", - "integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==", - "dev": true, - "license": "0BSD", - "peer": true - }, - "node_modules/tunnel-agent": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", - "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", - "dev": true, - "dependencies": { - "safe-buffer": "^5.0.1" - }, - "engines": { - "node": "*" - } - }, - "node_modules/tweetnacl": { - "version": "0.14.5", - "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", - "integrity": "sha512-KXXFFdAbFXY4geFIwoyNK+f5Z1b7swfXABfL7HXCmoIWMKU3dmS26672A4EeQtDzLKy7SXmfBu51JolvEKwtGA==", - "dev": true - }, "node_modules/type": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==", "dev": true }, - "node_modules/type-check": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", - "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", - "dev": true, - "peer": true, - "dependencies": { - "prelude-ls": "^1.2.1" - }, - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -24902,21 +20314,13 @@ "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "dev": true }, - "node_modules/typedarray-to-buffer": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/typedarray-to-buffer/-/typedarray-to-buffer-3.1.5.tgz", - "integrity": "sha512-zdu8XMNEDepKKR+XYOXAVPtWui0ly0NtohUscw+UmaHiAWT8hrV1rr//H6V+0DvJ3OQ19S979M0laLfX8rm82Q==", - "dev": true, - "dependencies": { - "is-typedarray": "^1.0.0" - } - }, "node_modules/typescript": { "version": "5.4.5", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "dev": true, "license": "Apache-2.0", + "optional": true, "peer": true, "bin": { "tsc": "bin/tsc", @@ -24981,38 +20385,11 @@ "integrity": "sha512-2qReiiM1W8dVZakdAhZuEVxQvr+/MlqWGFkPckpDT733tUgYRQmJMvjulXs6cnmpMUqc+d1OVaYbnInZ4tzC7A==", "dev": true }, - "node_modules/ua-parser-js": { - "version": "0.7.37", - "resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-0.7.37.tgz", - "integrity": "sha512-xV8kqRKM+jhMvcHWUKthV9fNebIzrNy//2O9ZwWcfiBFR5f25XVZPLlEajk/sf3Ra15V92isyQqnIEXRDaZWEA==", - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/ua-parser-js" - }, - { - "type": "paypal", - "url": "https://paypal.me/faisalman" - }, - { - "type": "github", - "url": "https://github.com/sponsors/faisalman" - } - ], - "engines": { - "node": "*" - } - }, "node_modules/uglify-js": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.7.0.tgz", - "integrity": "sha512-0casKM/s/IaTnQNkRe4iqNdRG2BvHEeJvmaB+g19VE50I4zxJ3E3+DdTWc7hyrR9VScuMDGAaQm3NLSqTu8Bmw==", - "dependencies": { - "async": "~0.2.6", - "source-map": "~0.5.1", - "uglify-to-browserify": "~1.0.0", - "yargs": "~3.10.0" - }, + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", "bin": { "uglifyjs": "bin/uglifyjs" }, @@ -25020,61 +20397,6 @@ "node": ">=0.8.0" } }, - "node_modules/uglify-js/node_modules/async": { - "version": "0.2.10", - "resolved": "https://registry.npmjs.org/async/-/async-0.2.10.tgz", - "integrity": "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ==" - }, - "node_modules/uglify-js/node_modules/camelcase": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", - "integrity": "sha512-wzLkDa4K/mzI1OSITC+DUyjgIl/ETNHE9QvYgy6J6Jvqyyz4C0Xfd+lQhb19sX2jMpZV4IssUn0VDVmglV+s4g==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/uglify-js/node_modules/cliui": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", - "integrity": "sha512-GIOYRizG+TGoc7Wgc1LiOTLare95R3mzKgoln+Q/lE4ceiYH19gUpl0l0Ffq4lJDEf3FxujMe6IBfOCs7pfqNA==", - "dependencies": { - "center-align": "^0.1.1", - "right-align": "^0.1.1", - "wordwrap": "0.0.2" - } - }, - "node_modules/uglify-js/node_modules/source-map": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", - "integrity": "sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/uglify-js/node_modules/wordwrap": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", - "integrity": "sha512-xSBsCeh+g+dinoBv3GAOWM4LcVVO68wLXRanibtBSdUvkGWQRGeE9P7IwU9EmDDi4jA6L44lz15CGMwdw9N5+Q==", - "engines": { - "node": ">=0.4.0" - } - }, - "node_modules/uglify-js/node_modules/yargs": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", - "integrity": "sha512-QFzUah88GAGy9lyDKGBqZdkYApt63rCXYBGYnEP4xDJPXNqXXnBDACnbrXnViV6jRSqAePwrATi2i8mfYm4L1A==", - "dependencies": { - "camelcase": "^1.0.2", - "cliui": "^2.1.0", - "decamelize": "^1.0.0", - "window-size": "0.1.0" - } - }, - "node_modules/uglify-to-browserify": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", - "integrity": "sha512-vb2s1lYx2xBtUgy+ta+b2J/GLVUR+wmpINwHePmPRhOsIVCG2wDzKJ0n14GslH1BifsqVzSOwQhRaCAsZ/nI4Q==" - }, "node_modules/ultron": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/ultron/-/ultron-1.0.2.tgz", @@ -25110,9 +20432,9 @@ } }, "node_modules/underscore": { - "version": "1.13.1", - "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.1.tgz", - "integrity": "sha512-hzSoAVtJF+3ZtiFX0VgfFPHEDRm7Y/QPjGyNo4TVdnDTdft3tr8hEkD25a1jC+TjTuE7tkHGKkhwCgs9dgBB2g==", + "version": "1.13.7", + "resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz", + "integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g==", "license": "MIT" }, "node_modules/underscore.string": { @@ -25138,9 +20460,10 @@ "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, "node_modules/unicode-canonical-property-names-ecmascript": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.0.tgz", - "integrity": "sha512-yY5PpDlfVIU5+y/BSCxAJRBIS1Zc2dDG3Ujq+sR0U+JjUevW2JhocOF+soROYDSaAezOzOKuyyixhD6mBknSmQ==", + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "license": "MIT", "engines": { "node": ">=4" } @@ -25149,6 +20472,7 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "license": "MIT", "dependencies": { "unicode-canonical-property-names-ecmascript": "^2.0.0", "unicode-property-aliases-ecmascript": "^2.0.0" @@ -25158,9 +20482,10 @@ } }, "node_modules/unicode-match-property-value-ecmascript": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.1.0.tgz", - "integrity": "sha512-qxkjQt6qjg/mYscYMC0XKRn3Rh0wFPlfxB0xkt9CfyTvpX1Ra0+rAmdX2QyAobptSEvuy4RtpPRui6XkV+8wjA==", + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", + "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "license": "MIT", "engines": { "node": ">=4" } @@ -25169,6 +20494,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "license": "MIT", "engines": { "node": ">=4" } @@ -25188,36 +20514,28 @@ "node": ">=0.10.0" } }, - "node_modules/uniq": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/uniq/-/uniq-1.0.1.tgz", - "integrity": "sha512-Gw+zz50YNKPDKXs+9d+aKAjVwpjNwqzvNpLigIruT4HA9lMZNdMqs9x07kKHB/L9WRzqp4+DlTU5s4wG2esdoA==" - }, - "node_modules/uniqs": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/uniqs/-/uniqs-2.0.0.tgz", - "integrity": "sha512-mZdDpf3vBV5Efh29kMw5tXoup/buMgxLzOt/XKFKcVmi+15ManNQWr6HfZ2aiZTYlYixbdNJ0KFmIZIv52tHSQ==" - }, "node_modules/unique-filename": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", - "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-4.0.0.tgz", + "integrity": "sha512-XSnEewXmQ+veP7xX2dS5Q4yZAvO40cBN2MWkJ7D/6sW4Dg6wYBNwM1Vrnz1FhH5AdeLIlUXRI9e28z1YZi71NQ==", + "license": "ISC", "dependencies": { - "unique-slug": "^4.0.0" + "unique-slug": "^5.0.0" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/unique-slug": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", - "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-5.0.0.tgz", + "integrity": "sha512-9OdaqO5kwqR+1kVgHAhsp5vPNU0hnxRa26rBFNfNgM7M6pNtgzeBn3s/xbyCQL3dcjzOatcef6UUHpB/6MaETg==", + "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": "^18.17.0 || >=20.5.0" } }, "node_modules/universal-cookie": { @@ -25318,9 +20636,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", - "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", "funding": [ { "type": "opencollective", @@ -25337,8 +20655,8 @@ ], "license": "MIT", "dependencies": { - "escalade": "^3.1.2", - "picocolors": "^1.0.1" + "escalade": "^3.2.0", + "picocolors": "^1.1.0" }, "bin": { "update-browserslist-db": "cli.js" @@ -25355,14 +20673,6 @@ "punycode": "^2.1.0" } }, - "node_modules/uri-js/node_modules/punycode": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", - "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", - "engines": { - "node": ">=6" - } - }, "node_modules/urijs": { "version": "1.19.11", "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", @@ -25385,6 +20695,11 @@ "requires-port": "^1.0.0" } }, + "node_modules/url-toolkit": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/url-toolkit/-/url-toolkit-2.2.5.tgz", + "integrity": "sha512-mtN6xk+Nac+oyJ/PrI7tzfmomRVNFIWKUbG8jdYFt52hxbiReFAXIjYskvu64/dvuW71IcB7lV8l0HvZMac6Jg==" + }, "node_modules/use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", @@ -25506,32 +20821,32 @@ "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", "dev": true, "optional": true, + "peer": true, "bin": { "uuid": "dist/bin/uuid" } }, "node_modules/v8-to-istanbul": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-7.1.2.tgz", - "integrity": "sha512-TxNb7YEUwkLXCQYeudi6lgQ/SZrzNO4kMdlqVxaZPUIUjCv6iSSypUQX70kNBSERpQ8fk48+d61FXk+tgqcWow==", + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", + "integrity": "sha512-kiGUalWN+rgBJ/1OHZsBtU4rXZOfj/7rKQxULKlIzwzQSvMJUUNgPwJEEh7gU6xEVxC0ahoOBvN2YI8GH6FNgA==", "dev": true, + "license": "ISC", "dependencies": { + "@jridgewell/trace-mapping": "^0.3.12", "@types/istanbul-lib-coverage": "^2.0.1", - "convert-source-map": "^1.6.0", - "source-map": "^0.7.3" + "convert-source-map": "^2.0.0" }, "engines": { - "node": ">=10.10.0" + "node": ">=10.12.0" } }, - "node_modules/v8-to-istanbul/node_modules/source-map": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", - "integrity": "sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==", + "node_modules/v8-to-istanbul/node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true, - "engines": { - "node": ">= 8" - } + "license": "MIT" }, "node_modules/validate-npm-package-license": { "version": "3.0.4", @@ -25548,35 +20863,6 @@ "resolved": "https://registry.npmjs.org/value-equal/-/value-equal-1.0.1.tgz", "integrity": "sha512-NOJ6JZCAWr0zlxZt+xqCHNTEKOsrks2HQd4MqhP1qy4z1SkbEP467eNx6TgDKXMvUOb+OENfJCZwM+16n7fRfw==" }, - "node_modules/vendors": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/vendors/-/vendors-1.0.4.tgz", - "integrity": "sha512-/juG65kTL4Cy2su4P8HjtkTxk6VmJDiOPBufWniqQ6wknac6jNiXS9vU+hO3wgusiyqWlzTbVHi0dyJqRONg3w==", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/wooorm" - } - }, - "node_modules/verror": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", - "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", - "dev": true, - "engines": [ - "node >=0.6.0" - ], - "dependencies": { - "assert-plus": "^1.0.0", - "core-util-is": "1.0.2", - "extsprintf": "^1.2.0" - } - }, - "node_modules/verror/node_modules/core-util-is": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", - "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", - "dev": true - }, "node_modules/void-elements": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/void-elements/-/void-elements-2.0.1.tgz", @@ -25586,24 +20872,25 @@ "node": ">=0.10.0" } }, - "node_modules/w3c-hr-time": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/w3c-hr-time/-/w3c-hr-time-1.0.2.tgz", - "integrity": "sha512-z8P5DvDNjKDoFIHK7q8r8lackT6l+jo/Ye3HOle7l9nICP9lf1Ci25fy9vHd0JOWewkIFzXIEig3TdKT7JQ5fQ==", - "deprecated": "Use your platform's native performance.now() and performance.timeOrigin.", - "dependencies": { - "browser-process-hrtime": "^1.0.0" - } - }, "node_modules/w3c-xmlserializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-2.0.0.tgz", - "integrity": "sha512-4tzD0mF8iSiMiNs30BiLO3EpfGLZUT2MSX/G+o7ZywDzliWQ3OPtTZ0PTC3B3ca1UAf4cJMHB+2Bf56EriJuRA==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-4.0.0.tgz", + "integrity": "sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==", + "license": "MIT", "dependencies": { - "xml-name-validator": "^3.0.0" + "xml-name-validator": "^4.0.0" }, "engines": { - "node": ">=10" + "node": ">=14" + } + }, + "node_modules/w3c-xmlserializer/node_modules/xml-name-validator": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", + "integrity": "sha512-ICP2e+jsHvAj2E2lIHxa5tjXRlKDJo4IdvPvCXbXQGdzSfmSpNVyIKMvoZHjDY9DP0zV17iI85o90vRFXNccRw==", + "license": "Apache-2.0", + "engines": { + "node": ">=12" } }, "node_modules/walker": { @@ -25636,26 +20923,27 @@ } }, "node_modules/webidl-conversions": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-6.1.0.tgz", - "integrity": "sha512-qBIvFLGiBpLjfwmYAaHPXsn+ho5xZnGvyGvsarywGNc8VyQJUMHJ8OBKGGrPER0okBeMDaan4mNBlgBROxuI8w==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "license": "BSD-2-Clause", "engines": { - "node": ">=10.4" + "node": ">=12" } }, "node_modules/webpack": { - "version": "5.94.0", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.94.0.tgz", - "integrity": "sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==", + "version": "5.97.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.97.1.tgz", + "integrity": "sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg==", "license": "MIT", "dependencies": { - "@types/estree": "^1.0.5", - "@webassemblyjs/ast": "^1.12.1", - "@webassemblyjs/wasm-edit": "^1.12.1", - "@webassemblyjs/wasm-parser": "^1.12.1", - "acorn": "^8.7.1", - "acorn-import-attributes": "^1.9.5", - "browserslist": "^4.21.10", + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.6", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.14.0", + "browserslist": "^4.24.0", "chrome-trace-event": "^1.0.2", "enhanced-resolve": "^5.17.1", "es-module-lexer": "^1.2.1", @@ -25874,44 +21162,23 @@ "node": ">=6" } }, - "node_modules/whatwg-encoding": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-1.0.5.tgz", - "integrity": "sha512-b5lim54JOPN9HtzvK9HFXvBma/rnfFeqsic0hSpjtDbVxR3dJKLc+KB4V6GgiGOvl7CY/KNh8rxSo9DKQrnUEw==", - "dependencies": { - "iconv-lite": "0.4.24" - } - }, "node_modules/whatwg-fetch": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/whatwg-fetch/-/whatwg-fetch-2.0.4.tgz", "integrity": "sha512-dcQ1GWpOD/eEQ97k66aiEVpNnapVj90/+R+SXTPYGHpYBBypfKJEQjLrvMZ7YXbKm21gXd4NcuxUTjiv1YtLng==", "license": "MIT" }, - "node_modules/whatwg-mimetype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-2.3.0.tgz", - "integrity": "sha512-M4yMwr6mAnQz76TbJm914+gPpB/nCwvZbJU28cUD6dR004SAxDLOOSUaB1JDRqLtaOV/vi0IC5lEAGFgrjGv/g==" - }, "node_modules/whatwg-url": { - "version": "8.7.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-8.7.0.tgz", - "integrity": "sha512-gAojqb/m9Q8a5IV96E3fHJM70AzCkgt4uXYX2O7EmuyOnLrViCQlsEBmF9UQIu3/aeAIp2U17rtbpZWNntQqdg==", + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", + "integrity": "sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==", + "license": "MIT", "dependencies": { - "lodash": "^4.7.0", - "tr46": "^2.1.0", - "webidl-conversions": "^6.1.0" + "tr46": "^3.0.0", + "webidl-conversions": "^7.0.0" }, "engines": { - "node": ">=10" - } - }, - "node_modules/whet.extend": { - "version": "0.9.9", - "resolved": "https://registry.npmjs.org/whet.extend/-/whet.extend-0.9.9.tgz", - "integrity": "sha512-mmIPAft2vTgEILgPeZFqE/wWh24SEsR/k+N9fJ3Jxrz44iDFy9aemCxdksfURSHYFCLmvs/d/7Iso5XjPpNfrA==", - "engines": { - "node": ">=0.6.0" + "node": ">=12" } }, "node_modules/which": { @@ -25943,46 +21210,10 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/which-builtin-type": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.3.tgz", - "integrity": "sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==", - "dev": true, - "peer": true, - "dependencies": { - "function.prototype.name": "^1.1.5", - "has-tostringtag": "^1.0.0", - "is-async-function": "^2.0.0", - "is-date-object": "^1.0.5", - "is-finalizationregistry": "^1.0.2", - "is-generator-function": "^1.0.10", - "is-regex": "^1.1.4", - "is-weakref": "^1.0.2", - "isarray": "^2.0.5", - "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.9" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/which-builtin-type/node_modules/isarray": { - "version": "2.0.5", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", - "dev": true, - "peer": true - }, "node_modules/which-collection": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", - "dev": true, - "peer": true, "dependencies": { "is-map": "^2.0.3", "is-set": "^2.0.3", @@ -26005,12 +21236,6 @@ "rbush": "^1.3.2" } }, - "node_modules/which-module": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", - "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", - "dev": true - }, "node_modules/which-typed-array": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", @@ -26035,14 +21260,6 @@ "integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==", "dev": true }, - "node_modules/window-size": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", - "integrity": "sha512-1pTPQDKTdd61ozlKGNCjhNRd+KPmgLSGa3mZTHoOliaGcESD8G1PXhh7c1fgiPjVbNVfgy2Faw4BI8/m0cC8Mg==", - "engines": { - "node": ">= 0.8.0" - } - }, "node_modules/word-wrap": { "version": "1.2.5", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", @@ -26052,24 +21269,22 @@ "node": ">=0.10.0" } }, - "node_modules/wordwrap": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", - "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", - "dev": true - }, "node_modules/wrap-ansi": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", - "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", "dev": true, + "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" }, "engines": { - "node": ">=8" + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, "node_modules/wrap-ansi-cjs": { @@ -26124,6 +21339,7 @@ "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", "dev": true, + "license": "MIT", "dependencies": { "color-convert": "^2.0.1" }, @@ -26139,6 +21355,7 @@ "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", "dependencies": { "color-name": "~1.1.4" }, @@ -26150,7 +21367,8 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true + "dev": true, + "license": "MIT" }, "node_modules/wrappy": { "version": "1.0.2", @@ -26170,27 +21388,30 @@ } }, "node_modules/write-file-atomic": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-3.0.3.tgz", - "integrity": "sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==", + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/write-file-atomic/-/write-file-atomic-4.0.2.tgz", + "integrity": "sha512-7KxauUdBmSdWnmpaGFg+ppNjKF8uNLry8LyzjauQDOVONfFLNKrKvQOxZ/VuTIcS/gge/YNahf5RIIQWTSarlg==", "dev": true, + "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4", - "is-typedarray": "^1.0.0", - "signal-exit": "^3.0.2", - "typedarray-to-buffer": "^3.1.5" + "signal-exit": "^3.0.7" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, "node_modules/ws": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", + "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", + "license": "MIT", "engines": { - "node": ">=8.3.0" + "node": ">=10.0.0" }, "peerDependencies": { "bufferutil": "^4.0.1", - "utf-8-validate": "^5.0.2" + "utf-8-validate": ">=5.0.2" }, "peerDependenciesMeta": { "bufferutil": { @@ -26207,24 +21428,21 @@ "integrity": "sha512-qfR6ovmRRMxNHgUNYI9LRdVofApe/eYrv4ggNOvvCP+pPdEo9Ym93QN4jUceGD6PignBbp2zAzgoE7GibAdq2A==", "dev": true }, - "node_modules/xml-name-validator": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-3.0.0.tgz", - "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==" - }, "node_modules/xmlbuilder": { - "version": "8.2.2", - "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", - "integrity": "sha512-eKRAFz04jghooy8muekqzo8uCSVNeyRedbuJrp0fovbLIi7wlsYtdUn3vBAAPq2Y3/0xMz2WMEUQ8yhVVO9Stw==", + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-12.0.0.tgz", + "integrity": "sha512-lMo8DJ8u6JRWp0/Y4XLa/atVDr75H9litKlb2E5j3V3MesoL50EBgZDWoLT3F/LztVnG67GjPXLZpqcky/UMnQ==", "dev": true, + "license": "MIT", "engines": { - "node": ">=4.0" + "node": ">=6.0" } }, "node_modules/xmlchars": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", - "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "license": "MIT" }, "node_modules/xmlhttprequest-ssl": { "version": "1.6.3", @@ -26245,10 +21463,14 @@ } }, "node_modules/y18n": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", - "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", - "dev": true + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } }, "node_modules/yallist": { "version": "3.1.1", @@ -26256,25 +21478,22 @@ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==" }, "node_modules/yargs": { - "version": "15.4.1", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", - "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", "dev": true, + "license": "MIT", "dependencies": { - "cliui": "^6.0.0", - "decamelize": "^1.2.0", - "find-up": "^4.1.0", - "get-caller-file": "^2.0.1", + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", - "require-main-filename": "^2.0.0", - "set-blocking": "^2.0.0", - "string-width": "^4.2.0", - "which-module": "^2.0.0", - "y18n": "^4.0.0", - "yargs-parser": "^18.1.2" + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" }, "engines": { - "node": ">=8" + "node": ">=12" } }, "node_modules/yargs-parser": { @@ -26287,16 +21506,13 @@ } }, "node_modules/yargs/node_modules/yargs-parser": { - "version": "18.1.3", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", - "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", "dev": true, - "dependencies": { - "camelcase": "^5.0.0", - "decamelize": "^1.2.0" - }, + "license": "ISC", "engines": { - "node": ">=6" + "node": ">=12" } }, "node_modules/yeast": { diff --git a/package.json b/package.json index 15e2e90586..2ca951d7db 100644 --- a/package.json +++ b/package.json @@ -12,20 +12,42 @@ "compile-sass-dev": "scripts/compile_sass.py --env=development", "watch": "{ npm run watch-webpack& npm run watch-sass& } && sleep infinity", "watch-webpack": "npm run webpack-dev -- --watch", - "watch-sass": "scripts/watch_sass.sh" + "watch-sass": "scripts/watch_sass.sh", + "test": "npm run test-jest && npm run test-karma", + "test-jest": "jest", + "test-karma": "npm run test-karma-vanilla && npm run test-karma-require && echo 'WARNING: Skipped broken webpack tests. For details, see: https://github.com/openedx/edx-platform/issues/35956'", + "test-karma-vanilla": "npm run test-cms-vanilla && npm run test-xmodule-vanilla && npm run test-common-vanilla", + "test-karma-require": "npm run test-cms-require && npm run test-common-require", + "test-karma-webpack": "npm run test-cms-webpack && npm run test-lms-webpack && npm run test-xmodule-webpack", + "test-karma-conf": "${NODE_WRAPPER:-xvfb-run --auto-servernum} node --max_old_space_size=4096 node_modules/.bin/karma start --single-run=true --capture-timeout=60000 --browsers=FirefoxNoUpdates", + "test-cms": "npm run test-cms-vanilla && npm run test-cms-require && npm run test-cms-webpack", + "test-cms-vanilla": "npm run test-karma-conf -- cms/static/karma_cms.conf.js", + "test-cms-require": "npm run test-karma-conf -- cms/static/karma_cms_squire.conf.js", + "test-cms-webpack": "npm run test-karma-conf -- cms/static/karma_cms_webpack.conf.js", + "test-lms": "npm run test-jest && npm run test-lms-webpack", + "test-lms-webpack": "npm run test-karma-conf -- lms/static/karma_lms.conf.js", + "test-xmodule": "npm run test-xmodule-vanilla && npm run test-xmodule-webpack", + "test-xmodule-vanilla": "npm run test-karma-conf -- xmodule/js/karma_xmodule.conf.js", + "test-xmodule-webpack": "npm run test-karma-conf -- xmodule/js/karma_xmodule_webpack.conf.js", + "test-common": "npm run test-common-vanilla && npm run test-common-require", + "test-common-vanilla": "npm run test-karma-conf -- common/static/karma_common.conf.js", + "test-common-require": "npm run test-karma-conf -- common/static/karma_common_requirejs.conf.js" }, "dependencies": { - "@babel/core": "7.25.2", + "@babel/core": "7.26.0", "@babel/plugin-proposal-object-rest-spread": "^7.18.9", "@babel/plugin-transform-object-assign": "^7.18.6", "@babel/preset-env": "^7.19.0", - "@babel/preset-react": "7.24.7", + "@babel/preset-react": "7.26.3", "@edx/brand-edx.org": "^2.0.7", "@edx/edx-bootstrap": "1.0.4", "@edx/edx-proctoring": "^4.18.1", "@edx/frontend-component-cookie-policy-banner": "2.2.0", "@edx/paragon": "2.6.4", "@edx/studio-frontend": "^2.1.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^12.1.5", + "@testing-library/user-event": "^12.8.3", "babel-loader": "^9.1.3", "babel-plugin-transform-class-properties": "6.24.1", "babel-polyfill": "6.26.0", @@ -35,80 +57,73 @@ "bootstrap": "4.0.0", "camelize": "1.0.1", "classnames": "2.5.1", - "css-loader": "0.28.8", + "css-loader": "7.1.2", "datatables": "1.10.18", "datatables.net-fixedcolumns": "5.0.4", "edx-proctoring-proctortrack": "git+https://git@github.com/anupdhabarde/edx-proctoring-proctortrack.git#f0fa9edbd16aa5af5a41ac309d2609e529ea8732", - "edx-ui-toolkit": "1.8.6", + "edx-ui-toolkit": "1.8.7", "exports-loader": "0.6.4", "file-loader": "^6.2.0", "font-awesome": "4.7.0", - "hls.js": "1.5.17", + "hls.js": "0.14.17", "imports-loader": "0.8.0", - "jest-environment-jsdom": "^26.0.0", + "jest-environment-jsdom": "^29.0.0", "jquery": "2.2.4", "jquery-migrate": "1.4.1", "jquery.scrollto": "2.1.3", "js-cookie": "3.0.5", "moment": "2.30.1", - "moment-timezone": "0.5.46", - "node-gyp": "10.0.1", - "picturefill": "3.0.3", + "moment-timezone": "0.5.47", + "node-gyp": "11.1.0", "popper.js": "1.16.1", - "prop-types": "15.6.0", + "prop-types": "15.8.1", "raw-loader": "0.5.1", "react": "16.14.0", "react-dom": "16.14.0", "react-focus-lock": "^1.19.1", - "react-redux": "5.0.7", - "react-router-dom": "5.1.2", - "react-slick": "0.30.2", + "react-redux": "5.1.2", + "react-router-dom": "5.3.4", + "react-slick": "0.30.3", "redux": "3.7.2", "redux-thunk": "2.2.0", "requirejs": "2.3.7", - "rtlcss": "2.6.2", + "rtlcss": "4.3.0", "sass": "^1.54.8", - "sass-loader": "^14.1.1", + "sass-loader": "^16.0.0", "scriptjs": "2.5.9", - "style-loader": "0.23.1", + "style-loader": "4.0.0", "svg-inline-loader": "0.8.2", - "uglify-js": "2.7.0", - "underscore": "1.13.1", + "uglify-js": "3.19.3", + "underscore": "1.13.7", "underscore.string": "3.3.6", "webpack": "^5.90.3", "webpack-bundle-tracker": "0.4.3", "webpack-merge": "4.2.2", - "whatwg-fetch": "2.0.4", "which-country": "1.0.0" }, "devDependencies": { - "@edx/eslint-config": "^4.0.0", "@edx/mockprock": "github:openedx/mockprock#d70b05231bd46b0122616c24e209c890ef2633c0", "@edx/stylelint-config-edx": "2.3.3", - "babel-jest": "26.6.3", - "enzyme": "3.11.0", - "enzyme-adapter-react-16": "1.15.8", - "eslint-import-resolver-webpack": "0.13.9", + "babel-jest": "29.7.0", "jasmine-core": "2.6.4", "jasmine-jquery": "git+https://git@github.com/velesin/jasmine-jquery.git#ebad463d592d3fea00c69f26ea18a930e09c7b58", - "jest": "26.6.3", - "jest-enzyme": "6.1.2", + "jest": "29.7.0", "karma": "0.13.22", - "karma-chrome-launcher": "0.2.3", - "karma-coverage": "0.5.5", - "karma-firefox-launcher": "0.1.7", + "karma-chrome-launcher": "3.2.0", + "karma-coverage": "2.2.1", + "karma-firefox-launcher": "2.1.3", "karma-jasmine": "0.3.8", "karma-jasmine-html-reporter": "0.2.2", - "karma-junit-reporter": "1.2.0", - "karma-requirejs": "0.2.6", + "karma-junit-reporter": "2.0.1", + "karma-requirejs": "1.1.0", "karma-selenium-webdriver-launcher": "github:openedx/karma-selenium-webdriver-launcher#0.0.4-openedx.0", "karma-sourcemap-loader": "0.4.0", - "karma-spec-reporter": "0.0.36", + "karma-spec-reporter": "0.0.20", "karma-webpack": "^5.0.1", "plato": "1.7.0", "react-test-renderer": "16.14.0", - "selenium-webdriver": "4.26.0", - "sinon": "2.4.1", + "selenium-webdriver": "4.30.0", + "sinon": "19.0.2", "squirejs": "0.1.0", "string-replace-loader": "^3.1.0", "stylelint-formatter-pretty": "4.0.1", diff --git a/pavelib/__init__.py b/pavelib/__init__.py deleted file mode 100644 index 875068166f..0000000000 --- a/pavelib/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -""" # lint-amnesty, pylint: disable=django-not-configured -paver commands -""" - - -from . import assets, js_test, prereqs, quality diff --git a/pavelib/assets.py b/pavelib/assets.py deleted file mode 100644 index f437b6427f..0000000000 --- a/pavelib/assets.py +++ /dev/null @@ -1,506 +0,0 @@ -""" -Asset compilation and collection. - -This entire module is DEPRECATED. In Redwood, it exists just as a collection of temporary compatibility wrappers. -In Sumac, this module will be deleted. To migrate, follow the advice in the printed warnings and/or read the -instructions on the DEPR ticket: https://github.com/openedx/edx-platform/issues/31895 -""" - -import argparse -import glob -import json -import shlex -import traceback -from functools import wraps -from threading import Timer - -from paver import tasks -from paver.easy import call_task, cmdopts, consume_args, needs, no_help, sh, task -from watchdog.events import PatternMatchingEventHandler -from watchdog.observers import Observer # pylint: disable=unused-import # Used by Tutor. Remove after Sumac cut. - -from .utils.cmd import django_cmd -from .utils.envs import Env -from .utils.timer import timed - - -SYSTEMS = { - 'lms': 'lms', - 'cms': 'cms', - 'studio': 'cms', -} - -WARNING_SYMBOLS = "⚠️ " * 50 # A row of 'warning' emoji to catch CLI users' attention - - -def run_deprecated_command_wrapper(*, old_command, ignored_old_flags, new_command): - """ - Run the new version of shell command, plus a warning that the old version is deprecated. - """ - depr_warning = ( - "\n" + - f"{WARNING_SYMBOLS}\n" + - "\n" + - f"WARNING: '{old_command}' is DEPRECATED! It will be removed before Sumac.\n" + - "The command you ran is now just a temporary wrapper around a new,\n" + - "supported command, which you should use instead:\n" + - "\n" + - f"\t{new_command}\n" + - "\n" + - "Details: https://github.com/openedx/edx-platform/issues/31895\n" + - "".join( - f" WARNING: ignored deprecated paver flag '{flag}'\n" - for flag in ignored_old_flags - ) + - f"{WARNING_SYMBOLS}\n" + - "\n" - ) - # Print deprecation warning twice so that it's more likely to be seen in the logs. - print(depr_warning) - sh(new_command) - print(depr_warning) - - -def debounce(seconds=1): - """ - Prevents the decorated function from being called more than every `seconds` - seconds. Waits until calls stop coming in before calling the decorated - function. - - This is DEPRECATED. It exists in Redwood just to ease the transition for Tutor. - """ - def decorator(func): - func.timer = None - - @wraps(func) - def wrapper(*args, **kwargs): - def call(): - func(*args, **kwargs) - func.timer = None - if func.timer: - func.timer.cancel() - func.timer = Timer(seconds, call) - func.timer.start() - - return wrapper - return decorator - - -class SassWatcher(PatternMatchingEventHandler): - """ - Watches for sass file changes - - This is DEPRECATED. It exists in Redwood just to ease the transition for Tutor. - """ - ignore_directories = True - patterns = ['*.scss'] - - def register(self, observer, directories): - """ - register files with observer - Arguments: - observer (watchdog.observers.Observer): sass file observer - directories (list): list of directories to be register for sass watcher. - """ - for dirname in directories: - paths = [] - if '*' in dirname: - paths.extend(glob.glob(dirname)) - else: - paths.append(dirname) - - for obs_dirname in paths: - observer.schedule(self, obs_dirname, recursive=True) - - @debounce() - def on_any_event(self, event): - print('\tCHANGED:', event.src_path) - try: - compile_sass() # pylint: disable=no-value-for-parameter - except Exception: # pylint: disable=broad-except - traceback.print_exc() - - -@task -@no_help -@cmdopts([ - ('system=', 's', 'The system to compile sass for (defaults to all)'), - ('theme-dirs=', '-td', 'Theme dirs containing all themes (defaults to None)'), - ('themes=', '-t', 'The theme to compile sass for (defaults to None)'), - ('debug', 'd', 'Whether to use development settings'), - ('force', '', 'DEPRECATED. Full recompilation is now always forced.'), -]) -@timed -def compile_sass(options): - """ - Compile Sass to CSS. If command is called without any arguments, it will - only compile lms, cms sass for the open source theme. And none of the comprehensive theme's sass would be compiled. - - If you want to compile sass for all comprehensive themes you will have to run compile_sass - specifying all the themes that need to be compiled.. - - The following is a list of some possible ways to use this command. - - Command: - paver compile_sass - Description: - compile sass files for both lms and cms. If command is called like above (i.e. without any arguments) it will - only compile lms, cms sass for the open source theme. None of the theme's sass will be compiled. - - Command: - paver compile_sass --theme-dirs /edx/app/edxapp/edx-platform/themes --themes=red-theme - Description: - compile sass files for both lms and cms for 'red-theme' present in '/edx/app/edxapp/edx-platform/themes' - - Command: - paver compile_sass --theme-dirs=/edx/app/edxapp/edx-platform/themes --themes red-theme stanford-style - Description: - compile sass files for both lms and cms for 'red-theme' and 'stanford-style' present in - '/edx/app/edxapp/edx-platform/themes'. - - Command: - paver compile_sass --system=cms - --theme-dirs /edx/app/edxapp/edx-platform/themes /edx/app/edxapp/edx-platform/common/test/ - --themes red-theme stanford-style test-theme - Description: - compile sass files for cms only for 'red-theme', 'stanford-style' and 'test-theme' present in - '/edx/app/edxapp/edx-platform/themes' and '/edx/app/edxapp/edx-platform/common/test/'. - - This is a DEPRECATED COMPATIBILITY WRAPPER. Use `npm run compile-sass` instead. - """ - systems = [SYSTEMS[sys] for sys in get_parsed_option(options, 'system', ['lms', 'cms'])] # normalize studio->cms - run_deprecated_command_wrapper( - old_command="paver compile_sass", - ignored_old_flags=(set(["--force"]) & set(options)), - new_command=shlex.join([ - "npm", - "run", - ("compile-sass-dev" if options.get("debug") else "compile-sass"), - "--", - *(["--dry"] if tasks.environment.dry_run else []), - *(["--skip-lms"] if "lms" not in systems else []), - *(["--skip-cms"] if "cms" not in systems else []), - *( - arg - for theme_dir in get_parsed_option(options, 'theme_dirs', []) - for arg in ["--theme-dir", str(theme_dir)] - ), - *( - arg - for theme in get_parsed_option(options, "themes", []) - for arg in ["--theme", theme] - ), - ]), - ) - - -def _compile_sass(system, theme, debug, force, _timing_info): - """ - This is a DEPRECATED COMPATIBILITY WRAPPER - - It exists to ease the transition for Tutor in Redwood, which directly imported and used this function. - """ - run_deprecated_command_wrapper( - old_command="pavelib.assets:_compile_sass", - ignored_old_flags=(set(["--force"]) if force else set()), - new_command=[ - "npm", - "run", - ("compile-sass-dev" if debug else "compile-sass"), - "--", - *(["--dry"] if tasks.environment.dry_run else []), - *( - ["--skip-default", "--theme-dir", str(theme.parent), "--theme", str(theme.name)] - if theme - else [] - ), - ("--skip-cms" if system == "lms" else "--skip-lms"), - ] - ) - - -def process_npm_assets(): - """ - Process vendor libraries installed via NPM. - - This is a DEPRECATED COMPATIBILITY WRAPPER. It is now handled as part of `npm clean-install`. - If you need to invoke it explicitly, you can run `npm run postinstall`. - """ - run_deprecated_command_wrapper( - old_command="pavelib.assets:process_npm_assets", - ignored_old_flags=[], - new_command=shlex.join(["npm", "run", "postinstall"]), - ) - - -@task -@no_help -def process_xmodule_assets(): - """ - Process XModule static assets. - - This is a DEPRECATED COMPATIBILITY STUB. Refrences to it should be deleted. - """ - print( - "\n" + - f"{WARNING_SYMBOLS}", - "\n" + - "WARNING: 'paver process_xmodule_assets' is DEPRECATED! It will be removed before Sumac.\n" + - "\n" + - "Starting with Quince, it is no longer necessary to post-process XModule assets, so \n" + - "'paver process_xmodule_assets' is a no-op. Please simply remove it from your build scripts.\n" + - "\n" + - "Details: https://github.com/openedx/edx-platform/issues/31895\n" + - f"{WARNING_SYMBOLS}", - ) - - -def collect_assets(systems, settings, **kwargs): - """ - Collect static assets, including Django pipeline processing. - `systems` is a list of systems (e.g. 'lms' or 'studio' or both) - `settings` is the Django settings module to use. - `**kwargs` include arguments for using a log directory for collectstatic output. Defaults to /dev/null. - - This is a DEPRECATED COMPATIBILITY WRAPPER - - It exists to ease the transition for Tutor in Redwood, which directly imported and used this function. - """ - run_deprecated_command_wrapper( - old_command="pavelib.asset:collect_assets", - ignored_old_flags=[], - new_command=" && ".join( - "( " + - shlex.join( - ["./manage.py", SYSTEMS[sys], f"--settings={settings}", "collectstatic", "--noinput"] - ) + ( - "" - if "collect_log_dir" not in kwargs else - " > /dev/null" - if kwargs["collect_log_dir"] is None else - f"> {kwargs['collect_log_dir']}/{SYSTEMS[sys]}-collectstatic.out" - ) + - " )" - for sys in systems - ), - ) - - -def execute_compile_sass(args): - """ - Construct django management command compile_sass (defined in theming app) and execute it. - Args: - args: command line argument passed via update_assets command - - This is a DEPRECATED COMPATIBILITY WRAPPER. Use `npm run compile-sass` instead. - """ - for sys in args.system: - options = "" - options += " --theme-dirs " + " ".join(args.theme_dirs) if args.theme_dirs else "" - options += " --themes " + " ".join(args.themes) if args.themes else "" - options += " --debug" if args.debug else "" - - sh( - django_cmd( - sys, - args.settings, - "compile_sass {system} {options}".format( - system='cms' if sys == 'studio' else sys, - options=options, - ), - ), - ) - - -@task -@cmdopts([ - ('settings=', 's', "Django settings (defaults to devstack)"), - ('watch', 'w', "DEPRECATED. This flag never did anything anyway."), -]) -@timed -def webpack(options): - """ - Run a Webpack build. - - This is a DEPRECATED COMPATIBILITY WRAPPER. Use `npm run webpack` instead. - """ - settings = getattr(options, 'settings', Env.DEVSTACK_SETTINGS) - result = Env.get_django_settings(['STATIC_ROOT', 'WEBPACK_CONFIG_PATH'], "lms", settings=settings) - static_root_lms, config_path = result - static_root_cms, = Env.get_django_settings(["STATIC_ROOT"], "cms", settings=settings) - js_env_extra_config_setting, = Env.get_django_json_settings(["JS_ENV_EXTRA_CONFIG"], "cms", settings=settings) - js_env_extra_config = json.dumps(js_env_extra_config_setting or "{}") - node_env = "development" if config_path == 'webpack.dev.config.js' else "production" - run_deprecated_command_wrapper( - old_command="paver webpack", - ignored_old_flags=(set(["watch"]) & set(options)), - new_command=' '.join([ - f"WEBPACK_CONFIG_PATH={config_path}", - f"NODE_ENV={node_env}", - f"STATIC_ROOT_LMS={static_root_lms}", - f"STATIC_ROOT_CMS={static_root_cms}", - f"JS_ENV_EXTRA_CONFIG={js_env_extra_config}", - "npm", - "run", - "webpack", - ]), - ) - - -def get_parsed_option(command_opts, opt_key, default=None): - """ - Extract user command option and parse it. - Arguments: - command_opts: Command line arguments passed via paver command. - opt_key: name of option to get and parse - default: if `command_opt_value` not in `command_opts`, `command_opt_value` will be set to default. - Returns: - list or None - """ - command_opt_value = getattr(command_opts, opt_key, default) - if command_opt_value: - command_opt_value = listfy(command_opt_value) - - return command_opt_value - - -def listfy(data): - """ - Check and convert data to list. - Arguments: - data: data structure to be converted. - """ - - if isinstance(data, str): - data = data.split(',') - elif not isinstance(data, list): - data = [data] - - return data - - -@task -@cmdopts([ - ('background', 'b', 'DEPRECATED. Use shell tools like & to run in background if needed.'), - ('settings=', 's', "DEPRECATED. Django is not longer invoked to compile JS/Sass."), - ('theme-dirs=', '-td', 'The themes dir containing all themes (defaults to None)'), - ('themes=', '-t', 'DEPRECATED. All themes in --theme-dirs are now watched.'), - ('wait=', '-w', 'DEPRECATED. Watchdog\'s default wait time is now used.'), -]) -@timed -def watch_assets(options): - """ - Watch for changes to asset files, and regenerate js/css - - This is a DEPRECATED COMPATIBILITY WRAPPER. Use `npm run watch` instead. - """ - # Don't watch assets when performing a dry run - if tasks.environment.dry_run: - return - - theme_dirs = ':'.join(get_parsed_option(options, 'theme_dirs', [])) - run_deprecated_command_wrapper( - old_command="paver watch_assets", - ignored_old_flags=(set(["debug", "themes", "settings", "background"]) & set(options)), - new_command=shlex.join([ - *( - ["env", f"COMPREHENSIVE_THEME_DIRS={theme_dirs}"] - if theme_dirs else [] - ), - "npm", - "run", - "watch", - ]), - ) - - -@task -@needs( - 'pavelib.prereqs.install_node_prereqs', - 'pavelib.prereqs.install_python_prereqs', -) -@consume_args -@timed -def update_assets(args): - """ - Compile Sass, then collect static assets. - - This is a DEPRECATED COMPATIBILITY WRAPPER around other DEPRECATED COMPATIBILITY WRAPPERS. - The aggregate affect of this command can be achieved with this sequence of commands instead: - - * pip install -r requirements/edx/assets.txt # replaces install_python_prereqs - * npm clean-install # replaces install_node_prereqs - * npm run build # replaces execute_compile_sass and webpack - * ./manage.py lms collectstatic --noinput # replaces collect_assets (for LMS) - * ./manage.py cms collectstatic --noinput # replaces collect_assets (for CMS) - """ - parser = argparse.ArgumentParser(prog='paver update_assets') - parser.add_argument( - 'system', type=str, nargs='*', default=["lms", "studio"], - help="lms or studio", - ) - parser.add_argument( - '--settings', type=str, default=Env.DEVSTACK_SETTINGS, - help="Django settings module", - ) - parser.add_argument( - '--debug', action='store_true', default=False, - help="Enable all debugging", - ) - parser.add_argument( - '--debug-collect', action='store_true', default=False, - help="Disable collect static", - ) - parser.add_argument( - '--skip-collect', dest='collect', action='store_false', default=True, - help="Skip collection of static assets", - ) - parser.add_argument( - '--watch', action='store_true', default=False, - help="Watch files for changes", - ) - parser.add_argument( - '--theme-dirs', dest='theme_dirs', type=str, nargs='+', default=None, - help="base directories where themes are placed", - ) - parser.add_argument( - '--themes', type=str, nargs='+', default=None, - help="list of themes to compile sass for. ignored when --watch is used; all themes are watched.", - ) - parser.add_argument( - '--collect-log', dest="collect_log_dir", default=None, - help="When running collectstatic, direct output to specified log directory", - ) - parser.add_argument( - '--wait', type=float, default=0.0, - help="DEPRECATED. Watchdog's default wait time is now used.", - ) - args = parser.parse_args(args) - - # Build Webpack - call_task('pavelib.assets.webpack', options={'settings': args.settings}) - - # Compile sass for themes and system - execute_compile_sass(args) - - if args.collect: - if args.collect_log_dir: - collect_log_args = {"collect_log_dir": args.collect_log_dir} - elif args.debug or args.debug_collect: - collect_log_args = {"collect_log_dir": None} - else: - collect_log_args = {} - - collect_assets(args.system, args.settings, **collect_log_args) - - if args.watch: - call_task( - 'pavelib.assets.watch_assets', - options={ - 'background': not args.debug, - 'settings': args.settings, - 'theme_dirs': args.theme_dirs, - 'themes': args.themes, - 'wait': [float(args.wait)] - }, - ) diff --git a/pavelib/js_test.py b/pavelib/js_test.py deleted file mode 100644 index fb9c213499..0000000000 --- a/pavelib/js_test.py +++ /dev/null @@ -1,143 +0,0 @@ -""" -Javascript test tasks -""" - - -import os -import re -import sys - -from paver.easy import cmdopts, needs, sh, task - -from pavelib.utils.envs import Env -from pavelib.utils.test.suites import JestSnapshotTestSuite, JsTestSuite -from pavelib.utils.timer import timed - -try: - from pygments.console import colorize -except ImportError: - colorize = lambda color, text: text - -__test__ = False # do not collect - - -@task -@needs( - 'pavelib.prereqs.install_node_prereqs', - 'pavelib.utils.test.utils.clean_reports_dir', -) -@cmdopts([ - ("suite=", "s", "Test suite to run"), - ("mode=", "m", "dev or run"), - ("coverage", "c", "Run test under coverage"), - ("port=", "p", "Port to run test server on (dev mode only)"), - ('skip-clean', 'C', 'skip cleaning repository before running tests'), - ('skip_clean', None, 'deprecated in favor of skip-clean'), -], share_with=["pavelib.utils.tests.utils.clean_reports_dir"]) -@timed -def test_js(options): - """ - Run the JavaScript tests - """ - mode = getattr(options, 'mode', 'run') - port = None - skip_clean = getattr(options, 'skip_clean', False) - - if mode == 'run': - suite = getattr(options, 'suite', 'all') - coverage = getattr(options, 'coverage', False) - elif mode == 'dev': - suite = getattr(options, 'suite', None) - coverage = False - port = getattr(options, 'port', None) - else: - sys.stderr.write("Invalid mode. Please choose 'dev' or 'run'.") - return - - if (suite != 'all') and (suite not in Env.JS_TEST_ID_KEYS): - sys.stderr.write( - "Unknown test suite. Please choose from ({suites})\n".format( - suites=", ".join(Env.JS_TEST_ID_KEYS) - ) - ) - return - - if suite != 'jest-snapshot': - test_suite = JsTestSuite(suite, mode=mode, with_coverage=coverage, port=port, skip_clean=skip_clean) - test_suite.run() - - if (suite == 'jest-snapshot') or (suite == 'all'): # lint-amnesty, pylint: disable=consider-using-in - test_suite = JestSnapshotTestSuite('jest') - test_suite.run() - - -@task -@cmdopts([ - ("suite=", "s", "Test suite to run"), - ("coverage", "c", "Run test under coverage"), -]) -@timed -def test_js_run(options): - """ - Run the JavaScript tests and print results to the console - """ - options.mode = 'run' - test_js(options) - - -@task -@cmdopts([ - ("suite=", "s", "Test suite to run"), - ("port=", "p", "Port to run test server on"), -]) -@timed -def test_js_dev(options): - """ - Run the JavaScript tests in your default browsers - """ - options.mode = 'dev' - test_js(options) - - -@task -@needs('pavelib.prereqs.install_coverage_prereqs') -@cmdopts([ - ("compare-branch=", "b", "Branch to compare against, defaults to origin/master"), -], share_with=['coverage']) -@timed -def diff_coverage(options): - """ - Build the diff coverage reports - """ - compare_branch = options.get('compare_branch', 'origin/master') - - # Find all coverage XML files (both Python and JavaScript) - xml_reports = [] - - for filepath in Env.REPORT_DIR.walk(): - if bool(re.match(r'^coverage.*\.xml$', filepath.basename())): - xml_reports.append(filepath) - - if not xml_reports: - err_msg = colorize( - 'red', - "No coverage info found. Run `paver test` before running " - "`paver coverage`.\n" - ) - sys.stderr.write(err_msg) - else: - xml_report_str = ' '.join(xml_reports) - diff_html_path = os.path.join(Env.REPORT_DIR, 'diff_coverage_combined.html') - - # Generate the diff coverage reports (HTML and console) - # The --diff-range-notation parameter is a workaround for https://github.com/Bachmann1234/diff_cover/issues/153 - sh( - "diff-cover {xml_report_str} --diff-range-notation '..' --compare-branch={compare_branch} " - "--html-report {diff_html_path}".format( - xml_report_str=xml_report_str, - compare_branch=compare_branch, - diff_html_path=diff_html_path, - ) - ) - - print("\n") diff --git a/pavelib/paver_tests/conftest.py b/pavelib/paver_tests/conftest.py deleted file mode 100644 index 214a35e3fe..0000000000 --- a/pavelib/paver_tests/conftest.py +++ /dev/null @@ -1,22 +0,0 @@ -""" -Pytest fixtures for the pavelib unit tests. -""" - - -import os -from shutil import rmtree - -import pytest - -from pavelib.utils.envs import Env - - -@pytest.fixture(autouse=True, scope='session') -def delete_quality_junit_xml(): - """ - Delete the JUnit XML results files for quality check tasks run during the - unit tests. - """ - yield - if os.path.exists(Env.QUALITY_DIR): - rmtree(Env.QUALITY_DIR, ignore_errors=True) diff --git a/pavelib/paver_tests/pylint_test_list.json b/pavelib/paver_tests/pylint_test_list.json deleted file mode 100644 index d0e8b43aa9..0000000000 --- a/pavelib/paver_tests/pylint_test_list.json +++ /dev/null @@ -1,6 +0,0 @@ -[ - "foo/bar.py:192: [C0111(missing-docstring), Bliptv] Missing docstring", - "foo/bar/test.py:74: [C0322(no-space-before-operator)] Operator not preceded by a space", - "ugly/string/test.py:16: [C0103(invalid-name)] Invalid name \"whats up\" for type constant (should match (([A-Z_][A-Z0-9_]*)|(__.*__)|log|urlpatterns)$)", - "multiple/lines/test.py:72: [C0322(no-space-before-operator)] Operator not preceded by a space\nFOO_BAR='pipeline.storage.NonPackagingPipelineStorage'\n ^" -] diff --git a/pavelib/paver_tests/test_assets.py b/pavelib/paver_tests/test_assets.py deleted file mode 100644 index f7100a7f03..0000000000 --- a/pavelib/paver_tests/test_assets.py +++ /dev/null @@ -1,130 +0,0 @@ -"""Unit tests for the Paver asset tasks.""" - -import json -import os -from pathlib import Path -from unittest import TestCase -from unittest.mock import patch - -import ddt -import paver.easy -from paver import tasks - -import pavelib.assets -from pavelib.assets import Env - - -REPO_ROOT = Path(__file__).parent.parent.parent - -LMS_SETTINGS = { - "WEBPACK_CONFIG_PATH": "webpack.fake.config.js", - "STATIC_ROOT": "/fake/lms/staticfiles", - -} -CMS_SETTINGS = { - "WEBPACK_CONFIG_PATH": "webpack.fake.config", - "STATIC_ROOT": "/fake/cms/staticfiles", - "JS_ENV_EXTRA_CONFIG": json.dumps({"key1": [True, False], "key2": {"key2.1": 1369, "key2.2": "1369"}}), -} - - -def _mock_get_django_settings(django_settings, system, settings=None): # pylint: disable=unused-argument - return [(LMS_SETTINGS if system == "lms" else CMS_SETTINGS)[s] for s in django_settings] - - -@ddt.ddt -@patch.object(Env, 'get_django_settings', _mock_get_django_settings) -@patch.object(Env, 'get_django_json_settings', _mock_get_django_settings) -class TestDeprecatedPaverAssets(TestCase): - """ - Simple test to ensure that the soon-to-be-removed Paver commands are correctly translated into the new npm-run - commands. - """ - def setUp(self): - super().setUp() - self.maxDiff = None - os.environ['NO_PREREQ_INSTALL'] = 'true' - tasks.environment = tasks.Environment() - - def tearDown(self): - super().tearDown() - del os.environ['NO_PREREQ_INSTALL'] - - @ddt.data( - dict( - task_name='pavelib.assets.compile_sass', - args=[], - kwargs={}, - expected=["npm run compile-sass --"], - ), - dict( - task_name='pavelib.assets.compile_sass', - args=[], - kwargs={"system": "lms,studio"}, - expected=["npm run compile-sass --"], - ), - dict( - task_name='pavelib.assets.compile_sass', - args=[], - kwargs={"debug": True}, - expected=["npm run compile-sass-dev --"], - ), - dict( - task_name='pavelib.assets.compile_sass', - args=[], - kwargs={"system": "lms"}, - expected=["npm run compile-sass -- --skip-cms"], - ), - dict( - task_name='pavelib.assets.compile_sass', - args=[], - kwargs={"system": "studio"}, - expected=["npm run compile-sass -- --skip-lms"], - ), - dict( - task_name='pavelib.assets.compile_sass', - args=[], - kwargs={"system": "cms", "theme_dirs": f"{REPO_ROOT}/common/test,{REPO_ROOT}/themes"}, - expected=[ - "npm run compile-sass -- --skip-lms " + - f"--theme-dir {REPO_ROOT}/common/test --theme-dir {REPO_ROOT}/themes" - ], - ), - dict( - task_name='pavelib.assets.compile_sass', - args=[], - kwargs={"theme_dirs": f"{REPO_ROOT}/common/test,{REPO_ROOT}/themes", "themes": "red-theme,test-theme"}, - expected=[ - "npm run compile-sass -- " + - f"--theme-dir {REPO_ROOT}/common/test --theme-dir {REPO_ROOT}/themes " + - "--theme red-theme --theme test-theme" - ], - ), - dict( - task_name='pavelib.assets.update_assets', - args=["lms", "studio", "--settings=fake.settings"], - kwargs={}, - expected=[ - ( - "WEBPACK_CONFIG_PATH=webpack.fake.config.js " + - "NODE_ENV=production " + - "STATIC_ROOT_LMS=/fake/lms/staticfiles " + - "STATIC_ROOT_CMS=/fake/cms/staticfiles " + - 'JS_ENV_EXTRA_CONFIG=' + - '"{\\"key1\\": [true, false], \\"key2\\": {\\"key2.1\\": 1369, \\"key2.2\\": \\"1369\\"}}" ' + - "npm run webpack" - ), - "python manage.py lms --settings=fake.settings compile_sass lms ", - "python manage.py cms --settings=fake.settings compile_sass cms ", - ( - "( ./manage.py lms --settings=fake.settings collectstatic --noinput ) && " + - "( ./manage.py cms --settings=fake.settings collectstatic --noinput )" - ), - ], - ), - ) - @ddt.unpack - @patch.object(pavelib.assets, 'sh') - def test_paver_assets_wrapper_invokes_new_commands(self, mock_sh, task_name, args, kwargs, expected): - paver.easy.call_task(task_name, args=args, options=kwargs) - assert [call_args[0] for (call_args, call_kwargs) in mock_sh.call_args_list] == expected diff --git a/pavelib/paver_tests/test_eslint.py b/pavelib/paver_tests/test_eslint.py deleted file mode 100644 index 5802d7d0d2..0000000000 --- a/pavelib/paver_tests/test_eslint.py +++ /dev/null @@ -1,54 +0,0 @@ -""" -Tests for Paver's Stylelint tasks. -""" - - -import unittest -from unittest.mock import patch - -import pytest -from paver.easy import BuildFailure, call_task - -import pavelib.quality - - -class TestPaverESLint(unittest.TestCase): - """ - For testing run_eslint - """ - - def setUp(self): - super().setUp() - - # Mock the paver @needs decorator - self._mock_paver_needs = patch.object(pavelib.quality.run_eslint, 'needs').start() - self._mock_paver_needs.return_value = 0 - - # Mock shell commands - patcher = patch('pavelib.quality.sh') - self._mock_paver_sh = patcher.start() - - # Cleanup mocks - self.addCleanup(patcher.stop) - self.addCleanup(self._mock_paver_needs.stop) - - @patch.object(pavelib.quality, '_write_metric') - @patch.object(pavelib.quality, '_prepare_report_dir') - @patch.object(pavelib.quality, '_get_count_from_last_line') - def test_eslint_violation_number_not_found(self, mock_count, mock_report_dir, mock_write_metric): # pylint: disable=unused-argument - """ - run_eslint encounters an error parsing the eslint output log - """ - mock_count.return_value = None - with pytest.raises(BuildFailure): - call_task('pavelib.quality.run_eslint', args=['']) - - @patch.object(pavelib.quality, '_write_metric') - @patch.object(pavelib.quality, '_prepare_report_dir') - @patch.object(pavelib.quality, '_get_count_from_last_line') - def test_eslint_vanilla(self, mock_count, mock_report_dir, mock_write_metric): # pylint: disable=unused-argument - """ - eslint finds violations, but a limit was not set - """ - mock_count.return_value = 1 - pavelib.quality.run_eslint("") diff --git a/pavelib/paver_tests/test_js_test.py b/pavelib/paver_tests/test_js_test.py deleted file mode 100644 index 4b165a1566..0000000000 --- a/pavelib/paver_tests/test_js_test.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Unit tests for the Paver JavaScript testing tasks.""" - -from unittest.mock import patch - -import ddt -from paver.easy import call_task - -import pavelib.js_test -from pavelib.utils.envs import Env - -from .utils import PaverTestCase - - -@ddt.ddt -class TestPaverJavaScriptTestTasks(PaverTestCase): - """ - Test the Paver JavaScript testing tasks. - """ - - EXPECTED_DELETE_JAVASCRIPT_REPORT_COMMAND = 'find {platform_root}/reports/javascript -type f -delete' - EXPECTED_KARMA_OPTIONS = ( - "{config_file} " - "--single-run={single_run} " - "--capture-timeout=60000 " - "--junitreportpath=" - "{platform_root}/reports/javascript/javascript_xunit-{suite}.xml " - "--browsers={browser}" - ) - EXPECTED_COVERAGE_OPTIONS = ( - ' --coverage --coveragereportpath={platform_root}/reports/javascript/coverage-{suite}.xml' - ) - - EXPECTED_COMMANDS = [ - "make report_dir", - 'git clean -fqdx test_root/logs test_root/data test_root/staticfiles test_root/uploads', - "find . -name '.git' -prune -o -name '*.pyc' -exec rm {} \\;", - 'rm -rf test_root/log/auto_screenshots/*', - "rm -rf /tmp/mako_[cl]ms", - ] - - def setUp(self): - super().setUp() - - # Mock the paver @needs decorator - self._mock_paver_needs = patch.object(pavelib.js_test.test_js, 'needs').start() - self._mock_paver_needs.return_value = 0 - - # Cleanup mocks - self.addCleanup(self._mock_paver_needs.stop) - - @ddt.data( - [""], - ["--coverage"], - ["--suite=lms"], - ["--suite=lms --coverage"], - ) - @ddt.unpack - def test_test_js_run(self, options_string): - """ - Test the "test_js_run" task. - """ - options = self.parse_options_string(options_string) - self.reset_task_messages() - call_task("pavelib.js_test.test_js_run", options=options) - self.verify_messages(options=options, dev_mode=False) - - @ddt.data( - [""], - ["--port=9999"], - ["--suite=lms"], - ["--suite=lms --port=9999"], - ) - @ddt.unpack - def test_test_js_dev(self, options_string): - """ - Test the "test_js_run" task. - """ - options = self.parse_options_string(options_string) - self.reset_task_messages() - call_task("pavelib.js_test.test_js_dev", options=options) - self.verify_messages(options=options, dev_mode=True) - - def parse_options_string(self, options_string): - """ - Parse a string containing the options for a test run - """ - parameters = options_string.split(" ") - suite = "all" - if "--system=lms" in parameters: - suite = "lms" - elif "--system=common" in parameters: - suite = "common" - coverage = "--coverage" in parameters - port = None - if "--port=9999" in parameters: - port = 9999 - return { - "suite": suite, - "coverage": coverage, - "port": port, - } - - def verify_messages(self, options, dev_mode): - """ - Verify that the messages generated when running tests are as expected - for the specified options and dev_mode. - """ - is_coverage = options['coverage'] - port = options['port'] - expected_messages = [] - suites = Env.JS_TEST_ID_KEYS if options['suite'] == 'all' else [options['suite']] - - expected_messages.extend(self.EXPECTED_COMMANDS) - if not dev_mode and not is_coverage: - expected_messages.append(self.EXPECTED_DELETE_JAVASCRIPT_REPORT_COMMAND.format( - platform_root=self.platform_root - )) - - command_template = ( - 'node --max_old_space_size=4096 node_modules/.bin/karma start {options}' - ) - - for suite in suites: - # Karma test command - if suite != 'jest-snapshot': - karma_config_file = Env.KARMA_CONFIG_FILES[Env.JS_TEST_ID_KEYS.index(suite)] - expected_test_tool_command = command_template.format( - options=self.EXPECTED_KARMA_OPTIONS.format( - config_file=karma_config_file, - single_run='false' if dev_mode else 'true', - suite=suite, - platform_root=self.platform_root, - browser=Env.KARMA_BROWSER, - ), - ) - if is_coverage: - expected_test_tool_command += self.EXPECTED_COVERAGE_OPTIONS.format( - platform_root=self.platform_root, - suite=suite - ) - if port: - expected_test_tool_command += f" --port={port}" - else: - expected_test_tool_command = 'jest' - - expected_messages.append(expected_test_tool_command) - - assert self.task_messages == expected_messages diff --git a/pavelib/paver_tests/test_paver_quality.py b/pavelib/paver_tests/test_paver_quality.py deleted file mode 100644 index 36d6dd59e1..0000000000 --- a/pavelib/paver_tests/test_paver_quality.py +++ /dev/null @@ -1,156 +0,0 @@ -""" # lint-amnesty, pylint: disable=django-not-configured -Tests for paver quality tasks -""" - - -import os -import shutil # lint-amnesty, pylint: disable=unused-import -import tempfile -import textwrap -import unittest -from unittest.mock import MagicMock, mock_open, patch # lint-amnesty, pylint: disable=unused-import - -import pytest # lint-amnesty, pylint: disable=unused-import -from ddt import data, ddt, file_data, unpack # lint-amnesty, pylint: disable=unused-import -from path import Path as path -from paver.easy import BuildFailure # lint-amnesty, pylint: disable=unused-import - -import pavelib.quality -from pavelib.paver_tests.utils import PaverTestCase, fail_on_eslint # lint-amnesty, pylint: disable=unused-import - -OPEN_BUILTIN = 'builtins.open' - - -@ddt -class TestPaverQualityViolations(unittest.TestCase): - """ - For testing the paver violations-counting tasks - """ - def setUp(self): - super().setUp() - self.f = tempfile.NamedTemporaryFile(delete=False) # lint-amnesty, pylint: disable=consider-using-with - self.f.close() - self.addCleanup(os.remove, self.f.name) - - def test_pep8_parser(self): - with open(self.f.name, 'w') as f: - f.write("hello\nhithere") - num = len(pavelib.quality._pep8_violations(f.name)) # pylint: disable=protected-access - assert num == 2 - - -class TestPaverReportViolationsCounts(unittest.TestCase): - """ - For testing utility functions for getting counts from reports for - run_eslint and run_xsslint. - """ - - def setUp(self): - super().setUp() - - # Temporary file infrastructure - self.f = tempfile.NamedTemporaryFile(delete=False) # lint-amnesty, pylint: disable=consider-using-with - self.f.close() - - # Cleanup various mocks and tempfiles - self.addCleanup(os.remove, self.f.name) - - def test_get_eslint_violations_count(self): - with open(self.f.name, 'w') as f: - f.write("3000 violations found") - actual_count = pavelib.quality._get_count_from_last_line(self.f.name, "eslint") # pylint: disable=protected-access - assert actual_count == 3000 - - def test_get_eslint_violations_no_number_found(self): - with open(self.f.name, 'w') as f: - f.write("Not expected string regex") - actual_count = pavelib.quality._get_count_from_last_line(self.f.name, "eslint") # pylint: disable=protected-access - assert actual_count is None - - def test_get_eslint_violations_count_truncated_report(self): - """ - A truncated report (i.e. last line is just a violation) - """ - with open(self.f.name, 'w') as f: - f.write("foo/bar/js/fizzbuzz.js: line 45, col 59, Missing semicolon.") - actual_count = pavelib.quality._get_count_from_last_line(self.f.name, "eslint") # pylint: disable=protected-access - assert actual_count is None - - def test_generic_value(self): - """ - Default behavior is to look for an integer appearing at head of line - """ - with open(self.f.name, 'w') as f: - f.write("5.777 good to see you") - actual_count = pavelib.quality._get_count_from_last_line(self.f.name, "foo") # pylint: disable=protected-access - assert actual_count == 5 - - def test_generic_value_none_found(self): - """ - Default behavior is to look for an integer appearing at head of line - """ - with open(self.f.name, 'w') as f: - f.write("hello 5.777 good to see you") - actual_count = pavelib.quality._get_count_from_last_line(self.f.name, "foo") # pylint: disable=protected-access - assert actual_count is None - - def test_get_xsslint_counts_happy(self): - """ - Test happy path getting violation counts from xsslint report. - """ - report = textwrap.dedent(""" - test.html: 30:53: javascript-jquery-append: $('#test').append(print_tos); - - javascript-concat-html: 310 violations - javascript-escape: 7 violations - - 2608 violations total - """) - with open(self.f.name, 'w') as f: - f.write(report) - counts = pavelib.quality._get_xsslint_counts(self.f.name) # pylint: disable=protected-access - self.assertDictEqual(counts, { - 'rules': { - 'javascript-concat-html': 310, - 'javascript-escape': 7, - }, - 'total': 2608, - }) - - def test_get_xsslint_counts_bad_counts(self): - """ - Test getting violation counts from truncated and malformed xsslint - report. - """ - report = textwrap.dedent(""" - javascript-concat-html: violations - """) - with open(self.f.name, 'w') as f: - f.write(report) - counts = pavelib.quality._get_xsslint_counts(self.f.name) # pylint: disable=protected-access - self.assertDictEqual(counts, { - 'rules': {}, - 'total': None, - }) - - -class TestPrepareReportDir(unittest.TestCase): - """ - Tests the report directory preparation - """ - - def setUp(self): - super().setUp() - self.test_dir = tempfile.mkdtemp() - self.test_file = tempfile.NamedTemporaryFile(delete=False, dir=self.test_dir) # lint-amnesty, pylint: disable=consider-using-with - self.addCleanup(os.removedirs, self.test_dir) - - def test_report_dir_with_files(self): - assert os.path.exists(self.test_file.name) - pavelib.quality._prepare_report_dir(path(self.test_dir)) # pylint: disable=protected-access - assert not os.path.exists(self.test_file.name) - - def test_report_dir_without_files(self): - os.remove(self.test_file.name) - pavelib.quality._prepare_report_dir(path(self.test_dir)) # pylint: disable=protected-access - assert os.listdir(path(self.test_dir)) == [] diff --git a/pavelib/paver_tests/test_pii_check.py b/pavelib/paver_tests/test_pii_check.py deleted file mode 100644 index d034360acd..0000000000 --- a/pavelib/paver_tests/test_pii_check.py +++ /dev/null @@ -1,79 +0,0 @@ -""" -Tests for Paver's PII checker task. -""" - -import shutil -import tempfile -import unittest -from unittest.mock import patch - -from path import Path as path -from paver.easy import call_task, BuildFailure - -import pavelib.quality -from pavelib.utils.envs import Env - - -class TestPaverPIICheck(unittest.TestCase): - """ - For testing the paver run_pii_check task - """ - def setUp(self): - super().setUp() - self.report_dir = path(tempfile.mkdtemp()) - self.addCleanup(shutil.rmtree, self.report_dir) - - @patch.object(pavelib.quality.run_pii_check, 'needs') - @patch('pavelib.quality.sh') - def test_pii_check_report_dir_override(self, mock_paver_sh, mock_needs): - """ - run_pii_check succeeds with proper report dir - """ - # Make the expected stdout files. - cms_stdout_report = self.report_dir / 'pii_check_cms.report' - cms_stdout_report.write_lines(['Coverage found 33 uncovered models:\n']) - lms_stdout_report = self.report_dir / 'pii_check_lms.report' - lms_stdout_report.write_lines(['Coverage found 66 uncovered models:\n']) - - mock_needs.return_value = 0 - call_task('pavelib.quality.run_pii_check', options={"report_dir": str(self.report_dir)}) - mock_calls = [str(call) for call in mock_paver_sh.mock_calls] - assert len(mock_calls) == 2 - assert any('lms.envs.test' in call for call in mock_calls) - assert any('cms.envs.test' in call for call in mock_calls) - assert all(str(self.report_dir) in call for call in mock_calls) - metrics_file = Env.METRICS_DIR / 'pii' - assert open(metrics_file).read() == 'Number of PII Annotation violations: 66\n' - - @patch.object(pavelib.quality.run_pii_check, 'needs') - @patch('pavelib.quality.sh') - def test_pii_check_failed(self, mock_paver_sh, mock_needs): - """ - run_pii_check fails due to crossing the threshold. - """ - # Make the expected stdout files. - cms_stdout_report = self.report_dir / 'pii_check_cms.report' - cms_stdout_report.write_lines(['Coverage found 33 uncovered models:\n']) - lms_stdout_report = self.report_dir / 'pii_check_lms.report' - lms_stdout_report.write_lines([ - 'Coverage found 66 uncovered models:', - 'Coverage threshold not met! Needed 100.0, actually 95.0!', - ]) - - mock_needs.return_value = 0 - try: - with self.assertRaises(BuildFailure): - call_task('pavelib.quality.run_pii_check', options={"report_dir": str(self.report_dir)}) - except SystemExit: - # Sometimes the BuildFailure raises a SystemExit, sometimes it doesn't, not sure why. - # As a hack, we just wrap it in try-except. - # This is not good, but these tests weren't even running for years, and we're removing this whole test - # suite soon anyway. - pass - mock_calls = [str(call) for call in mock_paver_sh.mock_calls] - assert len(mock_calls) == 2 - assert any('lms.envs.test' in call for call in mock_calls) - assert any('cms.envs.test' in call for call in mock_calls) - assert all(str(self.report_dir) in call for call in mock_calls) - metrics_file = Env.METRICS_DIR / 'pii' - assert open(metrics_file).read() == 'Number of PII Annotation violations: 66\n' diff --git a/pavelib/paver_tests/test_stylelint.py b/pavelib/paver_tests/test_stylelint.py deleted file mode 100644 index 3e1c79c93f..0000000000 --- a/pavelib/paver_tests/test_stylelint.py +++ /dev/null @@ -1,36 +0,0 @@ -""" -Tests for Paver's Stylelint tasks. -""" - -from unittest.mock import MagicMock, patch - -import pytest -import ddt -from paver.easy import call_task - -from .utils import PaverTestCase - - -@ddt.ddt -class TestPaverStylelint(PaverTestCase): - """ - Tests for Paver's Stylelint tasks. - """ - @ddt.data( - [False], - [True], - ) - @ddt.unpack - def test_run_stylelint(self, should_pass): - """ - Verify that the quality task fails with Stylelint violations. - """ - if should_pass: - _mock_stylelint_violations = MagicMock(return_value=0) - with patch('pavelib.quality._get_stylelint_violations', _mock_stylelint_violations): - call_task('pavelib.quality.run_stylelint') - else: - _mock_stylelint_violations = MagicMock(return_value=100) - with patch('pavelib.quality._get_stylelint_violations', _mock_stylelint_violations): - with pytest.raises(SystemExit): - call_task('pavelib.quality.run_stylelint') diff --git a/pavelib/paver_tests/test_timer.py b/pavelib/paver_tests/test_timer.py deleted file mode 100644 index bc98176683..0000000000 --- a/pavelib/paver_tests/test_timer.py +++ /dev/null @@ -1,168 +0,0 @@ -""" -Tests of the pavelib.utils.timer module. -""" - - -from datetime import datetime, timedelta -from unittest import TestCase - -from unittest.mock import MagicMock, patch - -from pavelib.utils import timer - - -@timer.timed -def identity(*args, **kwargs): - """ - An identity function used as a default task to test the timing of. - """ - return args, kwargs - - -MOCK_OPEN = MagicMock(spec=open) - - -@patch.dict('pavelib.utils.timer.__builtins__', open=MOCK_OPEN) -class TimedDecoratorTests(TestCase): - """ - Tests of the pavelib.utils.timer:timed decorator. - """ - def setUp(self): - super().setUp() - - patch_dumps = patch.object(timer.json, 'dump', autospec=True) - self.mock_dump = patch_dumps.start() - self.addCleanup(patch_dumps.stop) - - patch_makedirs = patch.object(timer.os, 'makedirs', autospec=True) - self.mock_makedirs = patch_makedirs.start() - self.addCleanup(patch_makedirs.stop) - - patch_datetime = patch.object(timer, 'datetime', autospec=True) - self.mock_datetime = patch_datetime.start() - self.addCleanup(patch_datetime.stop) - - patch_exists = patch.object(timer, 'exists', autospec=True) - self.mock_exists = patch_exists.start() - self.addCleanup(patch_exists.stop) - - MOCK_OPEN.reset_mock() - - def get_log_messages(self, task=identity, args=None, kwargs=None, raises=None): - """ - Return all timing messages recorded during the execution of ``task``. - """ - if args is None: - args = [] - if kwargs is None: - kwargs = {} - - if raises is None: - task(*args, **kwargs) - else: - self.assertRaises(raises, task, *args, **kwargs) - - return [ - call[0][0] # log_message - for call in self.mock_dump.call_args_list - ] - - @patch.object(timer, 'PAVER_TIMER_LOG', '/tmp/some-log') - def test_times(self): - start = datetime(2016, 7, 20, 10, 56, 19) - end = start + timedelta(seconds=35.6) - - self.mock_datetime.utcnow.side_effect = [start, end] - - messages = self.get_log_messages() - assert len(messages) == 1 - - assert 'duration' in messages[0] and messages[0]['duration'] == 35.6 - assert 'started_at' in messages[0] and messages[0]['started_at'] == start.isoformat(' ') - assert 'ended_at' in messages[0] and messages[0]['ended_at'] == end.isoformat(' ') - - @patch.object(timer, 'PAVER_TIMER_LOG', None) - def test_no_logs(self): - messages = self.get_log_messages() - assert len(messages) == 0 - - @patch.object(timer, 'PAVER_TIMER_LOG', '/tmp/some-log') - def test_arguments(self): - messages = self.get_log_messages(args=(1, 'foo'), kwargs=dict(bar='baz')) - assert len(messages) == 1 - - assert 'args' in messages[0] and messages[0]['args'] == [repr(1), repr('foo')] - assert 'kwargs' in messages[0] and messages[0]['kwargs'] == {'bar': repr('baz')} - - @patch.object(timer, 'PAVER_TIMER_LOG', '/tmp/some-log') - def test_task_name(self): - messages = self.get_log_messages() - assert len(messages) == 1 - - assert 'task' in messages[0] and messages[0]['task'] == 'pavelib.paver_tests.test_timer.identity' - - @patch.object(timer, 'PAVER_TIMER_LOG', '/tmp/some-log') - def test_exceptions(self): - @timer.timed - def raises(): - """ - A task used for testing exception handling of the timed decorator. - """ - raise Exception('The Message!') - - messages = self.get_log_messages(task=raises, raises=Exception) - assert len(messages) == 1 - - assert 'exception' in messages[0] and messages[0]['exception'] == 'Exception: The Message!' - - @patch.object(timer, 'PAVER_TIMER_LOG', '/tmp/some-log-%Y-%m-%d-%H-%M-%S.log') - def test_date_formatting(self): - start = datetime(2016, 7, 20, 10, 56, 19) - end = start + timedelta(seconds=35.6) - - self.mock_datetime.utcnow.side_effect = [start, end] - - messages = self.get_log_messages() - assert len(messages) == 1 - - MOCK_OPEN.assert_called_once_with('/tmp/some-log-2016-07-20-10-56-19.log', 'a') - - @patch.object(timer, 'PAVER_TIMER_LOG', '/tmp/some-log') - def test_nested_tasks(self): - - @timer.timed - def parent(): - """ - A timed task that calls another task - """ - identity() - - parent_start = datetime(2016, 7, 20, 10, 56, 19) - parent_end = parent_start + timedelta(seconds=60) - child_start = parent_start + timedelta(seconds=10) - child_end = parent_end - timedelta(seconds=10) - - self.mock_datetime.utcnow.side_effect = [parent_start, child_start, child_end, parent_end] - - messages = self.get_log_messages(task=parent) - assert len(messages) == 2 - - # Child messages first - assert 'duration' in messages[0] - assert 40 == messages[0]['duration'] - - assert 'started_at' in messages[0] - assert child_start.isoformat(' ') == messages[0]['started_at'] - - assert 'ended_at' in messages[0] - assert child_end.isoformat(' ') == messages[0]['ended_at'] - - # Parent messages after - assert 'duration' in messages[1] - assert 60 == messages[1]['duration'] - - assert 'started_at' in messages[1] - assert parent_start.isoformat(' ') == messages[1]['started_at'] - - assert 'ended_at' in messages[1] - assert parent_end.isoformat(' ') == messages[1]['ended_at'] diff --git a/pavelib/paver_tests/test_xsslint.py b/pavelib/paver_tests/test_xsslint.py deleted file mode 100644 index a9b4a41e16..0000000000 --- a/pavelib/paver_tests/test_xsslint.py +++ /dev/null @@ -1,120 +0,0 @@ -""" -Tests for paver xsslint quality tasks -""" -from unittest.mock import patch - -import pytest -from paver.easy import call_task - -import pavelib.quality - -from .utils import PaverTestCase - - -class PaverXSSLintTest(PaverTestCase): - """ - Test run_xsslint with a mocked environment in order to pass in opts - """ - - def setUp(self): - super().setUp() - self.reset_task_messages() - - @patch.object(pavelib.quality, '_write_metric') - @patch.object(pavelib.quality, '_prepare_report_dir') - @patch.object(pavelib.quality, '_get_xsslint_counts') - def test_xsslint_violation_number_not_found(self, _mock_counts, _mock_report_dir, _mock_write_metric): - """ - run_xsslint encounters an error parsing the xsslint output log - """ - _mock_counts.return_value = {} - with pytest.raises(SystemExit): - call_task('pavelib.quality.run_xsslint') - - @patch.object(pavelib.quality, '_write_metric') - @patch.object(pavelib.quality, '_prepare_report_dir') - @patch.object(pavelib.quality, '_get_xsslint_counts') - def test_xsslint_vanilla(self, _mock_counts, _mock_report_dir, _mock_write_metric): - """ - run_xsslint finds violations, but a limit was not set - """ - _mock_counts.return_value = {'total': 0} - call_task('pavelib.quality.run_xsslint') - - @patch.object(pavelib.quality, '_write_metric') - @patch.object(pavelib.quality, '_prepare_report_dir') - @patch.object(pavelib.quality, '_get_xsslint_counts') - def test_xsslint_invalid_thresholds_option(self, _mock_counts, _mock_report_dir, _mock_write_metric): - """ - run_xsslint fails when thresholds option is poorly formatted - """ - _mock_counts.return_value = {'total': 0} - with pytest.raises(SystemExit): - call_task('pavelib.quality.run_xsslint', options={"thresholds": "invalid"}) - - @patch.object(pavelib.quality, '_write_metric') - @patch.object(pavelib.quality, '_prepare_report_dir') - @patch.object(pavelib.quality, '_get_xsslint_counts') - def test_xsslint_invalid_thresholds_option_key(self, _mock_counts, _mock_report_dir, _mock_write_metric): - """ - run_xsslint fails when thresholds option is poorly formatted - """ - _mock_counts.return_value = {'total': 0} - with pytest.raises(SystemExit): - call_task('pavelib.quality.run_xsslint', options={"thresholds": '{"invalid": 3}'}) - - @patch.object(pavelib.quality, '_write_metric') - @patch.object(pavelib.quality, '_prepare_report_dir') - @patch.object(pavelib.quality, '_get_xsslint_counts') - def test_xsslint_too_many_violations(self, _mock_counts, _mock_report_dir, _mock_write_metric): - """ - run_xsslint finds more violations than are allowed - """ - _mock_counts.return_value = {'total': 4} - with pytest.raises(SystemExit): - call_task('pavelib.quality.run_xsslint', options={"thresholds": '{"total": 3}'}) - - @patch.object(pavelib.quality, '_write_metric') - @patch.object(pavelib.quality, '_prepare_report_dir') - @patch.object(pavelib.quality, '_get_xsslint_counts') - def test_xsslint_under_limit(self, _mock_counts, _mock_report_dir, _mock_write_metric): - """ - run_xsslint finds fewer violations than are allowed - """ - _mock_counts.return_value = {'total': 4} - # No System Exit is expected - call_task('pavelib.quality.run_xsslint', options={"thresholds": '{"total": 5}'}) - - @patch.object(pavelib.quality, '_write_metric') - @patch.object(pavelib.quality, '_prepare_report_dir') - @patch.object(pavelib.quality, '_get_xsslint_counts') - def test_xsslint_rule_violation_number_not_found(self, _mock_counts, _mock_report_dir, _mock_write_metric): - """ - run_xsslint encounters an error parsing the xsslint output log for a - given rule threshold that was set. - """ - _mock_counts.return_value = {'total': 4} - with pytest.raises(SystemExit): - call_task('pavelib.quality.run_xsslint', options={"thresholds": '{"rules": {"javascript-escape": 3}}'}) - - @patch.object(pavelib.quality, '_write_metric') - @patch.object(pavelib.quality, '_prepare_report_dir') - @patch.object(pavelib.quality, '_get_xsslint_counts') - def test_xsslint_too_many_rule_violations(self, _mock_counts, _mock_report_dir, _mock_write_metric): - """ - run_xsslint finds more rule violations than are allowed - """ - _mock_counts.return_value = {'total': 4, 'rules': {'javascript-escape': 4}} - with pytest.raises(SystemExit): - call_task('pavelib.quality.run_xsslint', options={"thresholds": '{"rules": {"javascript-escape": 3}}'}) - - @patch.object(pavelib.quality, '_write_metric') - @patch.object(pavelib.quality, '_prepare_report_dir') - @patch.object(pavelib.quality, '_get_xsslint_counts') - def test_xsslint_under_rule_limit(self, _mock_counts, _mock_report_dir, _mock_write_metric): - """ - run_xsslint finds fewer rule violations than are allowed - """ - _mock_counts.return_value = {'total': 4, 'rules': {'javascript-escape': 4}} - # No System Exit is expected - call_task('pavelib.quality.run_xsslint', options={"thresholds": '{"rules": {"javascript-escape": 5}}'}) diff --git a/pavelib/paver_tests/utils.py b/pavelib/paver_tests/utils.py deleted file mode 100644 index 1db26cf76a..0000000000 --- a/pavelib/paver_tests/utils.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Unit tests for the Paver server tasks.""" - - -import os -from unittest import TestCase -from uuid import uuid4 - -from paver import tasks -from paver.easy import BuildFailure - - -class PaverTestCase(TestCase): - """ - Base class for Paver test cases. - """ - def setUp(self): - super().setUp() - - # Show full length diffs upon test failure - self.maxDiff = None # pylint: disable=invalid-name - - # Create a mock Paver environment - tasks.environment = MockEnvironment() - - # Don't run pre-reqs - os.environ['NO_PREREQ_INSTALL'] = 'true' - - def tearDown(self): - super().tearDown() - tasks.environment = tasks.Environment() - del os.environ['NO_PREREQ_INSTALL'] - - @property - def task_messages(self): - """Returns the messages output by the Paver task.""" - return tasks.environment.messages - - @property - def platform_root(self): - """Returns the current platform's root directory.""" - return os.getcwd() - - def reset_task_messages(self): - """Clear the recorded message""" - tasks.environment.messages = [] - - -class MockEnvironment(tasks.Environment): - """ - Mock environment that collects information about Paver commands. - """ - def __init__(self): - super().__init__() - self.dry_run = True - self.messages = [] - - def info(self, message, *args): - """Capture any messages that have been recorded""" - if args: - output = message % args - else: - output = message - if not output.startswith("--->"): - self.messages.append(str(output)) - - -def fail_on_eslint(*args, **kwargs): - """ - For our tests, we need the call for diff-quality running eslint reports - to fail, since that is what is going to fail when we pass in a - percentage ("p") requirement. - """ - if "eslint" in args[0]: # lint-amnesty, pylint: disable=no-else-raise - raise BuildFailure('Subprocess return code: 1') - else: - if kwargs.get('capture', False): - return uuid4().hex - else: - return - - -def fail_on_npm_install(): - """ - Used to simulate an error when executing "npm install" - """ - return 1 - - -def unexpected_fail_on_npm_install(*args, **kwargs): # pylint: disable=unused-argument - """ - For our tests, we need the call for diff-quality running pycodestyle reports to fail, since that is what - is going to fail when we pass in a percentage ("p") requirement. - """ - if ["npm", "install", "--verbose"] == args[0]: # lint-amnesty, pylint: disable=no-else-raise - raise BuildFailure('Subprocess return code: 50') - else: - return diff --git a/pavelib/prereqs.py b/pavelib/prereqs.py deleted file mode 100644 index 4453176c94..0000000000 --- a/pavelib/prereqs.py +++ /dev/null @@ -1,351 +0,0 @@ -""" -Install Python and Node prerequisites. -""" - - -import hashlib -import os -import re -import subprocess -import sys -from distutils import sysconfig # pylint: disable=deprecated-module - -from paver.easy import sh, task # lint-amnesty, pylint: disable=unused-import - -from .utils.envs import Env -from .utils.timer import timed - -PREREQS_STATE_DIR = os.getenv('PREREQ_CACHE_DIR', Env.REPO_ROOT / '.prereqs_cache') -NO_PREREQ_MESSAGE = "NO_PREREQ_INSTALL is set, not installing prereqs" -NO_PYTHON_UNINSTALL_MESSAGE = 'NO_PYTHON_UNINSTALL is set. No attempts will be made to uninstall old Python libs.' -COVERAGE_REQ_FILE = 'requirements/edx/coverage.txt' - -# If you make any changes to this list you also need to make -# a corresponding change to circle.yml, which is how the python -# prerequisites are installed for builds on circleci.com -toxenv = os.environ.get('TOXENV') -if toxenv and toxenv != 'quality': - PYTHON_REQ_FILES = ['requirements/edx/testing.txt'] -else: - PYTHON_REQ_FILES = ['requirements/edx/development.txt'] - -# Developers can have private requirements, for local copies of github repos, -# or favorite debugging tools, etc. -PRIVATE_REQS = 'requirements/edx/private.txt' -if os.path.exists(PRIVATE_REQS): - PYTHON_REQ_FILES.append(PRIVATE_REQS) - - -def str2bool(s): - s = str(s) - return s.lower() in ('yes', 'true', 't', '1') - - -def no_prereq_install(): - """ - Determine if NO_PREREQ_INSTALL should be truthy or falsy. - """ - return str2bool(os.environ.get('NO_PREREQ_INSTALL', 'False')) - - -def no_python_uninstall(): - """ Determine if we should run the uninstall_python_packages task. """ - return str2bool(os.environ.get('NO_PYTHON_UNINSTALL', 'False')) - - -def create_prereqs_cache_dir(): - """Create the directory for storing the hashes, if it doesn't exist already.""" - try: - os.makedirs(PREREQS_STATE_DIR) - except OSError: - if not os.path.isdir(PREREQS_STATE_DIR): - raise - - -def compute_fingerprint(path_list): - """ - Hash the contents of all the files and directories in `path_list`. - Returns the hex digest. - """ - - hasher = hashlib.sha1() - - for path_item in path_list: - - # For directories, create a hash based on the modification times - # of first-level subdirectories - if os.path.isdir(path_item): - for dirname in sorted(os.listdir(path_item)): - path_name = os.path.join(path_item, dirname) - if os.path.isdir(path_name): - hasher.update(str(os.stat(path_name).st_mtime).encode('utf-8')) - - # For files, hash the contents of the file - if os.path.isfile(path_item): - with open(path_item, "rb") as file_handle: - hasher.update(file_handle.read()) - - return hasher.hexdigest() - - -def prereq_cache(cache_name, paths, install_func): - """ - Conditionally execute `install_func()` only if the files/directories - specified by `paths` have changed. - - If the code executes successfully (no exceptions are thrown), the cache - is updated with the new hash. - """ - # Retrieve the old hash - cache_filename = cache_name.replace(" ", "_") - cache_file_path = os.path.join(PREREQS_STATE_DIR, f"{cache_filename}.sha1") - old_hash = None - if os.path.isfile(cache_file_path): - with open(cache_file_path) as cache_file: - old_hash = cache_file.read() - - # Compare the old hash to the new hash - # If they do not match (either the cache hasn't been created, or the files have changed), - # then execute the code within the block. - new_hash = compute_fingerprint(paths) - if new_hash != old_hash: - install_func() - - # Update the cache with the new hash - # If the code executed within the context fails (throws an exception), - # then this step won't get executed. - create_prereqs_cache_dir() - with open(cache_file_path, "wb") as cache_file: - # Since the pip requirement files are modified during the install - # process, we need to store the hash generated AFTER the installation - post_install_hash = compute_fingerprint(paths) - cache_file.write(post_install_hash.encode('utf-8')) - else: - print(f'{cache_name} unchanged, skipping...') - - -def node_prereqs_installation(): - """ - Configures npm and installs Node prerequisites - """ - # Before July 2023, these directories were created and written to - # as root. Afterwards, they are created as being owned by the - # `app` user -- but also need to be deleted by that user (due to - # how npm runs post-install scripts.) Developers with an older - # devstack installation who are reprovisioning will see errors - # here if the files are still owned by root. Deleting the files in - # advance prevents this error. - # - # This hack should probably be left in place for at least a year. - # See ADR 17 for more background on the transition. - sh("rm -rf common/static/common/js/vendor/ common/static/common/css/vendor/") - # At the time of this writing, the js dir has git-versioned files - # but the css dir does not, so the latter would have been created - # as root-owned (in the process of creating the vendor - # subdirectory). Delete it only if empty, just in case - # git-versioned files are added later. - sh("rmdir common/static/common/css || true") - - # NPM installs hang sporadically. Log the installation process so that we - # determine if any packages are chronic offenders. - npm_log_file_path = f'{Env.GEN_LOG_DIR}/npm-install.log' - npm_log_file = open(npm_log_file_path, 'wb') # lint-amnesty, pylint: disable=consider-using-with - npm_command = 'npm ci --verbose'.split() - - # The implementation of Paver's `sh` function returns before the forked - # actually returns. Using a Popen object so that we can ensure that - # the forked process has returned - proc = subprocess.Popen(npm_command, stderr=npm_log_file) # lint-amnesty, pylint: disable=consider-using-with - retcode = proc.wait() - if retcode == 1: - raise Exception(f"npm install failed: See {npm_log_file_path}") - print("Successfully clean-installed NPM packages. Log found at {}".format( - npm_log_file_path - )) - - -def python_prereqs_installation(): - """ - Installs Python prerequisites - """ - # edx-platform installs some Python projects from within the edx-platform repo itself. - sh("pip install -e .") - for req_file in PYTHON_REQ_FILES: - pip_install_req_file(req_file) - - -def pip_install_req_file(req_file): - """Pip install the requirements file.""" - pip_cmd = 'pip install -q --disable-pip-version-check --exists-action w' - sh(f"{pip_cmd} -r {req_file}") - - -@task -@timed -def install_node_prereqs(): - """ - Installs Node prerequisites - """ - if no_prereq_install(): - print(NO_PREREQ_MESSAGE) - return - - prereq_cache("Node prereqs", ["package.json", "package-lock.json"], node_prereqs_installation) - - -# To add a package to the uninstall list, just add it to this list! No need -# to touch any other part of this file. -PACKAGES_TO_UNINSTALL = [ - "MySQL-python", # Because mysqlclient shares the same directory name - "South", # Because it interferes with Django 1.8 migrations. - "edxval", # Because it was bork-installed somehow. - "django-storages", - "django-oauth2-provider", # Because now it's called edx-django-oauth2-provider. - "edx-oauth2-provider", # Because it moved from github to pypi - "enum34", # Because enum34 is not needed in python>3.4 - "i18n-tools", # Because now it's called edx-i18n-tools - "moto", # Because we no longer use it and it conflicts with recent jsondiff versions - "python-saml", # Because python3-saml shares the same directory name - "pytest-faulthandler", # Because it was bundled into pytest - "djangorestframework-jwt", # Because now its called drf-jwt. -] - - -@task -@timed -def uninstall_python_packages(): - """ - Uninstall Python packages that need explicit uninstallation. - - Some Python packages that we no longer want need to be explicitly - uninstalled, notably, South. Some other packages were once installed in - ways that were resistant to being upgraded, like edxval. Also uninstall - them. - """ - - if no_python_uninstall(): - print(NO_PYTHON_UNINSTALL_MESSAGE) - return - - # So that we don't constantly uninstall things, use a hash of the packages - # to be uninstalled. Check it, and skip this if we're up to date. - hasher = hashlib.sha1() - hasher.update(repr(PACKAGES_TO_UNINSTALL).encode('utf-8')) - expected_version = hasher.hexdigest() - state_file_path = os.path.join(PREREQS_STATE_DIR, "Python_uninstall.sha1") - create_prereqs_cache_dir() - - if os.path.isfile(state_file_path): - with open(state_file_path) as state_file: - version = state_file.read() - if version == expected_version: - print('Python uninstalls unchanged, skipping...') - return - - # Run pip to find the packages we need to get rid of. Believe it or not, - # edx-val is installed in a way that it is present twice, so we have a loop - # to really really get rid of it. - for _ in range(3): - uninstalled = False - frozen = sh("pip freeze", capture=True) - - for package_name in PACKAGES_TO_UNINSTALL: - if package_in_frozen(package_name, frozen): - # Uninstall the pacakge - sh(f"pip uninstall --disable-pip-version-check -y {package_name}") - uninstalled = True - if not uninstalled: - break - else: - # We tried three times and didn't manage to get rid of the pests. - print("Couldn't uninstall unwanted Python packages!") - return - - # Write our version. - with open(state_file_path, "wb") as state_file: - state_file.write(expected_version.encode('utf-8')) - - -def package_in_frozen(package_name, frozen_output): - """Is this package in the output of 'pip freeze'?""" - # Look for either: - # - # PACKAGE-NAME== - # - # or: - # - # blah_blah#egg=package_name-version - # - pattern = r"(?mi)^{pkg}==|#egg={pkg_under}-".format( - pkg=re.escape(package_name), - pkg_under=re.escape(package_name.replace("-", "_")), - ) - return bool(re.search(pattern, frozen_output)) - - -@task -@timed -def install_coverage_prereqs(): - """ Install python prereqs for measuring coverage. """ - if no_prereq_install(): - print(NO_PREREQ_MESSAGE) - return - pip_install_req_file(COVERAGE_REQ_FILE) - - -@task -@timed -def install_python_prereqs(): - """ - Installs Python prerequisites. - """ - if no_prereq_install(): - print(NO_PREREQ_MESSAGE) - return - - uninstall_python_packages() - - # Include all of the requirements files in the fingerprint. - files_to_fingerprint = list(PYTHON_REQ_FILES) - - # Also fingerprint the directories where packages get installed: - # ("/edx/app/edxapp/venvs/edxapp/lib/python2.7/site-packages") - files_to_fingerprint.append(sysconfig.get_python_lib()) - - # In a virtualenv, "-e installs" get put in a src directory. - if Env.PIP_SRC: - src_dir = Env.PIP_SRC - else: - src_dir = os.path.join(sys.prefix, "src") - if os.path.isdir(src_dir): - files_to_fingerprint.append(src_dir) - - # Also fingerprint this source file, so that if the logic for installations - # changes, we will redo the installation. - this_file = __file__ - if this_file.endswith(".pyc"): - this_file = this_file[:-1] # use the .py file instead of the .pyc - files_to_fingerprint.append(this_file) - - prereq_cache("Python prereqs", files_to_fingerprint, python_prereqs_installation) - - -@task -@timed -def install_prereqs(): - """ - Installs Node and Python prerequisites - """ - if no_prereq_install(): - print(NO_PREREQ_MESSAGE) - return - - if not str2bool(os.environ.get('SKIP_NPM_INSTALL', 'False')): - install_node_prereqs() - install_python_prereqs() - log_installed_python_prereqs() - - -def log_installed_python_prereqs(): - """ Logs output of pip freeze for debugging. """ - sh("pip freeze > {}".format(Env.GEN_LOG_DIR + "/pip_freeze.log")) diff --git a/pavelib/quality.py b/pavelib/quality.py deleted file mode 100644 index 774179f450..0000000000 --- a/pavelib/quality.py +++ /dev/null @@ -1,602 +0,0 @@ -""" # lint-amnesty, pylint: disable=django-not-configured -Check code quality using pycodestyle, pylint, and diff_quality. -""" - -import json -import os -import re -from datetime import datetime -from xml.sax.saxutils import quoteattr - -from paver.easy import BuildFailure, cmdopts, needs, sh, task - -from .utils.envs import Env -from .utils.timer import timed - -ALL_SYSTEMS = 'lms,cms,common,openedx,pavelib,scripts' -JUNIT_XML_TEMPLATE = """ - -{failure_element} - -""" -JUNIT_XML_FAILURE_TEMPLATE = '' -START_TIME = datetime.utcnow() - - -def write_junit_xml(name, message=None): - """ - Write a JUnit results XML file describing the outcome of a quality check. - """ - if message: - failure_element = JUNIT_XML_FAILURE_TEMPLATE.format(message=quoteattr(message)) - else: - failure_element = '' - data = { - 'failure_count': 1 if message else 0, - 'failure_element': failure_element, - 'name': name, - 'seconds': (datetime.utcnow() - START_TIME).total_seconds(), - } - Env.QUALITY_DIR.makedirs_p() - filename = Env.QUALITY_DIR / f'{name}.xml' - with open(filename, 'w') as f: - f.write(JUNIT_XML_TEMPLATE.format(**data)) - - -def fail_quality(name, message): - """ - Fail the specified quality check by generating the JUnit XML results file - and raising a ``BuildFailure``. - """ - write_junit_xml(name, message) - raise BuildFailure(message) - - -def top_python_dirs(dirname): - """ - Find the directories to start from in order to find all the Python files in `dirname`. - """ - top_dirs = [] - - dir_init = os.path.join(dirname, "__init__.py") - if os.path.exists(dir_init): - top_dirs.append(dirname) - - for directory in ['djangoapps', 'lib']: - subdir = os.path.join(dirname, directory) - subdir_init = os.path.join(subdir, "__init__.py") - if os.path.exists(subdir) and not os.path.exists(subdir_init): - dirs = os.listdir(subdir) - top_dirs.extend(d for d in dirs if os.path.isdir(os.path.join(subdir, d))) - - modules_to_remove = ['__pycache__'] - for module in modules_to_remove: - if module in top_dirs: - top_dirs.remove(module) - - return top_dirs - - -def _get_pep8_violations(clean=True): - """ - Runs pycodestyle. Returns a tuple of (number_of_violations, violations_string) - where violations_string is a string of all PEP 8 violations found, separated - by new lines. - """ - report_dir = (Env.REPORT_DIR / 'pep8') - if clean: - report_dir.rmtree(ignore_errors=True) - report_dir.makedirs_p() - report = report_dir / 'pep8.report' - - # Make sure the metrics subdirectory exists - Env.METRICS_DIR.makedirs_p() - - if not report.exists(): - sh(f'pycodestyle . | tee {report} -a') - - violations_list = _pep8_violations(report) - - return len(violations_list), violations_list - - -def _pep8_violations(report_file): - """ - Returns the list of all PEP 8 violations in the given report_file. - """ - with open(report_file) as f: - return f.readlines() - - -@task -@cmdopts([ - ("system=", "s", "System to act on"), -]) -@timed -def run_pep8(options): # pylint: disable=unused-argument - """ - Run pycodestyle on system code. - Fail the task if any violations are found. - """ - (count, violations_list) = _get_pep8_violations() - violations_list = ''.join(violations_list) - - # Print number of violations to log - violations_count_str = f"Number of PEP 8 violations: {count}" - print(violations_count_str) - print(violations_list) - - # Also write the number of violations to a file - with open(Env.METRICS_DIR / "pep8", "w") as f: - f.write(violations_count_str + '\n\n') - f.write(violations_list) - - # Fail if any violations are found - if count: - failure_string = "FAILURE: Too many PEP 8 violations. " + violations_count_str - failure_string += f"\n\nViolations:\n{violations_list}" - fail_quality('pep8', failure_string) - else: - write_junit_xml('pep8') - - -@task -@needs( - 'pavelib.prereqs.install_node_prereqs', - 'pavelib.utils.test.utils.ensure_clean_package_lock', -) -@cmdopts([ - ("limit=", "l", "limit for number of acceptable violations"), -]) -@timed -def run_eslint(options): - """ - Runs eslint on static asset directories. - If limit option is passed, fails build if more violations than the limit are found. - """ - - eslint_report_dir = (Env.REPORT_DIR / "eslint") - eslint_report = eslint_report_dir / "eslint.report" - _prepare_report_dir(eslint_report_dir) - violations_limit = int(getattr(options, 'limit', -1)) - - sh( - "node --max_old_space_size=4096 node_modules/.bin/eslint " - "--ext .js --ext .jsx --format=compact . | tee {eslint_report}".format( - eslint_report=eslint_report - ), - ignore_error=True - ) - - try: - num_violations = int(_get_count_from_last_line(eslint_report, "eslint")) - except TypeError: - fail_quality( - 'eslint', - "FAILURE: Number of eslint violations could not be found in {eslint_report}".format( - eslint_report=eslint_report - ) - ) - - # Record the metric - _write_metric(num_violations, (Env.METRICS_DIR / "eslint")) - - # Fail if number of violations is greater than the limit - if num_violations > violations_limit > -1: - fail_quality( - 'eslint', - "FAILURE: Too many eslint violations ({count}).\nThe limit is {violations_limit}.".format( - count=num_violations, violations_limit=violations_limit - ) - ) - else: - write_junit_xml('eslint') - - -def _get_stylelint_violations(): - """ - Returns the number of Stylelint violations. - """ - stylelint_report_dir = (Env.REPORT_DIR / "stylelint") - stylelint_report = stylelint_report_dir / "stylelint.report" - _prepare_report_dir(stylelint_report_dir) - formatter = 'node_modules/stylelint-formatter-pretty' - - sh( - "stylelint **/*.scss --custom-formatter={formatter} | tee {stylelint_report}".format( - formatter=formatter, - stylelint_report=stylelint_report, - ), - ignore_error=True - ) - - try: - return int(_get_count_from_last_line(stylelint_report, "stylelint")) - except TypeError: - fail_quality( - 'stylelint', - "FAILURE: Number of stylelint violations could not be found in {stylelint_report}".format( - stylelint_report=stylelint_report - ) - ) - - -@task -@needs('pavelib.prereqs.install_node_prereqs') -@cmdopts([ - ("limit=", "l", "limit for number of acceptable violations"), -]) -@timed -def run_stylelint(options): - """ - Runs stylelint on Sass files. - If limit option is passed, fails build if more violations than the limit are found. - """ - violations_limit = 0 - num_violations = _get_stylelint_violations() - - # Record the metric - _write_metric(num_violations, (Env.METRICS_DIR / "stylelint")) - - # Fail if number of violations is greater than the limit - if num_violations > violations_limit: - fail_quality( - 'stylelint', - "FAILURE: Stylelint failed with too many violations: ({count}).\nThe limit is {violations_limit}.".format( - count=num_violations, - violations_limit=violations_limit, - ) - ) - else: - write_junit_xml('stylelint') - - -@task -@needs('pavelib.prereqs.install_python_prereqs') -@cmdopts([ - ("thresholds=", "t", "json containing limit for number of acceptable violations per rule"), -]) -@timed -def run_xsslint(options): - """ - Runs xsslint/xss_linter.py on the codebase - """ - - thresholds_option = getattr(options, 'thresholds', '{}') - try: - violation_thresholds = json.loads(thresholds_option) - except ValueError: - violation_thresholds = None - if isinstance(violation_thresholds, dict) is False or \ - any(key not in ("total", "rules") for key in violation_thresholds.keys()): - - fail_quality( - 'xsslint', - """FAILURE: Thresholds option "{thresholds_option}" was not supplied using proper format.\n""" - """Here is a properly formatted example, '{{"total":100,"rules":{{"javascript-escape":0}}}}' """ - """with property names in double-quotes.""".format( - thresholds_option=thresholds_option - ) - ) - - xsslint_script = "xss_linter.py" - xsslint_report_dir = (Env.REPORT_DIR / "xsslint") - xsslint_report = xsslint_report_dir / "xsslint.report" - _prepare_report_dir(xsslint_report_dir) - - sh( - "{repo_root}/scripts/xsslint/{xsslint_script} --rule-totals --config={cfg_module} >> {xsslint_report}".format( - repo_root=Env.REPO_ROOT, - xsslint_script=xsslint_script, - xsslint_report=xsslint_report, - cfg_module='scripts.xsslint_config' - ), - ignore_error=True - ) - - xsslint_counts = _get_xsslint_counts(xsslint_report) - - try: - metrics_str = "Number of {xsslint_script} violations: {num_violations}\n".format( - xsslint_script=xsslint_script, num_violations=int(xsslint_counts['total']) - ) - if 'rules' in xsslint_counts and any(xsslint_counts['rules']): - metrics_str += "\n" - rule_keys = sorted(xsslint_counts['rules'].keys()) - for rule in rule_keys: - metrics_str += "{rule} violations: {count}\n".format( - rule=rule, - count=int(xsslint_counts['rules'][rule]) - ) - except TypeError: - fail_quality( - 'xsslint', - "FAILURE: Number of {xsslint_script} violations could not be found in {xsslint_report}".format( - xsslint_script=xsslint_script, xsslint_report=xsslint_report - ) - ) - - metrics_report = (Env.METRICS_DIR / "xsslint") - # Record the metric - _write_metric(metrics_str, metrics_report) - # Print number of violations to log. - sh(f"cat {metrics_report}", ignore_error=True) - - error_message = "" - - # Test total violations against threshold. - if 'total' in list(violation_thresholds.keys()): - if violation_thresholds['total'] < xsslint_counts['total']: - error_message = "Too many violations total ({count}).\nThe limit is {violations_limit}.".format( - count=xsslint_counts['total'], violations_limit=violation_thresholds['total'] - ) - - # Test rule violations against thresholds. - if 'rules' in violation_thresholds: - threshold_keys = sorted(violation_thresholds['rules'].keys()) - for threshold_key in threshold_keys: - if threshold_key not in xsslint_counts['rules']: - error_message += ( - "\nNumber of {xsslint_script} violations for {rule} could not be found in " - "{xsslint_report}." - ).format( - xsslint_script=xsslint_script, rule=threshold_key, xsslint_report=xsslint_report - ) - elif violation_thresholds['rules'][threshold_key] < xsslint_counts['rules'][threshold_key]: - error_message += \ - "\nToo many {rule} violations ({count}).\nThe {rule} limit is {violations_limit}.".format( - rule=threshold_key, count=xsslint_counts['rules'][threshold_key], - violations_limit=violation_thresholds['rules'][threshold_key], - ) - - if error_message: - fail_quality( - 'xsslint', - "FAILURE: XSSLinter Failed.\n{error_message}\n" - "See {xsslint_report} or run the following command to hone in on the problem:\n" - " ./scripts/xss-commit-linter.sh -h".format( - error_message=error_message, xsslint_report=xsslint_report - ) - ) - else: - write_junit_xml('xsslint') - - -def _write_metric(metric, filename): - """ - Write a given metric to a given file - Used for things like reports/metrics/eslint, which will simply tell you the number of - eslint violations found - """ - Env.METRICS_DIR.makedirs_p() - - with open(filename, "w") as metric_file: - metric_file.write(str(metric)) - - -def _prepare_report_dir(dir_name): - """ - Sets a given directory to a created, but empty state - """ - dir_name.rmtree_p() - dir_name.mkdir_p() - - -def _get_report_contents(filename, report_name, last_line_only=False): - """ - Returns the contents of the given file. Use last_line_only to only return - the last line, which can be used for getting output from quality output - files. - - Arguments: - last_line_only: True to return the last line only, False to return a - string with full contents. - - Returns: - String containing full contents of the report, or the last line. - - """ - if os.path.isfile(filename): - with open(filename) as report_file: - if last_line_only: - lines = report_file.readlines() - for line in reversed(lines): - if line != '\n': - return line - return None - else: - return report_file.read() - else: - file_not_found_message = f"FAILURE: The following log file could not be found: {filename}" - fail_quality(report_name, file_not_found_message) - - -def _get_count_from_last_line(filename, file_type): - """ - This will return the number in the last line of a file. - It is returning only the value (as a floating number). - """ - report_contents = _get_report_contents(filename, file_type, last_line_only=True) - - if report_contents is None: - return 0 - - last_line = report_contents.strip() - # Example of the last line of a compact-formatted eslint report (for example): "62829 problems" - regex = r'^\d+' - - try: - return float(re.search(regex, last_line).group(0)) - # An AttributeError will occur if the regex finds no matches. - # A ValueError will occur if the returned regex cannot be cast as a float. - except (AttributeError, ValueError): - return None - - -def _get_xsslint_counts(filename): - """ - This returns a dict of violations from the xsslint report. - - Arguments: - filename: The name of the xsslint report. - - Returns: - A dict containing the following: - rules: A dict containing the count for each rule as follows: - violation-rule-id: N, where N is the number of violations - total: M, where M is the number of total violations - - """ - report_contents = _get_report_contents(filename, 'xsslint') - rule_count_regex = re.compile(r"^(?P[a-z-]+):\s+(?P\d+) violations", re.MULTILINE) - total_count_regex = re.compile(r"^(?P\d+) violations total", re.MULTILINE) - violations = {'rules': {}} - for violation_match in rule_count_regex.finditer(report_contents): - try: - violations['rules'][violation_match.group('rule_id')] = int(violation_match.group('count')) - except ValueError: - violations['rules'][violation_match.group('rule_id')] = None - try: - violations['total'] = int(total_count_regex.search(report_contents).group('count')) - # An AttributeError will occur if the regex finds no matches. - # A ValueError will occur if the returned regex cannot be cast as a float. - except (AttributeError, ValueError): - violations['total'] = None - return violations - - -def _extract_missing_pii_annotations(filename): - """ - Returns the number of uncovered models from the stdout report of django_find_annotations. - - Arguments: - filename: Filename where stdout of django_find_annotations was captured. - - Returns: - three-tuple containing: - 1. The number of uncovered models, - 2. A bool indicating whether the coverage is still below the threshold, and - 3. The full report as a string. - """ - uncovered_models = 0 - pii_check_passed = True - if os.path.isfile(filename): - with open(filename) as report_file: - lines = report_file.readlines() - - # Find the count of uncovered models. - uncovered_regex = re.compile(r'^Coverage found ([\d]+) uncovered') - for line in lines: - uncovered_match = uncovered_regex.match(line) - if uncovered_match: - uncovered_models = int(uncovered_match.groups()[0]) - break - - # Find a message which suggests the check failed. - failure_regex = re.compile(r'^Coverage threshold not met!') - for line in lines: - failure_match = failure_regex.match(line) - if failure_match: - pii_check_passed = False - break - - # Each line in lines already contains a newline. - full_log = ''.join(lines) - else: - fail_quality('pii', f'FAILURE: Log file could not be found: {filename}') - - return (uncovered_models, pii_check_passed, full_log) - - -@task -@needs('pavelib.prereqs.install_python_prereqs') -@cmdopts([ - ("report-dir=", "r", "Directory in which to put PII reports"), -]) -@timed -def run_pii_check(options): - """ - Guarantee that all Django models are PII-annotated. - """ - pii_report_name = 'pii' - default_report_dir = (Env.REPORT_DIR / pii_report_name) - report_dir = getattr(options, 'report_dir', default_report_dir) - output_file = os.path.join(report_dir, 'pii_check_{}.report') - env_report = [] - pii_check_passed = True - for env_name, env_settings_file in (("CMS", "cms.envs.test"), ("LMS", "lms.envs.test")): - try: - print() - print(f"Running {env_name} PII Annotation check and report") - print("-" * 45) - run_output_file = str(output_file).format(env_name.lower()) - sh( - "mkdir -p {} && " # lint-amnesty, pylint: disable=duplicate-string-formatting-argument - "export DJANGO_SETTINGS_MODULE={}; " - "code_annotations django_find_annotations " - "--config_file .pii_annotations.yml --report_path {} --app_name {} " - "--lint --report --coverage | tee {}".format( - report_dir, env_settings_file, report_dir, env_name.lower(), run_output_file - ) - ) - uncovered_model_count, pii_check_passed_env, full_log = _extract_missing_pii_annotations(run_output_file) - env_report.append(( - uncovered_model_count, - full_log, - )) - - except BuildFailure as error_message: - fail_quality(pii_report_name, f'FAILURE: {error_message}') - - if not pii_check_passed_env: - pii_check_passed = False - - # Determine which suite is the worst offender by obtaining the max() keying off uncovered_count. - uncovered_count, full_log = max(env_report, key=lambda r: r[0]) - - # Write metric file. - if uncovered_count is None: - uncovered_count = 0 - metrics_str = f"Number of PII Annotation violations: {uncovered_count}\n" - _write_metric(metrics_str, (Env.METRICS_DIR / pii_report_name)) - - # Finally, fail the paver task if code_annotations suggests that the check failed. - if not pii_check_passed: - fail_quality('pii', full_log) - - -@task -@needs('pavelib.prereqs.install_python_prereqs') -@timed -def check_keywords(): - """ - Check Django model fields for names that conflict with a list of reserved keywords - """ - report_path = os.path.join(Env.REPORT_DIR, 'reserved_keywords') - sh(f"mkdir -p {report_path}") - - overall_status = True - for env, env_settings_file in [('lms', 'lms.envs.test'), ('cms', 'cms.envs.test')]: - report_file = f"{env}_reserved_keyword_report.csv" - override_file = os.path.join(Env.REPO_ROOT, "db_keyword_overrides.yml") - try: - sh( - "export DJANGO_SETTINGS_MODULE={settings_file}; " - "python manage.py {app} check_reserved_keywords " - "--override_file {override_file} " - "--report_path {report_path} " - "--report_file {report_file}".format( - settings_file=env_settings_file, app=env, override_file=override_file, - report_path=report_path, report_file=report_file - ) - ) - except BuildFailure: - overall_status = False - - if not overall_status: - fail_quality( - 'keywords', - 'Failure: reserved keyword checker failed. Reports can be found here: {}'.format( - report_path - ) - ) diff --git a/pavelib/utils/cmd.py b/pavelib/utils/cmd.py deleted file mode 100644 index a350c90a6a..0000000000 --- a/pavelib/utils/cmd.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -Helper functions for constructing shell commands. -""" - - -def cmd(*args): - """ - Concatenate the arguments into a space-separated shell command. - """ - return " ".join(str(arg) for arg in args if arg) - - -def django_cmd(sys, settings, *args): - """ - Construct a Django management command. - - `sys` is either 'lms' or 'studio'. - `settings` is the Django settings module (such as "dev" or "test") - `args` are concatenated to form the rest of the command. - """ - # Maintain backwards compatibility with manage.py, - # which calls "studio" "cms" - sys = 'cms' if sys == 'studio' else sys - return cmd("python manage.py", sys, f"--settings={settings}", *args) diff --git a/pavelib/utils/envs.py b/pavelib/utils/envs.py deleted file mode 100644 index d2cdd4a77d..0000000000 --- a/pavelib/utils/envs.py +++ /dev/null @@ -1,267 +0,0 @@ -""" -Helper functions for loading environment settings. -""" -import configparser -import json -import os -import sys -from time import sleep - -from lazy import lazy -from path import Path as path -from paver.easy import BuildFailure, sh - -from pavelib.utils.cmd import django_cmd - - -def repo_root(): - """ - Get the root of the git repository (edx-platform). - - This sometimes fails on Docker Devstack, so it's been broken - down with some additional error handling. It usually starts - working within 30 seconds or so; for more details, see - https://openedx.atlassian.net/browse/PLAT-1629 and - https://github.com/docker/for-mac/issues/1509 - """ - file_path = path(__file__) - attempt = 1 - while True: - try: - absolute_path = file_path.abspath() - break - except OSError: - print(f'Attempt {attempt}/180 to get an absolute path failed') - if attempt < 180: - attempt += 1 - sleep(1) - else: - print('Unable to determine the absolute path of the edx-platform repo, aborting') - raise - return absolute_path.parent.parent.parent - - -class Env: - """ - Load information about the execution environment. - """ - - # Root of the git repository (edx-platform) - REPO_ROOT = repo_root() - - # Reports Directory - REPORT_DIR = REPO_ROOT / 'reports' - METRICS_DIR = REPORT_DIR / 'metrics' - QUALITY_DIR = REPORT_DIR / 'quality_junitxml' - - # Generic log dir - GEN_LOG_DIR = REPO_ROOT / "test_root" / "log" - - # Python unittest dirs - PYTHON_COVERAGERC = REPO_ROOT / ".coveragerc" - - # Which Python version should be used in xdist workers? - PYTHON_VERSION = os.environ.get("PYTHON_VERSION", "2.7") - - # Directory that videos are served from - VIDEO_SOURCE_DIR = REPO_ROOT / "test_root" / "data" / "video" - - PRINT_SETTINGS_LOG_FILE = GEN_LOG_DIR / "print_settings.log" - - # Detect if in a Docker container, and if so which one - FRONTEND_TEST_SERVER_HOST = os.environ.get('FRONTEND_TEST_SERVER_HOSTNAME', '0.0.0.0') - USING_DOCKER = FRONTEND_TEST_SERVER_HOST != '0.0.0.0' - DEVSTACK_SETTINGS = 'devstack_docker' if USING_DOCKER else 'devstack' - TEST_SETTINGS = 'test' - - # Mongo databases that will be dropped before/after the tests run - MONGO_HOST = 'localhost' - - # Test Ids Directory - TEST_DIR = REPO_ROOT / ".testids" - - # Configured browser to use for the js test suites - SELENIUM_BROWSER = os.environ.get('SELENIUM_BROWSER', 'firefox') - if USING_DOCKER: - KARMA_BROWSER = 'ChromeDocker' if SELENIUM_BROWSER == 'chrome' else 'FirefoxDocker' - else: - KARMA_BROWSER = 'FirefoxNoUpdates' - - # Files used to run each of the js test suites - # TODO: Store this as a dict. Order seems to matter for some - # reason. See issue TE-415. - KARMA_CONFIG_FILES = [ - REPO_ROOT / 'cms/static/karma_cms.conf.js', - REPO_ROOT / 'cms/static/karma_cms_squire.conf.js', - REPO_ROOT / 'cms/static/karma_cms_webpack.conf.js', - REPO_ROOT / 'lms/static/karma_lms.conf.js', - REPO_ROOT / 'xmodule/js/karma_xmodule.conf.js', - REPO_ROOT / 'xmodule/js/karma_xmodule_webpack.conf.js', - REPO_ROOT / 'common/static/karma_common.conf.js', - REPO_ROOT / 'common/static/karma_common_requirejs.conf.js', - ] - - JS_TEST_ID_KEYS = [ - 'cms', - 'cms-squire', - 'cms-webpack', - 'lms', - 'xmodule', - 'xmodule-webpack', - 'common', - 'common-requirejs', - 'jest-snapshot' - ] - - JS_REPORT_DIR = REPORT_DIR / 'javascript' - - # Directories used for pavelib/ tests - IGNORED_TEST_DIRS = ('__pycache__', '.cache', '.pytest_cache') - LIB_TEST_DIRS = [path("pavelib/paver_tests"), path("scripts/xsslint/tests")] - - # Directory for i18n test reports - I18N_REPORT_DIR = REPORT_DIR / 'i18n' - - # Directory for keeping src folder that comes with pip installation. - # Setting this is equivalent to passing `--src ` to pip directly. - PIP_SRC = os.environ.get("PIP_SRC") - - # Service variant (lms, cms, etc.) configured with an environment variable - # We use this to determine which envs.json file to load. - SERVICE_VARIANT = os.environ.get('SERVICE_VARIANT', None) - - # If service variant not configured in env, then pass the correct - # environment for lms / cms - if not SERVICE_VARIANT: # this will intentionally catch ""; - if any(i in sys.argv[1:] for i in ('cms', 'studio')): - SERVICE_VARIANT = 'cms' - else: - SERVICE_VARIANT = 'lms' - - @classmethod - def get_django_settings(cls, django_settings, system, settings=None, print_setting_args=None): - """ - Interrogate Django environment for specific settings values - :param django_settings: list of django settings values to get - :param system: the django app to use when asking for the setting (lms | cms) - :param settings: the settings file to use when asking for the value - :param print_setting_args: the additional arguments to send to print_settings - :return: unicode value of the django setting - """ - if not settings: - settings = os.environ.get("EDX_PLATFORM_SETTINGS", "aws") - log_dir = os.path.dirname(cls.PRINT_SETTINGS_LOG_FILE) - if not os.path.exists(log_dir): - os.makedirs(log_dir) - settings_length = len(django_settings) - django_settings = ' '.join(django_settings) # parse_known_args makes a list again - print_setting_args = ' '.join(print_setting_args or []) - try: - value = sh( - django_cmd( - system, - settings, - "print_setting {django_settings} 2>{log_file} {print_setting_args}".format( - django_settings=django_settings, - print_setting_args=print_setting_args, - log_file=cls.PRINT_SETTINGS_LOG_FILE - ).strip() - ), - capture=True - ) - # else for cases where values are not found & sh returns one None value - return tuple(str(value).splitlines()) if value else tuple(None for _ in range(settings_length)) - except BuildFailure: - print(f"Unable to print the value of the {django_settings} setting:") - with open(cls.PRINT_SETTINGS_LOG_FILE) as f: - print(f.read()) - sys.exit(1) - - @classmethod - def get_django_json_settings(cls, django_settings, system, settings=None): - """ - Interrogate Django environment for specific settings value - :param django_settings: list of django settings values to get - :param system: the django app to use when asking for the setting (lms | cms) - :param settings: the settings file to use when asking for the value - :return: json string value of the django setting - """ - return cls.get_django_settings( - django_settings, - system, - settings=settings, - print_setting_args=["--json"], - ) - - @classmethod - def covered_modules(cls): - """ - List the source modules listed in .coveragerc for which coverage - will be measured. - """ - coveragerc = configparser.RawConfigParser() - coveragerc.read(cls.PYTHON_COVERAGERC) - modules = coveragerc.get('run', 'source') - result = [] - for module in modules.split('\n'): - module = module.strip() - if module: - result.append(module) - return result - - @lazy - def env_tokens(self): - """ - Return a dict of environment settings. - If we couldn't find the JSON file, issue a warning and return an empty dict. - """ - - # Find the env JSON file - if self.SERVICE_VARIANT: - env_path = self.REPO_ROOT.parent / f"{self.SERVICE_VARIANT}.env.json" - else: - env_path = path("env.json").abspath() - - # If the file does not exist, here or one level up, - # issue a warning and return an empty dict - if not env_path.isfile(): - env_path = env_path.parent.parent / env_path.basename() - if not env_path.isfile(): - print( - "Warning: could not find environment JSON file " - "at '{path}'".format(path=env_path), - file=sys.stderr, - ) - return {} - - # Otherwise, load the file as JSON and return the resulting dict - try: - with open(env_path) as env_file: - return json.load(env_file) - - except ValueError: - print( - "Error: Could not parse JSON " - "in {path}".format(path=env_path), - file=sys.stderr, - ) - sys.exit(1) - - @lazy - def feature_flags(self): - """ - Return a dictionary of feature flags configured by the environment. - """ - return self.env_tokens.get('FEATURES', {}) - - @classmethod - def rsync_dirs(cls): - """ - List the directories that should be synced during pytest-xdist - execution. Needs to include all modules for which coverage is - measured, not just the tests being run. - """ - result = set() - for module in cls.covered_modules(): - result.add(module.split('/')[0]) - return result diff --git a/pavelib/utils/process.py b/pavelib/utils/process.py deleted file mode 100644 index da2dafa880..0000000000 --- a/pavelib/utils/process.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -Helper functions for managing processes. -""" - - -import atexit -import os -import signal -import subprocess -import sys - -import psutil -from paver import tasks - - -def kill_process(proc): - """ - Kill the process `proc` created with `subprocess`. - """ - p1_group = psutil.Process(proc.pid) - child_pids = p1_group.children(recursive=True) - - for child_pid in child_pids: - os.kill(child_pid.pid, signal.SIGKILL) - - -def run_multi_processes(cmd_list, out_log=None, err_log=None): - """ - Run each shell command in `cmd_list` in a separate process, - piping stdout to `out_log` (a path) and stderr to `err_log` (also a path). - - Terminates the processes on CTRL-C and ensures the processes are killed - if an error occurs. - """ - kwargs = {'shell': True, 'cwd': None} - pids = [] - - if out_log: - out_log_file = open(out_log, 'w') # lint-amnesty, pylint: disable=consider-using-with - kwargs['stdout'] = out_log_file - - if err_log: - err_log_file = open(err_log, 'w') # lint-amnesty, pylint: disable=consider-using-with - kwargs['stderr'] = err_log_file - - # If the user is performing a dry run of a task, then just log - # the command strings and return so that no destructive operations - # are performed. - if tasks.environment.dry_run: - for cmd in cmd_list: - tasks.environment.info(cmd) - return - - try: - for cmd in cmd_list: - pids.extend([subprocess.Popen(cmd, **kwargs)]) - - # pylint: disable=unused-argument - def _signal_handler(*args): - """ - What to do when process is ended - """ - print("\nEnding...") - - signal.signal(signal.SIGINT, _signal_handler) - print("Enter CTL-C to end") - signal.pause() - print("Processes ending") - - # pylint: disable=broad-except - except Exception as err: - print(f"Error running process {err}", file=sys.stderr) - - finally: - for pid in pids: - kill_process(pid) - - -def run_process(cmd, out_log=None, err_log=None): - """ - Run the shell command `cmd` in a separate process, - piping stdout to `out_log` (a path) and stderr to `err_log` (also a path). - - Terminates the process on CTRL-C or if an error occurs. - """ - return run_multi_processes([cmd], out_log=out_log, err_log=err_log) - - -def run_background_process(cmd, out_log=None, err_log=None, cwd=None): - """ - Runs a command as a background process. Sends SIGINT at exit. - """ - - kwargs = {'shell': True, 'cwd': cwd} - if out_log: - out_log_file = open(out_log, 'w') # lint-amnesty, pylint: disable=consider-using-with - kwargs['stdout'] = out_log_file - - if err_log: - err_log_file = open(err_log, 'w') # lint-amnesty, pylint: disable=consider-using-with - kwargs['stderr'] = err_log_file - - proc = subprocess.Popen(cmd, **kwargs) # lint-amnesty, pylint: disable=consider-using-with - - def exit_handler(): - """ - Send SIGINT to the process's children. This is important - for running commands under coverage, as coverage will not - produce the correct artifacts if the child process isn't - killed properly. - """ - p1_group = psutil.Process(proc.pid) - child_pids = p1_group.children(recursive=True) - - for child_pid in child_pids: - os.kill(child_pid.pid, signal.SIGINT) - - # Wait for process to actually finish - proc.wait() - - atexit.register(exit_handler) diff --git a/pavelib/utils/test/suites/__init__.py b/pavelib/utils/test/suites/__init__.py deleted file mode 100644 index 34ecd49c1c..0000000000 --- a/pavelib/utils/test/suites/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -""" -TestSuite class and subclasses -""" -from .js_suite import JestSnapshotTestSuite, JsTestSuite -from .suite import TestSuite diff --git a/pavelib/utils/test/suites/js_suite.py b/pavelib/utils/test/suites/js_suite.py deleted file mode 100644 index 4e53d454fe..0000000000 --- a/pavelib/utils/test/suites/js_suite.py +++ /dev/null @@ -1,109 +0,0 @@ -""" -Javascript test tasks -""" - - -from paver import tasks - -from pavelib.utils.envs import Env -from pavelib.utils.test import utils as test_utils -from pavelib.utils.test.suites.suite import TestSuite - -__test__ = False # do not collect - - -class JsTestSuite(TestSuite): - """ - A class for running JavaScript tests. - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.run_under_coverage = kwargs.get('with_coverage', True) - self.mode = kwargs.get('mode', 'run') - self.report_dir = Env.JS_REPORT_DIR - self.opts = kwargs - - suite = args[0] - self.subsuites = self._default_subsuites if suite == 'all' else [JsTestSubSuite(*args, **kwargs)] - - def __enter__(self): - super().__enter__() - if tasks.environment.dry_run: - tasks.environment.info("make report_dir") - else: - self.report_dir.makedirs_p() - if not self.skip_clean: - test_utils.clean_test_files() - - if self.mode == 'run' and not self.run_under_coverage: - test_utils.clean_dir(self.report_dir) - - @property - def _default_subsuites(self): - """ - Returns all JS test suites - """ - return [JsTestSubSuite(test_id, **self.opts) for test_id in Env.JS_TEST_ID_KEYS if test_id != 'jest-snapshot'] - - -class JsTestSubSuite(TestSuite): - """ - Class for JS suites like cms, cms-squire, lms, common, - common-requirejs and xmodule - """ - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.test_id = args[0] - self.run_under_coverage = kwargs.get('with_coverage', True) - self.mode = kwargs.get('mode', 'run') - self.port = kwargs.get('port') - self.root = self.root + ' javascript' - self.report_dir = Env.JS_REPORT_DIR - - try: - self.test_conf_file = Env.KARMA_CONFIG_FILES[Env.JS_TEST_ID_KEYS.index(self.test_id)] - except ValueError: - self.test_conf_file = Env.KARMA_CONFIG_FILES[0] - - self.coverage_report = self.report_dir / f'coverage-{self.test_id}.xml' - self.xunit_report = self.report_dir / f'javascript_xunit-{self.test_id}.xml' - - @property - def cmd(self): - """ - Run the tests using karma runner. - """ - cmd = [ - "node", - "--max_old_space_size=4096", - "node_modules/.bin/karma", - "start", - self.test_conf_file, - "--single-run={}".format('false' if self.mode == 'dev' else 'true'), - "--capture-timeout=60000", - f"--junitreportpath={self.xunit_report}", - f"--browsers={Env.KARMA_BROWSER}", - ] - - if self.port: - cmd.append(f"--port={self.port}") - - if self.run_under_coverage: - cmd.extend([ - "--coverage", - f"--coveragereportpath={self.coverage_report}", - ]) - - return cmd - - -class JestSnapshotTestSuite(TestSuite): - """ - A class for running Jest Snapshot tests. - """ - @property - def cmd(self): - """ - Run the tests using Jest. - """ - return ["jest"] diff --git a/pavelib/utils/test/suites/suite.py b/pavelib/utils/test/suites/suite.py deleted file mode 100644 index 5a423c827c..0000000000 --- a/pavelib/utils/test/suites/suite.py +++ /dev/null @@ -1,149 +0,0 @@ -""" -A class used for defining and running test suites -""" - - -import os -import subprocess -import sys - -from paver import tasks - -from pavelib.utils.process import kill_process - -try: - from pygments.console import colorize -except ImportError: - colorize = lambda color, text: text - -__test__ = False # do not collect - - -class TestSuite: - """ - TestSuite is a class that defines how groups of tests run. - """ - def __init__(self, *args, **kwargs): - self.root = args[0] - self.subsuites = kwargs.get('subsuites', []) - self.failed_suites = [] - self.verbosity = int(kwargs.get('verbosity', 1)) - self.skip_clean = kwargs.get('skip_clean', False) - self.passthrough_options = kwargs.get('passthrough_options', []) - - def __enter__(self): - """ - This will run before the test suite is run with the run_suite_tests method. - If self.run_test is called directly, it should be run in a 'with' block to - ensure that the proper context is created. - - Specific setup tasks should be defined in each subsuite. - - i.e. Checking for and defining required directories. - """ - print(f"\nSetting up for {self.root}") - self.failed_suites = [] - - def __exit__(self, exc_type, exc_value, traceback): - """ - This is run after the tests run with the run_suite_tests method finish. - Specific clean up tasks should be defined in each subsuite. - - If self.run_test is called directly, it should be run in a 'with' block - to ensure that clean up happens properly. - - i.e. Cleaning mongo after the lms tests run. - """ - print(f"\nCleaning up after {self.root}") - - @property - def cmd(self): - """ - The command to run tests (as a string). For this base class there is none. - """ - return None - - @staticmethod - def is_success(exit_code): - """ - Determine if the given exit code represents a success of the test - suite. By default, only a zero counts as a success. - """ - return exit_code == 0 - - def run_test(self): - """ - Runs a self.cmd in a subprocess and waits for it to finish. - It returns False if errors or failures occur. Otherwise, it - returns True. - """ - cmd = " ".join(self.cmd) - - if tasks.environment.dry_run: - tasks.environment.info(cmd) - return - - sys.stdout.write(cmd) - - msg = colorize( - 'green', - '\n{bar}\n Running tests for {suite_name} \n{bar}\n'.format(suite_name=self.root, bar='=' * 40), - ) - - sys.stdout.write(msg) - sys.stdout.flush() - - if 'TEST_SUITE' not in os.environ: - os.environ['TEST_SUITE'] = self.root.replace("/", "_") - kwargs = {'shell': True, 'cwd': None} - process = None - - try: - process = subprocess.Popen(cmd, **kwargs) # lint-amnesty, pylint: disable=consider-using-with - return self.is_success(process.wait()) - except KeyboardInterrupt: - kill_process(process) - sys.exit(1) - - def run_suite_tests(self): - """ - Runs each of the suites in self.subsuites while tracking failures - """ - # Uses __enter__ and __exit__ for context - with self: - # run the tests for this class, and for all subsuites - if self.cmd: - passed = self.run_test() - if not passed: - self.failed_suites.append(self) - - for suite in self.subsuites: - suite.run_suite_tests() - if suite.failed_suites: - self.failed_suites.extend(suite.failed_suites) - - def report_test_results(self): - """ - Writes a list of failed_suites to sys.stderr - """ - if self.failed_suites: - msg = colorize('red', "\n\n{bar}\nTests failed in the following suites:\n* ".format(bar="=" * 48)) - msg += colorize('red', '\n* '.join([s.root for s in self.failed_suites]) + '\n\n') - else: - msg = colorize('green', "\n\n{bar}\nNo test failures ".format(bar="=" * 48)) - - print(msg) - - def run(self): - """ - Runs the tests in the suite while tracking and reporting failures. - """ - self.run_suite_tests() - - if tasks.environment.dry_run: - return - - self.report_test_results() - - if self.failed_suites: - sys.exit(1) diff --git a/pavelib/utils/test/utils.py b/pavelib/utils/test/utils.py deleted file mode 100644 index 0851251e22..0000000000 --- a/pavelib/utils/test/utils.py +++ /dev/null @@ -1,91 +0,0 @@ -""" -Helper functions for test tasks -""" - - -import os - -from paver.easy import cmdopts, sh, task - -from pavelib.utils.envs import Env -from pavelib.utils.timer import timed - - -MONGO_PORT_NUM = int(os.environ.get('EDXAPP_TEST_MONGO_PORT', '27017')) - -COVERAGE_CACHE_BUCKET = "edx-tools-coverage-caches" -COVERAGE_CACHE_BASEPATH = "test_root/who_tests_what" -COVERAGE_CACHE_BASELINE = "who_tests_what.{}.baseline".format(os.environ.get('WTW_CONTEXT', 'all')) -WHO_TESTS_WHAT_DIFF = "who_tests_what.diff" - - -__test__ = False # do not collect - - -@task -@timed -def clean_test_files(): - """ - Clean fixture files used by tests and .pyc files - """ - sh("git clean -fqdx test_root/logs test_root/data test_root/staticfiles test_root/uploads") - # This find command removes all the *.pyc files that aren't in the .git - # directory. See this blog post for more details: - # http://nedbatchelder.com/blog/201505/be_careful_deleting_files_around_git.html - sh(r"find . -name '.git' -prune -o -name '*.pyc' -exec rm {} \;") - sh("rm -rf test_root/log/auto_screenshots/*") - sh("rm -rf /tmp/mako_[cl]ms") - - -@task -@timed -def ensure_clean_package_lock(): - """ - Ensure no untracked changes have been made in the current git context. - """ - sh(""" - git diff --name-only --exit-code package-lock.json || - (echo \"Dirty package-lock.json, run 'npm install' and commit the generated changes\" && exit 1) - """) - - -def clean_dir(directory): - """ - Delete all the files from the specified directory. - """ - # We delete the files but preserve the directory structure - # so that coverage.py has a place to put the reports. - sh(f'find {directory} -type f -delete') - - -@task -@cmdopts([ - ('skip-clean', 'C', 'skip cleaning repository before running tests'), - ('skip_clean', None, 'deprecated in favor of skip-clean'), -]) -@timed -def clean_reports_dir(options): - """ - Clean coverage files, to ensure that we don't use stale data to generate reports. - """ - if getattr(options, 'skip_clean', False): - print('--skip-clean is set, skipping...') - return - - # We delete the files but preserve the directory structure - # so that coverage.py has a place to put the reports. - reports_dir = Env.REPORT_DIR.makedirs_p() - clean_dir(reports_dir) - - -@task -@timed -def clean_mongo(): - """ - Clean mongo test databases - """ - sh("mongo {host}:{port} {repo_root}/scripts/delete-mongo-test-dbs.js".format( - host=Env.MONGO_HOST, - port=MONGO_PORT_NUM, - repo_root=Env.REPO_ROOT, - )) diff --git a/pavelib/utils/timer.py b/pavelib/utils/timer.py deleted file mode 100644 index fc6f300373..0000000000 --- a/pavelib/utils/timer.py +++ /dev/null @@ -1,83 +0,0 @@ -""" -Tools for timing paver tasks -""" - - -import json -import logging -import os -import sys -import traceback -from datetime import datetime -from os.path import dirname, exists - -import wrapt - -LOGGER = logging.getLogger(__file__) -PAVER_TIMER_LOG = os.environ.get('PAVER_TIMER_LOG') - - -@wrapt.decorator -def timed(wrapped, instance, args, kwargs): # pylint: disable=unused-argument - """ - Log execution time for a function to a log file. - - Logging is only actually executed if the PAVER_TIMER_LOG environment variable - is set. That variable is expanded for the current user and current - environment variables. It also can have :meth:`~Datetime.strftime` format - identifiers which are substituted using the time when the task started. - - For example, ``PAVER_TIMER_LOG='~/.paver.logs/%Y-%d-%m.log'`` will create a new - log file every day containing reconds for paver tasks run that day, and - will put those log files in the ``.paver.logs`` directory inside the users - home. - - Must be earlier in the decorator stack than the paver task declaration. - """ - start = datetime.utcnow() - exception_info = {} - try: - return wrapped(*args, **kwargs) - except Exception as exc: - exception_info = { - 'exception': "".join(traceback.format_exception_only(type(exc), exc)).strip() - } - raise - finally: - end = datetime.utcnow() - - # N.B. This is intended to provide a consistent interface and message format - # across all of Open edX tooling, so it deliberately eschews standard - # python logging infrastructure. - if PAVER_TIMER_LOG is not None: - - log_path = start.strftime(PAVER_TIMER_LOG) - - log_message = { - 'python_version': sys.version, - 'task': f"{wrapped.__module__}.{wrapped.__name__}", - 'args': [repr(arg) for arg in args], - 'kwargs': {key: repr(value) for key, value in kwargs.items()}, - 'started_at': start.isoformat(' '), - 'ended_at': end.isoformat(' '), - 'duration': (end - start).total_seconds(), - } - log_message.update(exception_info) - - try: - log_dir = dirname(log_path) - if log_dir and not exists(log_dir): - os.makedirs(log_dir) - - with open(log_path, 'a') as outfile: - json.dump( - log_message, - outfile, - separators=(',', ':'), - sort_keys=True, - ) - outfile.write('\n') - except OSError: - # Squelch OSErrors, because we expect them and they shouldn't - # interrupt the rest of the process. - LOGGER.exception("Unable to write timing logs") diff --git a/pavement.py b/pavement.py deleted file mode 100644 index 41a6227dbf..0000000000 --- a/pavement.py +++ /dev/null @@ -1,12 +0,0 @@ -import sys # lint-amnesty, pylint: disable=django-not-configured, missing-module-docstring -import os - -# Ensure that we can import pavelib, and that our copy of pavelib -# takes precedence over anything else installed in the virtualenv. -# In local dev, we usually don't need to do this, because Python -# automatically puts the current working directory on the system path. -# Until we re-run pip install, the other copies of edx-platform could -# take precedence, leading to some strange results. -sys.path.insert(0, os.path.dirname(__file__)) - -from pavelib import * # lint-amnesty, pylint: disable=wildcard-import, wrong-import-position diff --git a/pylintrc b/pylintrc index 55a9bbab3b..43f2b3bc9e 100644 --- a/pylintrc +++ b/pylintrc @@ -64,18 +64,18 @@ # SERIOUSLY. # # ------------------------------ -# Generated by edx-lint version: 5.3.7 +# Generated by edx-lint version: 5.6.0 # ------------------------------ [MASTER] ignore = ,.git,.tox,migrations,node_modules,.pycharm_helpers persistent = yes -load-plugins = edx_lint.pylint,pylint_django_settings,pylint_django,pylint_celery,pylint_pytest +load-plugins = edx_lint.pylint,openedx.core.tests.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,10 @@ disable = c-extension-no-member, no-name-in-module, unnecessary-lambda-assignment, + too-many-positional-arguments, + possibly-used-before-assignment, + use-dict-literal, + superfluous-parens [REPORTS] output-format = text @@ -356,7 +360,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 +386,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 +407,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 +# d6e4348dec0a8eb2752fc4fe02315286c298aeff diff --git a/pylintrc_tweaks b/pylintrc_tweaks index 1633da5c10..149433fa90 100644 --- a/pylintrc_tweaks +++ b/pylintrc_tweaks @@ -1,7 +1,7 @@ # pylintrc tweaks for use with edx_lint. [MASTER] ignore+ = ,.git,.tox,migrations,node_modules,.pycharm_helpers -load-plugins = edx_lint.pylint,pylint_django_settings,pylint_django,pylint_celery,pylint_pytest +load-plugins = edx_lint.pylint,openedx.core.tests.pylint_django_settings,pylint_django,pylint_celery,pylint_pytest [MESSAGES CONTROL] disable+ = @@ -33,6 +33,10 @@ disable+ = c-extension-no-member, no-name-in-module, unnecessary-lambda-assignment, + too-many-positional-arguments, + possibly-used-before-assignment, + use-dict-literal, + superfluous-parens [BASIC] attr-rgx = [a-z_][a-z0-9_]{2,40}$ diff --git a/requirements/common_constraints.txt b/requirements/common_constraints.txt index b8166ba675..f3cc8fc9c9 100644 --- a/requirements/common_constraints.txt +++ b/requirements/common_constraints.txt @@ -28,3 +28,7 @@ elasticsearch<7.14.0 # Cause: https://github.com/openedx/edx-lint/issues/458 # This can be unpinned once https://github.com/openedx/edx-lint/issues/459 has been resolved. pip<24.3 + +# Cause: https://github.com/openedx/edx-lint/issues/475 +# This can be unpinned once https://github.com/openedx/edx-lint/issues/476 has been resolved. +urllib3<2.3.0 diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 426e7e67ec..bef19ed18b 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -13,30 +13,12 @@ # This file contains all common constraints for edx-repos -c common_constraints.txt -# Date: 2024-08-21 -# Description: This is the major upgrade of algoliasearch python client and it will -# break one of the edX' platform plugin, so we need to make that compatible first. -# Ticket: https://github.com/openedx/edx-platform/issues/35334 -algoliasearch<4.0.0 - - # Date: 2020-02-26 # As it is not clarified what exact breaking changes will be introduced as per # the next major release, ensure the installed version is within boundaries. # Issue for unpinning: https://github.com/openedx/edx-platform/issues/35280 celery>=5.2.2,<6.0.0 -# Date: 2021-05-17 -# greater version breaking upgrade builds -# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35279 -click==8.1.6 - -# Date: 2022-07-20 -# edx-enterprise, snowflake-connector-python require charset-normalizer==2.0.0 -# Can be removed once snowflake-connector-python>2.7.9 is released with the fix. -# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35278 -charset-normalizer<2.1.0 - # Date: 2024-02-02 # Stay on LTS version, remove once this is added to common constraint Django<5.0 @@ -47,10 +29,6 @@ Django<5.0 # Issue for unpinning: https://github.com/openedx/edx-platform/issues/35277 django-oauth-toolkit==1.7.1 -# Date: 2024-02-02 -# incremental upgrade -django-simple-history==3.4.0 - # Date: 2021-05-17 # greater version has breaking changes and requires some migration steps. # Issue for unpinning: https://github.com/openedx/edx-platform/issues/35276 @@ -60,39 +38,20 @@ django-webpack-loader==0.7.0 # Adding pin to avoid any major upgrade djangorestframework<3.15.0 -# Date: 2023-07-19 -# The version of django-stubs we can use depends on which Django release we're using -# 1.16.0 works with Django 3.2 through 4.1 -# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35275 -django-stubs==1.16.0 -djangorestframework-stubs==3.14.0 # Pinned to match django-stubs. Remove this when we can remove the above pin. - -# Date: 2024-07-23 -# django-storages==1.14.4 breaks course imports -# Two lines were added in 1.14.4 that make file_exists_in_storage function always return False, -# as the default value of AWS_S3_FILE_OVERWRITE is True -# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35170 -django-storages<1.14.4 +# Date: 2024-07-19 +# Generally speaking, the major version of django-stubs must either match the major version +# of django, or exceed it by 1. So, we will need to perpetually constrain django-stubs and +# update it as we perform django upgrades. For more details, see: +# https://github.com/typeddjango/django-stubs?tab=readme-ov-file#version-compatibility +# including the note on "Partial Support". +# Issue: https://github.com/openedx/edx-platform/issues/35275 +django-stubs<6 # Date: 2019-08-16 # 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==4.32.2 - -# Date: 2024-05-09 -# This has to be constrained as well because newer versions of edx-i18n-tools need the -# newer version of lxml but that requirement was not made expilict in the 1.6.0 version -# of the package. This can be un-pinned when we're upgrading lxml. -# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35274 -edx-i18n-tools<1.6.0 - -# Date: 2024-07-26 -# To override the constraint of edx-lint -# This can be removed once https://github.com/openedx/edx-platform/issues/34586 is resolved -# and the upstream constraint in edx-lint has been removed. -# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35273 -event-tracking==3.0.0 +edx-enterprise==6.2.13 # Date: 2023-07-26 # Our legacy Sass code is incompatible with anything except this ancient libsass version. @@ -101,23 +60,6 @@ event-tracking==3.0.0 # https://github.com/openedx/edx-platform/issues/31616 libsass==0.10.0 -# Date: 2018-12-14 -# markdown>=3.4.0 has failures due to internal refactorings which causes the tests to fail -# pinning the version untill the issue gets resolved in the package itself -# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35271 -markdown<3.4.0 - -# Date: 2024-04-24 -# moto==5.0 contains breaking changes. Needs to be updated separately. -# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35270 -moto<5.0 - -# Date: 2024-10-16 -# MyPY 1.12.0 fails on all PRs with the following error: -# openedx/core/djangoapps/content_libraries/api.py:732: error: INTERNAL ERROR -# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35667 -mypy<1.12.0 - # Date: 2024-07-16 # We need to upgrade the version of elasticsearch to atleast 7.15 before we can upgrade to Numpy 2.0.0 # Otherwise we see a failure while running the following command: @@ -125,17 +67,10 @@ mypy<1.12.0 # Issue for unpinning: https://github.com/openedx/edx-platform/issues/35126 numpy<2.0.0 -# Date: 2024-01-26 -# optimizely-sdk 5.0.0 is breaking following test with segmentation fault -# common/djangoapps/third_party_auth/tests/test_views.py::SAMLMetadataTest::test_secure_key_configuration -# needs to be fixed in the follow up issue -# Issue for unpinning: https://github.com/openedx/edx-platform/issues/34103 -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.17.0 +openedx-learning==0.27.0 # Date: 2023-11-29 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. @@ -153,10 +88,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. - # 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). @@ -180,3 +111,26 @@ social-auth-app-django<=5.4.1 # # Date: 2024-10-14 # # The edx-enterprise is currently using edx-rest-api-client==5.7.1, which needs to be updated first. # edx-rest-api-client==5.7.1 + +# Date 2025-01-08 +# elasticsearch==7.13.x is downgrading urllib3 from 2.2.3 to 1.26.20 +# https://github.com/elastic/elasticsearch-py/blob/v7.13.4/setup.py#L42 +# We are pinning this until we can upgrade to a version of elasticsearch that uses a more recent version of urllib3. +# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35126 +elasticsearch==7.9.1 + +# Date 2025-03-21 +# social-auth-core>4.5.4 breaks tests with authorization on LinkedIn API +# Both of these constraints will be updated in a follow up PR under the following issue: +# https://github.com/openedx/edx-platform/issues/36425 +social-auth-core==4.5.4 + +# Date 2025-05-09 +# lxml and xmlsec need to be constrained because the latest version builds against a newer +# version of libxml2 than what we're running with. This leads to a version mismatch error +# at runtime. You can re-produce it by running any test. +# If lxml is pinned in the future and you see this error, it may be that the system libxml2 +# is now shipping the correct version and we can un-pin this. +# Issue: https://github.com/openedx/edx-platform/issues/36695 +lxml==5.3.2 +xmlsec==1.3.14 diff --git a/requirements/edx-sandbox/base.txt b/requirements/edx-sandbox/base.txt index 8cf0cbb829..caf002ffc0 100644 --- a/requirements/edx-sandbox/base.txt +++ b/requirements/edx-sandbox/base.txt @@ -6,42 +6,41 @@ # cffi==1.17.1 # via cryptography -chem==1.3.0 +chem==2.0.0 # via -r requirements/edx-sandbox/base.in -click==8.1.6 - # via - # -c requirements/edx-sandbox/../constraints.txt - # nltk +click==8.2.1 + # via nltk codejail-includes==1.0.0 # via -r requirements/edx-sandbox/base.in -contourpy==1.3.0 +contourpy==1.3.2 # via matplotlib -cryptography==43.0.3 +cryptography==45.0.3 # via -r requirements/edx-sandbox/base.in cycler==0.12.1 # via matplotlib -fonttools==4.54.1 +fonttools==4.58.1 # via matplotlib -joblib==1.4.2 +joblib==1.5.1 # via nltk -kiwisolver==1.4.7 +kiwisolver==1.4.8 # via matplotlib -lxml[html-clean,html_clean]==5.3.0 +lxml[html-clean,html_clean]==5.3.2 # via + # -c requirements/edx-sandbox/../constraints.txt # -r requirements/edx-sandbox/base.in # lxml-html-clean # openedx-calc -lxml-html-clean==0.3.1 +lxml-html-clean==0.4.2 # via lxml markupsafe==3.0.2 # via # chem # openedx-calc -matplotlib==3.9.2 +matplotlib==3.10.3 # via -r requirements/edx-sandbox/base.in mpmath==1.3.0 # via sympy -networkx==3.4.2 +networkx==3.5 # via -r requirements/edx-sandbox/base.in nltk==3.9.1 # via @@ -55,15 +54,15 @@ numpy==1.26.4 # matplotlib # openedx-calc # scipy -openedx-calc==3.1.2 +openedx-calc==4.0.2 # via -r requirements/edx-sandbox/base.in -packaging==24.1 +packaging==25.0 # via matplotlib -pillow==11.0.0 +pillow==11.2.1 # via matplotlib pycparser==2.22 # via cffi -pyparsing==3.2.0 +pyparsing==3.2.3 # via # -r requirements/edx-sandbox/base.in # chem @@ -73,20 +72,19 @@ python-dateutil==2.9.0.post0 # via matplotlib random2==1.0.2 # via -r requirements/edx-sandbox/base.in -regex==2024.9.11 +regex==2024.11.6 # via nltk -scipy==1.14.1 +scipy==1.15.3 # via # -r requirements/edx-sandbox/base.in # chem - # openedx-calc -six==1.16.0 +six==1.17.0 # via # codejail-includes # python-dateutil -sympy==1.13.3 +sympy==1.14.0 # via # -r requirements/edx-sandbox/base.in # openedx-calc -tqdm==4.66.6 +tqdm==4.67.1 # via nltk diff --git a/requirements/edx-sandbox/py38.txt b/requirements/edx-sandbox/py38.txt deleted file mode 100644 index 5164c3975a..0000000000 --- a/requirements/edx-sandbox/py38.txt +++ /dev/null @@ -1,4 +0,0 @@ -# This file is a temporary compatibility wrapper around quince.txt. -# It will be removed before Sumac. - --r releases/quince.txt diff --git a/requirements/edx-sandbox/releases/teak.txt b/requirements/edx-sandbox/releases/teak.txt new file mode 100644 index 0000000000..9c04897f39 --- /dev/null +++ b/requirements/edx-sandbox/releases/teak.txt @@ -0,0 +1,89 @@ +# +# This file is autogenerated by pip-compile with Python 3.11 +# by the following command: +# +# make upgrade +# +cffi==1.17.1 + # via cryptography +chem==1.3.0 + # via -r requirements/edx-sandbox/base.in +click==8.1.8 + # via nltk +codejail-includes==1.0.0 + # via -r requirements/edx-sandbox/base.in +contourpy==1.3.2 + # via matplotlib +cryptography==44.0.2 + # via -r requirements/edx-sandbox/base.in +cycler==0.12.1 + # via matplotlib +fonttools==4.57.0 + # via matplotlib +joblib==1.4.2 + # via nltk +kiwisolver==1.4.8 + # via matplotlib +lxml[html-clean,html_clean]==5.3.2 + # via + # -r requirements/edx-sandbox/base.in + # lxml-html-clean + # openedx-calc +lxml-html-clean==0.4.2 + # via lxml +markupsafe==3.0.2 + # via + # chem + # openedx-calc +matplotlib==3.10.1 + # via -r requirements/edx-sandbox/base.in +mpmath==1.3.0 + # via sympy +networkx==3.4.2 + # via -r requirements/edx-sandbox/base.in +nltk==3.9.1 + # via + # -r requirements/edx-sandbox/base.in + # chem +numpy==1.26.4 + # via + # -c requirements/edx-sandbox/../constraints.txt + # chem + # contourpy + # matplotlib + # openedx-calc + # scipy +openedx-calc==4.0.2 + # via -r requirements/edx-sandbox/base.in +packaging==25.0 + # via matplotlib +pillow==11.2.1 + # via matplotlib +pycparser==2.22 + # via cffi +pyparsing==3.2.3 + # via + # -r requirements/edx-sandbox/base.in + # chem + # matplotlib + # openedx-calc +python-dateutil==2.9.0.post0 + # via matplotlib +random2==1.0.2 + # via -r requirements/edx-sandbox/base.in +regex==2024.11.6 + # via nltk +scipy==1.15.2 + # via + # -r requirements/edx-sandbox/base.in + # chem +six==1.17.0 + # via + # codejail-includes + # python-dateutil +sympy==1.13.3 + # via + # -r requirements/edx-sandbox/base.in + # openedx-calc +tqdm==4.67.1 + # via nltk diff --git a/requirements/edx/assets.txt b/requirements/edx/assets.txt index 6c3e1a4151..6288377f63 100644 --- a/requirements/edx/assets.txt +++ b/requirements/edx/assets.txt @@ -4,15 +4,13 @@ # # make upgrade # -click==8.1.6 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/assets.in +click==8.2.1 + # via -r requirements/edx/assets.in libsass==0.10.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/assets.in nodeenv==1.9.1 # via -r requirements/edx/assets.in -six==1.16.0 +six==1.17.0 # via libsass diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index ab33781c6b..debd45915f 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -8,23 +8,19 @@ # via -r requirements/edx/github.in acid-xblock==0.4.1 # via -r requirements/edx/kernel.in -aiohappyeyeballs==2.4.3 +aiohappyeyeballs==2.6.1 # via aiohttp -aiohttp==3.10.10 +aiohttp==3.12.8 # via # geoip2 # openai -aiosignal==1.3.1 +aiosignal==1.3.2 # via aiohttp -algoliasearch==3.0.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/bundled.in -amqp==5.2.0 +amqp==5.3.1 # via kombu analytics-python==1.4.post1 # via -r requirements/edx/kernel.in -aniso8601==9.0.1 +aniso8601==10.0.1 # via edx-tincan-py35 annotated-types==0.7.0 # via pydantic @@ -37,7 +33,7 @@ asgiref==3.8.1 # django-countries asn1crypto==1.5.1 # via snowflake-connector-python -attrs==24.2.0 +attrs==25.3.0 # via # -r requirements/edx/kernel.in # aiohttp @@ -47,20 +43,22 @@ attrs==24.2.0 # openedx-events # openedx-learning # referencing -babel==2.16.0 +babel==2.17.0 # via # -r requirements/edx/kernel.in # enmerkar # enmerkar-underscore backoff==1.10.0 # via analytics-python -bcrypt==4.2.0 +bcrypt==4.3.0 # via paramiko -beautifulsoup4==4.12.3 - # via pynliner +beautifulsoup4==4.13.4 + # via + # openedx-forum + # pynliner billiard==4.2.1 # via celery -bleach[css]==6.1.0 +bleach[css]==6.2.0 # via # edx-enterprise # lti-consumer-xblock @@ -70,26 +68,30 @@ bleach[css]==6.1.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/kernel.in -boto3==1.35.50 +boto3==1.38.29 # via # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 -botocore==1.35.50 + # snowflake-connector-python +botocore==1.38.29 # via # -r requirements/edx/kernel.in # boto3 # s3transfer + # snowflake-connector-python bridgekeeper==0.9 # via -r requirements/edx/kernel.in -cachecontrol==0.14.0 +cachecontrol==0.14.3 # via firebase-admin -cachetools==5.5.0 - # via google-auth +cachetools==5.5.2 + # via + # edxval + # google-auth camel-converter[pydantic]==4.0.1 # via meilisearch -celery==5.4.0 +celery==5.5.3 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -97,13 +99,12 @@ celery==5.4.0 # django-user-tasks # edx-celeryutils # edx-enterprise + # enterprise-integrated-channels # event-tracking # openedx-learning -certifi==2024.8.30 +certifi==2025.4.26 # via - # -r requirements/edx/paver.txt # elasticsearch - # py2neo # requests # snowflake-connector-python cffi==1.17.1 @@ -113,17 +114,14 @@ cffi==1.17.1 # snowflake-connector-python chardet==5.2.0 # via pysrt -charset-normalizer==2.0.12 +charset-normalizer==3.4.2 # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/paver.txt # requests # snowflake-connector-python -chem==1.3.0 +chem==2.0.0 # via -r requirements/edx/kernel.in -click==8.1.6 +click==8.2.1 # via - # -c requirements/edx/../constraints.txt # celery # click-didyoumean # click-plugins @@ -138,7 +136,7 @@ click-plugins==1.1.1 # via celery click-repl==0.3.0 # via celery -code-annotations==1.8.0 +code-annotations==2.3.0 # via # edx-enterprise # edx-toggles @@ -146,13 +144,12 @@ codejail-includes==1.0.0 # via -r requirements/edx/kernel.in crowdsourcehinter-xblock==0.8 # via -r requirements/edx/bundled.in -cryptography==43.0.3 +cryptography==45.0.3 # via # -r requirements/edx/kernel.in # django-fernet-fields-v2 # edx-enterprise # jwcrypto - # optimizely-sdk # paramiko # pgpy # pyjwt @@ -168,12 +165,13 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -django==4.2.16 +django==4.2.22 # via # -c requirements/edx/../common_constraints.txt # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # django-appconf + # django-autocomplete-light # django-celery-results # django-classy-tags # django-config-models @@ -190,6 +188,7 @@ django==4.2.16 # django-push-notifications # django-sekizai # django-ses + # django-simple-history # django-statici18n # django-storages # django-user-tasks @@ -221,11 +220,11 @@ django==4.2.16 # edx-search # edx-submissions # edx-toggles - # edx-token-utils # edx-when # edxval # enmerkar # enmerkar-underscore + # enterprise-integrated-channels # event-tracking # help-tokens # jsonfield @@ -234,27 +233,31 @@ django==4.2.16 # openedx-django-wiki # openedx-events # openedx-filters + # openedx-forum # openedx-learning # ora2 # social-auth-app-django # super-csv # xblock-google-drive # xss-utils -django-appconf==1.0.6 +django-appconf==1.1.0 # via django-statici18n -django-cache-memoize==0.2.0 +django-autocomplete-light==3.12.1 + # via -r requirements/edx/kernel.in +django-cache-memoize==0.2.1 # via edx-enterprise -django-celery-results==2.5.1 +django-celery-results==2.6.0 # via -r requirements/edx/kernel.in django-classy-tags==4.1.0 # via django-sekizai -django-config-models==2.7.0 +django-config-models==2.9.0 # via # -r requirements/edx/kernel.in # edx-enterprise # edx-name-affirmation + # enterprise-integrated-channels # lti-consumer-xblock -django-cors-headers==4.5.0 +django-cors-headers==4.7.0 # via -r requirements/edx/kernel.in django-countries==7.6.1 # via @@ -270,8 +273,10 @@ django-crum==0.7.9 # edx-toggles # super-csv django-fernet-fields-v2==0.9 - # via edx-enterprise -django-filter==24.3 + # via + # edx-enterprise + # enterprise-integrated-channels +django-filter==25.1 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -281,7 +286,7 @@ django-ipware==7.0.1 # -r requirements/edx/kernel.in # edx-enterprise # edx-proctoring -django-js-asset==2.2.0 +django-js-asset==3.1.2 # via django-mptt django-method-override==1.0.4 # via -r requirements/edx/kernel.in @@ -301,26 +306,30 @@ django-model-utils==5.0.0 # edx-submissions # edx-when # edxval + # enterprise-integrated-channels # ora2 # super-csv -django-mptt==0.16.0 +django-mptt==0.17.0 # via # -r requirements/edx/kernel.in # openedx-django-wiki -django-multi-email-field==0.7.0 +django-multi-email-field==0.8.0 # via edx-enterprise -django-mysql==4.14.0 +django-mysql==4.17.0 # via -r requirements/edx/kernel.in django-oauth-toolkit==1.7.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-enterprise -django-object-actions==4.3.0 - # via edx-enterprise -django-pipeline==3.1.0 + # enterprise-integrated-channels +django-object-actions==5.0.0 + # via + # edx-enterprise + # enterprise-integrated-channels +django-pipeline==4.0.0 # via -r requirements/edx/kernel.in -django-push-notifications==3.1.0 +django-push-notifications==3.2.1 # via edx-ace django-ratelimit==4.1.0 # via -r requirements/edx/kernel.in @@ -328,31 +337,31 @@ django-sekizai==4.1.0 # via # -r requirements/edx/kernel.in # openedx-django-wiki -django-ses==4.2.0 +django-ses==4.4.0 # via -r requirements/edx/bundled.in -django-simple-history==3.4.0 +django-simple-history==3.8.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-enterprise # edx-name-affirmation # edx-organizations # edx-proctoring + # enterprise-integrated-channels # ora2 -django-statici18n==2.5.0 +django-statici18n==2.6.0 # via # -r requirements/edx/kernel.in # lti-consumer-xblock # xblock-drag-and-drop-v2 # xblock-poll -django-storages==1.14.3 + # xblocks-contrib +django-storages==1.14.6 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edxval -django-user-tasks==3.2.0 +django-user-tasks==3.4.1 # via -r requirements/edx/kernel.in -django-waffle==4.1.0 +django-waffle==4.2.0 # via # -r requirements/edx/kernel.in # edx-django-utils @@ -382,63 +391,58 @@ djangorestframework==3.14.0 # edx-organizations # edx-proctoring # edx-submissions + # openedx-forum # openedx-learning # ora2 # super-csv djangorestframework-xml==2.0.0 # via edx-enterprise dnspython==2.7.0 - # via - # -r requirements/edx/paver.txt - # pymongo -done-xblock==2.4.0 + # via pymongo +done-xblock==2.5.0 # via -r requirements/edx/bundled.in drf-jwt==1.19.2 # via edx-drf-extensions -drf-spectacular==0.27.2 +drf-spectacular==0.28.0 # via -r requirements/edx/kernel.in -drf-yasg==1.21.8 +drf-yasg==1.21.10 # via # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.3 +edx-ace==1.15.0 # via -r requirements/edx/kernel.in -edx-api-doc-tools==2.0.0 +edx-api-doc-tools==2.1.0 # via # -r requirements/edx/kernel.in # edx-name-affirmation -edx-auth-backends==4.4.0 +edx-auth-backends==4.5.0 # via -r requirements/edx/kernel.in -edx-braze-client==0.2.5 - # via - # -r requirements/edx/bundled.in - # edx-enterprise -edx-bulk-grades==1.1.0 +edx-bulk-grades==1.2.0 # via # -r requirements/edx/kernel.in # staff-graded-xblock -edx-ccx-keys==1.3.0 +edx-ccx-keys==2.0.2 # via # -r requirements/edx/kernel.in # lti-consumer-xblock # openedx-events -edx-celeryutils==1.3.0 +edx-celeryutils==1.4.0 # via # -r requirements/edx/kernel.in # edx-name-affirmation # super-csv -edx-codejail==3.5.1 +edx-codejail==4.0.0 # via -r requirements/edx/kernel.in -edx-completion==4.7.3 +edx-completion==4.9 # via -r requirements/edx/kernel.in -edx-django-release-util==1.4.0 +edx-django-release-util==1.5.0 # via # -r requirements/edx/kernel.in # edx-submissions # edxval -edx-django-sites-extensions==4.2.0 +edx-django-sites-extensions==5.1.0 # via -r requirements/edx/kernel.in -edx-django-utils==7.0.0 +edx-django-utils==8.0.0 # via # -r requirements/edx/kernel.in # django-config-models @@ -451,11 +455,12 @@ edx-django-utils==7.0.0 # edx-rest-api-client # edx-toggles # edx-when + # enterprise-integrated-channels # event-tracking # openedx-events # ora2 # super-csv -edx-drf-extensions==10.5.0 +edx-drf-extensions==10.6.0 # via # -r requirements/edx/kernel.in # edx-completion @@ -466,28 +471,28 @@ edx-drf-extensions==10.5.0 # edx-rbac # edx-when # edxval + # enterprise-integrated-channels # openedx-learning -edx-enterprise==4.32.2 +edx-enterprise==6.2.13 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in -edx-event-bus-kafka==6.0.0 +edx-event-bus-kafka==6.1.0 # via -r requirements/edx/kernel.in -edx-event-bus-redis==0.5.1 +edx-event-bus-redis==0.6.1 # via -r requirements/edx/kernel.in -edx-i18n-tools==1.5.0 +edx-i18n-tools==1.9.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/bundled.in # ora2 -edx-milestones==0.6.0 + # xblocks-contrib +edx-milestones==1.1.0 # via -r requirements/edx/kernel.in edx-name-affirmation==3.0.1 # via -r requirements/edx/kernel.in -edx-opaque-keys[django]==2.11.0 +edx-opaque-keys[django]==3.0.0 # via # -r requirements/edx/kernel.in - # -r requirements/edx/paver.txt # edx-bulk-grades # edx-ccx-keys # edx-completion @@ -497,33 +502,43 @@ edx-opaque-keys[django]==2.11.0 # edx-organizations # edx-proctoring # edx-when + # enterprise-integrated-channels # lti-consumer-xblock # openedx-events + # openedx-filters # ora2 + # xblocks-contrib edx-organizations==6.13.0 # via -r requirements/edx/kernel.in -edx-proctoring==4.18.3 +edx-proctoring==5.2.0 # via # -r requirements/edx/kernel.in # edx-proctoring-proctortrack -edx-rbac==1.10.0 - # via edx-enterprise -edx-rest-api-client==6.0.0 +edx-rbac==2.1.0 + # via + # edx-enterprise + # enterprise-integrated-channels +edx-rest-api-client==6.2.0 # via # -r requirements/edx/kernel.in # edx-enterprise # edx-proctoring -edx-search==4.1.1 - # via -r requirements/edx/kernel.in -edx-sga==0.25.0 + # enterprise-integrated-channels +edx-search==4.1.3 + # via + # -r requirements/edx/kernel.in + # openedx-forum +edx-sga==0.26.0 # via -r requirements/edx/bundled.in -edx-submissions==3.8.2 +edx-submissions==3.11.1 # via # -r requirements/edx/kernel.in # ora2 -edx-tincan-py35==1.0.0 - # via edx-enterprise -edx-toggles==5.2.0 +edx-tincan-py35==2.0.0 + # via + # edx-enterprise + # enterprise-integrated-channels +edx-toggles==5.3.0 # via # -r requirements/edx/kernel.in # edx-completion @@ -535,36 +550,37 @@ 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.0 +edx-when==3.0.0 # via # -r requirements/edx/kernel.in # edx-proctoring -edxval==2.6.0 +edxval==3.0.0 # via -r requirements/edx/kernel.in elasticsearch==7.9.1 # via # -c requirements/edx/../common_constraints.txt + # -c requirements/edx/../constraints.txt # edx-search + # openedx-forum enmerkar==0.7.1 # via enmerkar-underscore -enmerkar-underscore==2.3.1 +enmerkar-underscore==2.4.0 # via -r requirements/edx/kernel.in -event-tracking==3.0.0 +enterprise-integrated-channels==0.1.13 + # via -r requirements/edx/bundled.in +event-tracking==3.3.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-completion # edx-proctoring # edx-search -fastavro==1.9.7 +fastavro==1.11.1 # via openedx-events -filelock==3.16.1 +filelock==3.18.0 # via snowflake-connector-python -firebase-admin==6.5.0 +firebase-admin==6.8.0 # via edx-ace -frozenlist==1.5.0 +frozenlist==1.6.2 # via # aiohttp # aiosignal @@ -580,20 +596,20 @@ fs-s3fs==0.1.8 # openedx-django-pyfs future==1.0.0 # via pyjwkest -geoip2==4.8.0 +geoip2==5.1.0 # via -r requirements/edx/kernel.in glob2==0.7 # via -r requirements/edx/kernel.in -google-api-core[grpc]==2.22.0 +google-api-core[grpc]==2.25.0 # via # firebase-admin # google-api-python-client # google-cloud-core # google-cloud-firestore # google-cloud-storage -google-api-python-client==2.149.0 +google-api-python-client==2.171.0 # via firebase-admin -google-auth==2.35.0 +google-auth==2.40.2 # via # google-api-core # google-api-python-client @@ -603,33 +619,33 @@ google-auth==2.35.0 # google-cloud-storage google-auth-httplib2==0.2.0 # via google-api-python-client -google-cloud-core==2.4.1 +google-cloud-core==2.4.3 # via # google-cloud-firestore # google-cloud-storage -google-cloud-firestore==2.19.0 +google-cloud-firestore==2.21.0 # via firebase-admin -google-cloud-storage==2.18.2 +google-cloud-storage==3.1.0 # via firebase-admin -google-crc32c==1.6.0 +google-crc32c==1.7.1 # via # google-cloud-storage # google-resumable-media google-resumable-media==2.7.2 # via google-cloud-storage -googleapis-common-protos==1.65.0 +googleapis-common-protos==1.70.0 # via # google-api-core # grpcio-status -grpcio==1.67.0 +grpcio==1.72.1 # via # google-api-core # grpcio-status -grpcio-status==1.67.0 +grpcio-status==1.72.1 # via google-api-core gunicorn==23.0.0 # via -r requirements/edx/kernel.in -help-tokens==2.4.0 +help-tokens==3.2.0 # via -r requirements/edx/kernel.in html5lib==1.1 # via @@ -639,34 +655,31 @@ httplib2==0.22.0 # via # google-api-python-client # google-auth-httplib2 -icalendar==6.0.1 +icalendar==6.3.1 # via -r requirements/edx/kernel.in idna==3.10 # via - # -r requirements/edx/paver.txt # optimizely-sdk # requests # snowflake-connector-python # yarl -importlib-metadata==8.5.0 +importlib-metadata==8.7.0 # via -r requirements/edx/kernel.in inflection==0.5.1 # via # drf-spectacular # drf-yasg -interchange==2021.0.4 - # via py2neo ipaddress==1.0.23 # via -r requirements/edx/kernel.in isodate==0.7.2 # via python3-saml -jinja2==3.1.4 +jinja2==3.1.6 # via code-annotations jmespath==1.0.1 # via # boto3 # botocore -joblib==1.4.2 +joblib==1.5.1 # via nltk jsondiff==2.2.1 # via edx-enterprise @@ -677,39 +690,36 @@ jsonfield==3.1.0 # edx-enterprise # edx-proctoring # edx-submissions + # enterprise-integrated-channels # lti-consumer-xblock # ora2 -jsonschema==4.23.0 +jsonschema==4.24.0 # via # drf-spectacular # optimizely-sdk -jsonschema-specifications==2024.10.1 +jsonschema-specifications==2025.4.1 # via jsonschema jwcrypto==1.5.6 # via # django-oauth-toolkit # pylti1p3 -kombu==5.4.2 +kombu==5.5.4 # via celery laboratory==1.0.2 # via -r requirements/edx/kernel.in lazy==1.6 # via - # -r requirements/edx/paver.txt # acid-xblock # lti-consumer-xblock # ora2 # xblock -libsass==0.10.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/paver.txt loremipsum==1.0.5 # via ora2 -lti-consumer-xblock==9.11.3 +lti-consumer-xblock==9.14.0 # via -r requirements/edx/kernel.in -lxml[html-clean,html_clean]==5.3.0 +lxml[html-clean,html_clean]==5.3.2 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-i18n-tools # edxval @@ -721,62 +731,58 @@ lxml[html-clean,html_clean]==5.3.0 # python3-saml # xblock # xmlsec -lxml-html-clean==0.3.1 +lxml-html-clean==0.4.2 # via lxml mailsnake==1.6.4 # via -r requirements/edx/bundled.in -mako==1.3.6 +mako==1.3.10 # via # -r requirements/edx/kernel.in # acid-xblock # lti-consumer-xblock # xblock # xblock-utils -markdown==3.3.7 +markdown==3.8 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # openedx-django-wiki # staff-graded-xblock # xblock-poll markupsafe==3.0.2 # via - # -r requirements/edx/paver.txt # chem # jinja2 # mako # openedx-calc # xblock -maxminddb==2.6.2 +maxminddb==2.7.0 # via geoip2 -meilisearch==0.31.6 +meilisearch==0.34.1 # via # -r requirements/edx/kernel.in # edx-search -mock==5.1.0 - # via -r requirements/edx/paver.txt mongoengine==0.29.1 # via -r requirements/edx/kernel.in monotonic==1.6 - # via - # analytics-python - # py2neo -more-itertools==10.5.0 + # via analytics-python +more-itertools==10.7.0 # via cssutils mpmath==1.3.0 # via sympy msgpack==1.1.0 # via cachecontrol -multidict==6.1.0 +multidict==6.4.4 # via # aiohttp # yarl -mysqlclient==2.2.5 - # via -r requirements/edx/kernel.in -newrelic==10.2.0 - # via edx-django-utils -nh3==0.2.18 - # via -r requirements/edx/kernel.in +mysqlclient==2.2.7 + # via + # -r requirements/edx/kernel.in + # openedx-forum +nh3==0.2.21 + # via + # -r requirements/edx/kernel.in + # xblocks-contrib nltk==3.9.1 # via chem nodeenv==1.9.1 @@ -795,25 +801,30 @@ oauthlib==3.2.2 # lti-consumer-xblock # requests-oauthlib # social-auth-core + # xblocks-contrib olxcleaner==0.3.0 # via -r requirements/edx/kernel.in openai==0.28.1 # via # -c requirements/edx/../constraints.txt # edx-enterprise -openedx-atlas==0.6.2 +openedx-atlas==0.7.0 + # via + # -r requirements/edx/kernel.in + # enterprise-integrated-channels + # openedx-forum +openedx-calc==4.0.2 # via -r requirements/edx/kernel.in -openedx-calc==3.1.2 - # via -r requirements/edx/kernel.in -openedx-django-pyfs==3.7.0 +openedx-django-pyfs==3.8.0 # via # lti-consumer-xblock # xblock + # xblocks-contrib openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in -openedx-django-wiki==2.1.0 +openedx-django-wiki==3.1.1 # via -r requirements/edx/kernel.in -openedx-events==9.15.0 +openedx-events==10.2.1 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -822,38 +833,33 @@ openedx-events==9.15.0 # edx-name-affirmation # event-tracking # ora2 -openedx-filters==1.11.0 +openedx-filters==2.1.0 # via # -r requirements/edx/kernel.in # lti-consumer-xblock # ora2 -openedx-learning==0.17.0 +openedx-forum==0.3.0 + # via -r requirements/edx/kernel.in +openedx-learning==0.27.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in -openedx-mongodbproxy==0.2.2 - # via -r requirements/edx/kernel.in -optimizely-sdk==4.1.1 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/bundled.in -ora2==6.14.0 +optimizely-sdk==5.2.0 # via -r requirements/edx/bundled.in -packaging==24.1 +ora2==6.16.3 + # via -r requirements/edx/bundled.in +packaging==25.0 # via # drf-yasg # gunicorn - # py2neo + # kombu # snowflake-connector-python -pansi==2020.7.3 - # via py2neo -paramiko==3.5.0 +paramiko==3.5.1 # via edx-enterprise path==16.11.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in - # -r requirements/edx/paver.txt # edx-i18n-tools # path-py path-py==12.5.0 @@ -861,80 +867,69 @@ path-py==12.5.0 # edx-enterprise # ora2 # staff-graded-xblock -paver==1.3.4 - # via -r requirements/edx/paver.txt -pbr==6.1.0 - # via - # -r requirements/edx/paver.txt - # stevedore +pbr==6.1.1 + # via stevedore pgpy==0.6.0 # via edx-enterprise piexif==1.1.3 # via -r requirements/edx/kernel.in -pillow==11.0.0 +pillow==11.2.1 # via # -r requirements/edx/kernel.in # edx-enterprise # edx-organizations # edxval -platformdirs==4.3.6 +platformdirs==4.3.8 # via snowflake-connector-python polib==1.2.0 # via edx-i18n-tools -prompt-toolkit==3.0.48 +prompt-toolkit==3.0.51 # via click-repl -propcache==0.2.0 - # via yarl -proto-plus==1.25.0 +propcache==0.3.1 + # via + # aiohttp + # yarl +proto-plus==1.26.1 # via # google-api-core # google-cloud-firestore -protobuf==5.28.3 +protobuf==6.31.1 # via # google-api-core # google-cloud-firestore # googleapis-common-protos # grpcio-status # proto-plus -psutil==6.1.0 +psutil==7.0.0 # via - # -r requirements/edx/paver.txt + # -r requirements/edx/kernel.in # edx-django-utils -py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/bundled.in pyasn1==0.6.1 # via # pgpy # pyasn1-modules # rsa -pyasn1-modules==0.4.1 +pyasn1-modules==0.4.2 # via google-auth pycountry==24.6.1 # via -r requirements/edx/kernel.in pycparser==2.22 # via cffi -pycryptodomex==3.21.0 +pycryptodomex==3.23.0 # via # -r requirements/edx/kernel.in # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.9.2 +pydantic==2.11.5 # via camel-converter -pydantic-core==2.23.4 +pydantic-core==2.33.2 # via pydantic -pygments==2.18.0 - # via - # -r requirements/edx/bundled.in - # py2neo pyjwkest==1.4.2 # via # -r requirements/edx/kernel.in - # edx-token-utils # lti-consumer-xblock -pyjwt[crypto]==2.9.0 +pyjwt[crypto]==2.10.1 # via # -r requirements/edx/kernel.in # drf-jwt @@ -943,6 +938,7 @@ pyjwt[crypto]==2.9.0 # edx-proctoring # edx-rest-api-client # firebase-admin + # lti-consumer-xblock # pylti1p3 # snowflake-connector-python # social-auth-core @@ -951,27 +947,24 @@ pylatexenc==2.10 pylti1p3==2.0.0 # via -r requirements/edx/kernel.in pymemcache==4.0.0 - # via -r requirements/edx/paver.txt + # via -r requirements/edx/kernel.in pymongo==4.4.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in - # -r requirements/edx/paver.txt # edx-opaque-keys # event-tracking # mongoengine - # openedx-mongodbproxy + # openedx-forum pynacl==1.5.0 # via # edx-django-utils # paramiko pynliner==0.8.0 # via -r requirements/edx/kernel.in -pyopenssl==24.2.1 - # via - # optimizely-sdk - # snowflake-connector-python -pyparsing==3.2.0 +pyopenssl==25.1.0 + # via snowflake-connector-python +pyparsing==3.2.3 # via # chem # httplib2 @@ -997,11 +990,9 @@ python-dateutil==2.9.0.post0 # xblock python-ipware==3.0.0 # via django-ipware -python-memcached==1.62 - # via -r requirements/edx/paver.txt python-slugify==8.0.4 # via code-annotations -python-swiftclient==4.6.0 +python-swiftclient==4.8.0 # via ora2 python3-openid==3.2.0 ; python_version >= "3" # via @@ -1009,7 +1000,7 @@ python3-openid==3.2.0 ; python_version >= "3" # social-auth-core python3-saml==1.16.0 # via -r requirements/edx/kernel.in -pytz==2024.2 +pytz==2025.2 # via # -r requirements/edx/kernel.in # djangorestframework @@ -1019,9 +1010,9 @@ pytz==2024.2 # edx-proctoring # edx-submissions # edx-tincan-py35 + # enterprise-integrated-channels # event-tracking # fs - # interchange # olxcleaner # ora2 # snowflake-connector-python @@ -1040,22 +1031,20 @@ pyyaml==6.0.2 # xblock random2==1.0.2 # via -r requirements/edx/kernel.in -recommender-xblock==3.0.0 +recommender-xblock==3.1.0 # via -r requirements/edx/bundled.in -redis==5.2.0 +redis==6.2.0 # via # -r requirements/edx/kernel.in # walrus -referencing==0.35.1 +referencing==0.36.2 # via # jsonschema # jsonschema-specifications -regex==2024.9.11 +regex==2024.11.6 # via nltk requests==2.32.3 # via - # -r requirements/edx/paver.txt - # algoliasearch # analytics-python # cachecontrol # django-oauth-toolkit @@ -1063,12 +1052,14 @@ requests==2.32.3 # edx-drf-extensions # edx-enterprise # edx-rest-api-client + # enterprise-integrated-channels # geoip2 # google-api-core # google-cloud-storage # mailsnake # meilisearch # openai + # openedx-forum # optimizely-sdk # pyjwkest # pylti1p3 @@ -1083,11 +1074,11 @@ requests-oauthlib==2.0.0 # via # -r requirements/edx/kernel.in # social-auth-core -rpds-py==0.20.0 +rpds-py==0.25.1 # via # jsonschema # referencing -rsa==4.9 +rsa==4.9.1 # via google-auth rules==3.5 # via @@ -1095,31 +1086,27 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-learning -s3transfer==0.10.3 +s3transfer==0.13.0 # via boto3 sailthru-client==2.2.3 # via edx-ace -scipy==1.14.1 - # via - # chem - # openedx-calc +scipy==1.15.3 + # via chem semantic-version==2.10.0 # via edx-drf-extensions -shapely==2.0.6 +shapely==2.1.1 # via -r requirements/edx/kernel.in -simplejson==3.19.3 +simplejson==3.20.1 # via # -r requirements/edx/kernel.in # sailthru-client # super-csv # xblock # xblock-utils -six==1.16.0 +six==1.17.0 # via # -r requirements/edx/kernel.in - # -r requirements/edx/paver.txt # analytics-python - # bleach # codejail-includes # crowdsourcehinter-xblock # edx-ace @@ -1133,12 +1120,6 @@ six==1.16.0 # fs # fs-s3fs # html5lib - # interchange - # libsass - # optimizely-sdk - # pansi - # paver - # py2neo # pyjwkest # python-dateutil slumber==0.7.1 @@ -1146,7 +1127,8 @@ slumber==0.7.1 # -r requirements/edx/kernel.in # edx-bulk-grades # edx-enterprise -snowflake-connector-python==3.12.3 + # enterprise-integrated-channels +snowflake-connector-python==3.15.0 # via edx-enterprise social-auth-app-django==5.4.1 # via @@ -1155,6 +1137,7 @@ social-auth-app-django==5.4.1 # edx-auth-backends social-auth-core==4.5.4 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in # edx-auth-backends # social-auth-app-django @@ -1166,69 +1149,76 @@ sortedcontainers==2.4.0 # via # -r requirements/edx/kernel.in # snowflake-connector-python -soupsieve==2.6 +soupsieve==2.7 # via beautifulsoup4 -sqlparse==0.5.1 +sqlparse==0.5.3 # via django -staff-graded-xblock==2.3.0 +staff-graded-xblock==3.1.0 # via -r requirements/edx/bundled.in -stevedore==5.3.0 +stevedore==5.4.1 # via # -r requirements/edx/kernel.in - # -r requirements/edx/paver.txt # code-annotations # edx-ace # edx-django-utils # edx-enterprise # edx-opaque-keys -super-csv==3.2.0 +super-csv==4.1.0 # via edx-bulk-grades -sympy==1.13.3 +sympy==1.14.0 # via openedx-calc testfixtures==8.3.0 # via edx-enterprise text-unidecode==1.3 # via python-slugify -tinycss2==1.2.1 +tinycss2==1.4.0 # via bleach tomlkit==0.13.2 - # via snowflake-connector-python -tqdm==4.66.6 + # via + # openedx-learning + # snowflake-connector-python +tqdm==4.67.1 # via # nltk # openai -typing-extensions==4.12.2 +typing-extensions==4.14.0 # via - # -r requirements/edx/paver.txt + # beautifulsoup4 # django-countries # edx-opaque-keys # jwcrypto # pydantic # pydantic-core # pylti1p3 + # pyopenssl + # referencing # snowflake-connector-python -tzdata==2024.2 + # typing-inspection +typing-inspection==0.4.1 + # via pydantic +tzdata==2025.2 # via - # celery # icalendar # kombu unicodecsv==0.14.1 # via # -r requirements/edx/kernel.in # edx-enterprise -uritemplate==4.1.1 + # enterprise-integrated-channels +unicodeit==0.7.5 + # via -r requirements/edx/kernel.in +uritemplate==4.2.0 # via # drf-spectacular # drf-yasg # google-api-python-client urllib3==2.2.3 # via - # -r requirements/edx/paver.txt + # -c requirements/edx/../common_constraints.txt # botocore # elasticsearch - # py2neo # requests -user-util==1.1.0 +user-util==2.0.0 # via -r requirements/edx/kernel.in vine==5.1.0 # via @@ -1239,11 +1229,9 @@ voluptuous==0.15.2 # via ora2 walrus==0.9.4 # via edx-event-bus-redis -watchdog==5.0.3 - # via -r requirements/edx/paver.txt wcwidth==0.2.13 # via prompt-toolkit -web-fragments==2.2.0 +web-fragments==3.1.0 # via # -r requirements/edx/kernel.in # crowdsourcehinter-xblock @@ -1260,9 +1248,11 @@ webob==1.8.9 # via # -r requirements/edx/kernel.in # xblock -wrapt==1.16.0 - # via -r requirements/edx/paver.txt -xblock[django]==5.1.0 +wheel==0.45.1 + # via django-pipeline +wrapt==1.17.2 + # via -r requirements/edx/kernel.in +xblock[django]==5.2.0 # via # -r requirements/edx/kernel.in # acid-xblock @@ -1277,23 +1267,28 @@ xblock[django]==5.1.0 # xblock-drag-and-drop-v2 # xblock-google-drive # xblock-utils -xblock-drag-and-drop-v2==4.0.3 + # xblocks-contrib +xblock-drag-and-drop-v2==5.0.2 # via -r requirements/edx/bundled.in -xblock-google-drive==0.7.0 +xblock-google-drive==0.8.1 # via -r requirements/edx/bundled.in -xblock-poll==1.14.0 +xblock-poll==1.15.1 # via -r requirements/edx/bundled.in xblock-utils==4.0.0 # via # edx-sga # xblock-poll +xblocks-contrib==0.4.0 + # via -r requirements/edx/bundled.in xmlsec==1.3.14 - # via python3-saml -xss-utils==0.6.0 + # via + # -c requirements/edx/../constraints.txt + # python3-saml +xss-utils==0.8.0 # via -r requirements/edx/kernel.in -yarl==1.17.0 +yarl==1.20.0 # via aiohttp -zipp==3.20.2 +zipp==3.22.0 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/edx/bundled.in b/requirements/edx/bundled.in index 5a46c710a6..426dc5de3f 100644 --- a/requirements/edx/bundled.in +++ b/requirements/edx/bundled.in @@ -20,19 +20,11 @@ # 4. If the package is not needed in production, add it to another file such # as development.in or testing.in instead. -# Driver for converting Python modulestore structures to Neo4j's schema (for Coursegraph). -# Using the fork because official package has been removed from PyPI/GitHub -# Follow up issue to remove this fork: https://github.com/openedx/edx-platform/issues/33456 -https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz - -pygments # Used to support colors in paver command output # i18n_tool is needed at build time for pulling translations edx-i18n-tools>=0.4.6 # Commands for developers and translators to extract, compile and validate translations ## Third party integrations -algoliasearch # Algolia’s API client for indexed searching django-ses # Django email backend for Amazon’s Simple Email Service -edx-braze-client # a customer engagement platform used for edx.org mailsnake # MailChimp API; used for two management commands in the "mailing" djangoapp optimizely-sdk # Optimizely provides A/B testing and other features, used by edx.org @@ -47,3 +39,8 @@ ora2>=4.5.0 # Open Response Assessment XBlock xblock-poll # Xblock for polling users xblock-drag-and-drop-v2 # Drag and Drop XBlock xblock-google-drive # XBlock for google docs and calendar +xblocks-contrib # Package having multiple core XBlocks, https://github.com/openedx/xblocks-contrib?tab=readme-ov-file#xblocks-being-moved-here + + +## Integrated Channels +enterprise-integrated-channels # Integrated Channels to transmit content metadata and learner data. diff --git a/requirements/edx/coverage.txt b/requirements/edx/coverage.txt index 3635567f89..deccd3faa7 100644 --- a/requirements/edx/coverage.txt +++ b/requirements/edx/coverage.txt @@ -6,15 +6,15 @@ # chardet==5.2.0 # via diff-cover -coverage==7.6.4 +coverage==7.8.2 # via -r requirements/edx/coverage.in -diff-cover==9.2.0 +diff-cover==9.3.2 # via -r requirements/edx/coverage.in -jinja2==3.1.4 +jinja2==3.1.6 # via diff-cover markupsafe==3.0.2 # via jinja2 -pluggy==1.5.0 +pluggy==1.6.0 # via diff-cover -pygments==2.18.0 +pygments==2.19.1 # via diff-cover diff --git a/requirements/edx/development.in b/requirements/edx/development.in index 00c9e533b1..0de91b366f 100644 --- a/requirements/edx/development.in +++ b/requirements/edx/development.in @@ -17,7 +17,7 @@ click # Used for perf_tests utilities in modulestore django-debug-toolbar # A set of panels that display debug information about the current request/response -django-stubs # Typing stubs for Django, so it works with mypy +django-stubs[compatible-mypy] # Typing stubs for Django, so it works with mypy djangorestframework-stubs # Typing stubs for DRF mypy # static type checking pywatchman # More efficient checking for runserver reload trigger events diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index b8812b7261..8f2eda3158 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -16,18 +16,18 @@ acid-xblock==0.4.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -aiohappyeyeballs==2.4.3 +aiohappyeyeballs==2.6.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # aiohttp -aiohttp==3.10.10 +aiohttp==3.12.8 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # geoip2 # openai -aiosignal==1.3.1 +aiosignal==1.3.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -36,12 +36,7 @@ alabaster==1.0.0 # via # -r requirements/edx/doc.txt # sphinx -algoliasearch==3.0.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt -amqp==5.2.0 +amqp==5.3.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -50,7 +45,7 @@ analytics-python==1.4.post1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -aniso8601==9.0.1 +aniso8601==10.0.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -60,9 +55,10 @@ annotated-types==0.7.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # pydantic -anyio==4.6.2.post1 +anyio==4.9.0 # via # -r requirements/edx/testing.txt + # httpcore # starlette appdirs==1.4.4 # via @@ -76,17 +72,20 @@ asgiref==3.8.1 # django # django-cors-headers # django-countries + # django-stubs asn1crypto==1.5.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # snowflake-connector-python -astroid==2.13.5 +astroid==3.3.10 # via + # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # pylint # pylint-celery -attrs==24.2.0 + # sphinx-autoapi +attrs==25.3.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -97,7 +96,7 @@ attrs==24.2.0 # openedx-events # openedx-learning # referencing -babel==2.16.0 +babel==2.17.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -110,15 +109,16 @@ backoff==1.10.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # analytics-python -bcrypt==4.2.0 +bcrypt==4.3.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # paramiko -beautifulsoup4==4.12.3 +beautifulsoup4==4.13.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt + # openedx-forum # pydata-sphinx-theme # pynliner billiard==4.2.1 @@ -126,7 +126,7 @@ billiard==4.2.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # celery -bleach[css]==6.1.0 +bleach[css]==6.2.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -140,19 +140,21 @@ boto==2.49.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -boto3==1.35.50 +boto3==1.38.29 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-ses # fs-s3fs # ora2 -botocore==1.35.50 + # snowflake-connector-python +botocore==1.38.29 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # boto3 # s3transfer + # snowflake-connector-python bridgekeeper==0.9 # via # -r requirements/edx/doc.txt @@ -161,15 +163,16 @@ build==1.2.2.post1 # via # -r requirements/edx/../pip-tools.txt # pip-tools -cachecontrol==0.14.0 +cachecontrol==0.14.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # firebase-admin -cachetools==5.5.0 +cachetools==5.5.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt + # edxval # google-auth # tox camel-converter[pydantic]==4.0.1 @@ -177,7 +180,7 @@ camel-converter[pydantic]==4.0.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # meilisearch -celery==5.4.0 +celery==5.5.3 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -186,14 +189,16 @@ celery==5.4.0 # django-user-tasks # edx-celeryutils # edx-enterprise + # enterprise-integrated-channels # event-tracking # openedx-learning -certifi==2024.8.30 +certifi==2025.4.26 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # elasticsearch - # py2neo + # httpcore + # httpx # requests # snowflake-connector-python cffi==1.17.1 @@ -201,7 +206,6 @@ cffi==1.17.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # cryptography - # pact-python # pynacl # snowflake-connector-python chardet==5.2.0 @@ -211,20 +215,18 @@ chardet==5.2.0 # diff-cover # pysrt # tox -charset-normalizer==2.0.12 +charset-normalizer==3.4.2 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # requests # snowflake-connector-python -chem==1.3.0 +chem==2.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -click==8.1.6 +click==8.2.1 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/../pip-tools.txt # -r requirements/edx/assets.txt # -r requirements/edx/development.in @@ -263,7 +265,7 @@ click-repl==0.3.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # celery -code-annotations==1.8.0 +code-annotations==2.3.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -278,7 +280,7 @@ colorama==0.4.6 # via # -r requirements/edx/testing.txt # tox -coverage[toml]==7.6.4 +coverage[toml]==7.8.2 # via # -r requirements/edx/testing.txt # pytest-cov @@ -286,21 +288,20 @@ crowdsourcehinter-xblock==0.8 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -cryptography==43.0.3 +cryptography==45.0.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-fernet-fields-v2 # edx-enterprise # jwcrypto - # optimizely-sdk # paramiko # pgpy # pyjwt # pyopenssl # snowflake-connector-python # social-auth-core -cssselect==1.2.0 +cssselect==1.3.0 # via # -r requirements/edx/testing.txt # pyquery @@ -323,9 +324,9 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -diff-cover==9.2.0 +diff-cover==9.3.2 # via -r requirements/edx/testing.txt -dill==0.3.9 +dill==0.4.0 # via # -r requirements/edx/testing.txt # pylint @@ -333,13 +334,14 @@ distlib==0.3.9 # via # -r requirements/edx/testing.txt # virtualenv -django==4.2.16 +django==4.2.22 # via # -c requirements/edx/../common_constraints.txt # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-appconf + # django-autocomplete-light # django-celery-results # django-classy-tags # django-config-models @@ -357,6 +359,7 @@ django==4.2.16 # django-push-notifications # django-sekizai # django-ses + # django-simple-history # django-statici18n # django-storages # django-stubs @@ -390,11 +393,11 @@ django==4.2.16 # edx-search # edx-submissions # edx-toggles - # edx-token-utils # edx-when # edxval # enmerkar # enmerkar-underscore + # enterprise-integrated-channels # event-tracking # help-tokens # jsonfield @@ -403,23 +406,28 @@ django==4.2.16 # openedx-django-wiki # openedx-events # openedx-filters + # openedx-forum # openedx-learning # ora2 # social-auth-app-django # super-csv # xblock-google-drive # xss-utils -django-appconf==1.0.6 +django-appconf==1.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-statici18n -django-cache-memoize==0.2.0 +django-autocomplete-light==3.12.1 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt +django-cache-memoize==0.2.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise -django-celery-results==2.5.1 +django-celery-results==2.6.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -428,14 +436,15 @@ django-classy-tags==4.1.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-sekizai -django-config-models==2.7.0 +django-config-models==2.9.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise # edx-name-affirmation + # enterprise-integrated-channels # lti-consumer-xblock -django-cors-headers==4.5.0 +django-cors-headers==4.7.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -454,14 +463,15 @@ django-crum==0.7.9 # edx-rbac # edx-toggles # super-csv -django-debug-toolbar==4.4.6 +django-debug-toolbar==5.2.0 # via -r requirements/edx/development.in django-fernet-fields-v2==0.9 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise -django-filter==24.3 + # enterprise-integrated-channels +django-filter==25.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -473,7 +483,7 @@ django-ipware==7.0.1 # -r requirements/edx/testing.txt # edx-enterprise # edx-proctoring -django-js-asset==2.2.0 +django-js-asset==3.1.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -499,19 +509,20 @@ django-model-utils==5.0.0 # edx-submissions # edx-when # edxval + # enterprise-integrated-channels # ora2 # super-csv -django-mptt==0.16.0 +django-mptt==0.17.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # openedx-django-wiki -django-multi-email-field==0.7.0 +django-multi-email-field==0.8.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise -django-mysql==4.14.0 +django-mysql==4.17.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -521,16 +532,18 @@ django-oauth-toolkit==1.7.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise -django-object-actions==4.3.0 + # enterprise-integrated-channels +django-object-actions==5.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise -django-pipeline==3.1.0 + # enterprise-integrated-channels +django-pipeline==4.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -django-push-notifications==3.1.0 +django-push-notifications==3.2.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -544,45 +557,45 @@ django-sekizai==4.1.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # openedx-django-wiki -django-ses==4.2.0 +django-ses==4.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -django-simple-history==3.4.0 +django-simple-history==3.8.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise # edx-name-affirmation # edx-organizations # edx-proctoring + # enterprise-integrated-channels # ora2 -django-statici18n==2.5.0 +django-statici18n==2.6.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # lti-consumer-xblock # xblock-drag-and-drop-v2 # xblock-poll -django-storages==1.14.3 + # xblocks-contrib +django-storages==1.14.6 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edxval -django-stubs==1.16.0 +django-stubs[compatible-mypy]==5.2.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/development.in # djangorestframework-stubs -django-stubs-ext==5.1.1 +django-stubs-ext==5.2.0 # via django-stubs -django-user-tasks==3.2.0 +django-user-tasks==3.4.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -django-waffle==4.1.0 +django-waffle==4.2.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -615,13 +628,12 @@ djangorestframework==3.14.0 # edx-organizations # edx-proctoring # edx-submissions + # openedx-forum # openedx-learning # ora2 # super-csv -djangorestframework-stubs==3.14.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/development.in +djangorestframework-stubs==3.16.0 + # via -r requirements/edx/development.in djangorestframework-xml==2.0.0 # via # -r requirements/edx/doc.txt @@ -638,7 +650,7 @@ docutils==0.21.2 # pydata-sphinx-theme # sphinx # sphinx-mdinclude -done-xblock==2.4.0 +done-xblock==2.5.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -647,70 +659,65 @@ drf-jwt==1.19.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-drf-extensions -drf-spectacular==0.27.2 +drf-spectacular==0.28.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -drf-yasg==1.21.8 +drf-yasg==1.21.10 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.3 +edx-ace==1.15.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-api-doc-tools==2.0.0 +edx-api-doc-tools==2.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-name-affirmation -edx-auth-backends==4.4.0 +edx-auth-backends==4.5.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-braze-client==0.2.5 - # via - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt - # edx-enterprise -edx-bulk-grades==1.1.0 +edx-bulk-grades==1.2.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # staff-graded-xblock -edx-ccx-keys==1.3.0 +edx-ccx-keys==2.0.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # lti-consumer-xblock # openedx-events -edx-celeryutils==1.3.0 +edx-celeryutils==1.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-name-affirmation # super-csv -edx-codejail==3.5.1 +edx-codejail==4.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-completion==4.7.3 +edx-completion==4.9 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-django-release-util==1.4.0 +edx-django-release-util==1.5.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-submissions # edxval -edx-django-sites-extensions==4.2.0 +edx-django-sites-extensions==5.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-django-utils==7.0.0 +edx-django-utils==8.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -724,11 +731,12 @@ edx-django-utils==7.0.0 # edx-rest-api-client # edx-toggles # edx-when + # enterprise-integrated-channels # event-tracking # openedx-events # ora2 # super-csv -edx-drf-extensions==10.5.0 +edx-drf-extensions==10.6.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -740,29 +748,30 @@ edx-drf-extensions==10.5.0 # edx-rbac # edx-when # edxval + # enterprise-integrated-channels # openedx-learning -edx-enterprise==4.32.2 +edx-enterprise==6.2.13 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-event-bus-kafka==6.0.0 +edx-event-bus-kafka==6.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-event-bus-redis==0.5.1 +edx-event-bus-redis==0.6.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-i18n-tools==1.5.0 +edx-i18n-tools==1.9.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # ora2 -edx-lint==5.4.1 + # xblocks-contrib +edx-lint==5.6.0 # via -r requirements/edx/testing.txt -edx-milestones==0.6.0 +edx-milestones==1.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -770,7 +779,7 @@ edx-name-affirmation==3.0.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-opaque-keys[django]==2.11.0 +edx-opaque-keys[django]==3.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -783,48 +792,55 @@ edx-opaque-keys[django]==2.11.0 # edx-organizations # edx-proctoring # edx-when + # enterprise-integrated-channels # lti-consumer-xblock # openedx-events + # openedx-filters # ora2 + # xblocks-contrib edx-organizations==6.13.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-proctoring==4.18.3 +edx-proctoring==5.2.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-proctoring-proctortrack -edx-rbac==1.10.0 +edx-rbac==2.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise -edx-rest-api-client==6.0.0 + # enterprise-integrated-channels +edx-rest-api-client==6.2.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise # edx-proctoring -edx-search==4.1.1 + # enterprise-integrated-channels +edx-search==4.1.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-sga==0.25.0 + # openedx-forum +edx-sga==0.26.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-submissions==3.8.2 +edx-submissions==3.11.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # ora2 -edx-tincan-py35==1.0.0 +edx-tincan-py35==2.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise -edx-toggles==5.2.0 + # enterprise-integrated-channels +edx-toggles==5.3.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -837,37 +853,38 @@ 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.0 +edx-when==3.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-proctoring -edxval==2.6.0 +edxval==3.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt elasticsearch==7.9.1 # via # -c requirements/edx/../common_constraints.txt + # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-search + # openedx-forum enmerkar==0.7.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # enmerkar-underscore -enmerkar-underscore==2.3.1 +enmerkar-underscore==2.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -event-tracking==3.0.0 +enterprise-integrated-channels==0.1.13 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt +event-tracking==3.3.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-completion @@ -877,36 +894,36 @@ execnet==2.1.1 # via # -r requirements/edx/testing.txt # pytest-xdist -factory-boy==3.3.1 +factory-boy==3.3.3 # via -r requirements/edx/testing.txt -faker==30.8.1 +faker==37.3.0 # via # -r requirements/edx/testing.txt # factory-boy -fastapi==0.115.4 +fastapi==0.115.12 # via # -r requirements/edx/testing.txt # pact-python -fastavro==1.9.7 +fastavro==1.11.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # openedx-events -filelock==3.16.1 +filelock==3.18.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # snowflake-connector-python # tox # virtualenv -firebase-admin==6.5.0 +firebase-admin==6.8.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-ace -freezegun==1.5.1 +freezegun==1.5.2 # via -r requirements/edx/testing.txt -frozenlist==1.5.0 +frozenlist==1.6.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -929,21 +946,21 @@ future==1.0.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # pyjwkest -geoip2==4.8.0 +geoip2==5.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -gitdb==4.0.11 +gitdb==4.0.12 # via # -r requirements/edx/doc.txt # gitpython -gitpython==3.1.43 +gitpython==3.1.44 # via -r requirements/edx/doc.txt glob2==0.7 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -google-api-core[grpc]==2.22.0 +google-api-core[grpc]==2.25.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -952,12 +969,12 @@ google-api-core[grpc]==2.22.0 # google-cloud-core # google-cloud-firestore # google-cloud-storage -google-api-python-client==2.149.0 +google-api-python-client==2.171.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # firebase-admin -google-auth==2.35.0 +google-auth==2.40.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -972,23 +989,23 @@ google-auth-httplib2==0.2.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # google-api-python-client -google-cloud-core==2.4.1 +google-cloud-core==2.4.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # google-cloud-firestore # google-cloud-storage -google-cloud-firestore==2.19.0 +google-cloud-firestore==2.21.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # firebase-admin -google-cloud-storage==2.18.2 +google-cloud-storage==3.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # firebase-admin -google-crc32c==1.6.0 +google-crc32c==1.7.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -999,23 +1016,23 @@ google-resumable-media==2.7.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # google-cloud-storage -googleapis-common-protos==1.65.0 +googleapis-common-protos==1.70.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # google-api-core # grpcio-status -grimp==3.5 +grimp==3.9 # via # -r requirements/edx/testing.txt # import-linter -grpcio==1.67.0 +grpcio==1.72.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # google-api-core # grpcio-status -grpcio-status==1.67.0 +grpcio-status==1.72.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1027,8 +1044,9 @@ gunicorn==23.0.0 h11==0.14.0 # via # -r requirements/edx/testing.txt + # httpcore # uvicorn -help-tokens==2.4.0 +help-tokens==3.2.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1037,6 +1055,10 @@ html5lib==1.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # ora2 +httpcore==0.16.3 + # via + # -r requirements/edx/testing.txt + # httpx httplib2==0.22.0 # via # -r requirements/edx/doc.txt @@ -1045,7 +1067,11 @@ httplib2==0.22.0 # google-auth-httplib2 httpretty==1.1.4 # via -r requirements/edx/testing.txt -icalendar==6.0.1 +httpx==0.23.3 + # via + # -r requirements/edx/testing.txt + # pact-python +icalendar==6.3.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1056,15 +1082,16 @@ idna==3.10 # anyio # optimizely-sdk # requests + # rfc3986 # snowflake-connector-python # yarl imagesize==1.4.1 # via # -r requirements/edx/doc.txt # sphinx -import-linter==2.1 +import-linter==2.3 # via -r requirements/edx/testing.txt -importlib-metadata==8.5.0 +importlib-metadata==8.7.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1074,15 +1101,10 @@ inflection==0.5.1 # -r requirements/edx/testing.txt # drf-spectacular # drf-yasg -iniconfig==2.0.0 +iniconfig==2.1.0 # via # -r requirements/edx/testing.txt # pytest -interchange==2021.0.4 - # via - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt - # py2neo ipaddress==1.0.23 # via # -r requirements/edx/doc.txt @@ -1092,27 +1114,29 @@ isodate==0.7.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # python3-saml -isort==5.13.2 +isort==6.0.1 # via # -r requirements/edx/testing.txt # pylint -jinja2==3.1.4 +jinja2==3.1.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # code-annotations # diff-cover # sphinx + # sphinx-autoapi jmespath==1.0.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # boto3 # botocore -joblib==1.4.2 +joblib==1.5.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt + # grimp # nltk jsondiff==2.2.1 # via @@ -1127,16 +1151,17 @@ jsonfield==3.1.0 # edx-enterprise # edx-proctoring # edx-submissions + # enterprise-integrated-channels # lti-consumer-xblock # ora2 -jsonschema==4.23.0 +jsonschema==4.24.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # drf-spectacular # optimizely-sdk # sphinxcontrib-openapi -jsonschema-specifications==2024.10.1 +jsonschema-specifications==2025.4.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1147,7 +1172,7 @@ jwcrypto==1.5.6 # -r requirements/edx/testing.txt # django-oauth-toolkit # pylti1p3 -kombu==5.4.2 +kombu==5.5.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1164,27 +1189,22 @@ lazy==1.6 # lti-consumer-xblock # ora2 # xblock -lazy-object-proxy==1.10.0 - # via - # -r requirements/edx/testing.txt - # astroid libsass==0.10.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/assets.txt - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt loremipsum==1.0.5 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # ora2 -lti-consumer-xblock==9.11.3 +lti-consumer-xblock==9.14.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -lxml[html-clean]==5.3.0 +lxml[html-clean]==5.3.2 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-i18n-tools @@ -1198,7 +1218,7 @@ lxml[html-clean]==5.3.0 # python3-saml # xblock # xmlsec -lxml-html-clean==0.3.1 +lxml-html-clean==0.4.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1207,7 +1227,7 @@ mailsnake==1.6.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -mako==1.3.6 +mako==1.3.10 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1215,9 +1235,8 @@ mako==1.3.6 # lti-consumer-xblock # xblock # xblock-utils -markdown==3.3.7 +markdown==3.8 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # openedx-django-wiki @@ -1232,7 +1251,7 @@ markupsafe==3.0.2 # mako # openedx-calc # xblock -maxminddb==2.6.2 +maxminddb==2.7.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1241,19 +1260,17 @@ mccabe==0.7.0 # via # -r requirements/edx/testing.txt # pylint -meilisearch==0.31.6 +meilisearch==0.34.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-search -mistune==3.0.2 +mistune==3.1.3 # via # -r requirements/edx/doc.txt # sphinx-mdinclude -mock==5.1.0 - # via - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt +mock==5.2.0 + # via -r requirements/edx/testing.txt mongoengine==0.29.1 # via # -r requirements/edx/doc.txt @@ -1263,8 +1280,7 @@ monotonic==1.6 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # analytics-python - # py2neo -more-itertools==10.5.0 +more-itertools==10.7.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1279,33 +1295,28 @@ msgpack==1.1.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # cachecontrol -multidict==6.1.0 +multidict==6.4.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # aiohttp # yarl -mypy==1.11.2 +mypy==1.15.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/development.in # django-stubs - # djangorestframework-stubs -mypy-extensions==1.0.0 +mypy-extensions==1.1.0 # via mypy -mysqlclient==2.2.5 +mysqlclient==2.2.7 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -newrelic==10.2.0 - # via - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt - # edx-django-utils -nh3==0.2.18 + # openedx-forum +nh3==0.2.21 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt + # xblocks-contrib nltk==3.9.1 # via # -r requirements/edx/doc.txt @@ -1333,6 +1344,7 @@ oauthlib==3.2.2 # lti-consumer-xblock # requests-oauthlib # social-auth-core + # xblocks-contrib olxcleaner==0.3.0 # via # -r requirements/edx/doc.txt @@ -1343,29 +1355,32 @@ openai==0.28.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise -openedx-atlas==0.6.2 +openedx-atlas==0.7.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-calc==3.1.2 + # enterprise-integrated-channels + # openedx-forum +openedx-calc==4.0.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-django-pyfs==3.7.0 +openedx-django-pyfs==3.8.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # lti-consumer-xblock # xblock + # xblocks-contrib openedx-django-require==2.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-django-wiki==2.1.0 +openedx-django-wiki==3.1.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-events==9.15.0 +openedx-events==10.2.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1375,31 +1390,30 @@ openedx-events==9.15.0 # edx-name-affirmation # event-tracking # ora2 -openedx-filters==1.11.0 +openedx-filters==2.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # lti-consumer-xblock # ora2 -openedx-learning==0.17.0 +openedx-forum==0.3.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt +openedx-learning==0.27.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-mongodbproxy==0.2.2 +optimizely-sdk==5.2.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -optimizely-sdk==4.1.1 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt -ora2==6.14.0 +ora2==6.16.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -packaging==24.1 +packaging==25.0 # via # -r requirements/edx/../pip-tools.txt # -r requirements/edx/doc.txt @@ -1407,20 +1421,16 @@ packaging==24.1 # build # drf-yasg # gunicorn - # py2neo + # kombu + # pydata-sphinx-theme # pyproject-api # pytest # snowflake-connector-python # sphinx # tox -pact-python==2.2.2 +pact-python==2.0.1 # via -r requirements/edx/testing.txt -pansi==2020.7.3 - # via - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt - # py2neo -paramiko==3.5.0 +paramiko==3.5.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1439,11 +1449,7 @@ path-py==12.5.0 # edx-enterprise # ora2 # staff-graded-xblock -paver==1.3.4 - # via - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt -pbr==6.1.0 +pbr==6.1.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1461,7 +1467,7 @@ piexif==1.1.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -pillow==11.0.0 +pillow==11.2.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1470,7 +1476,7 @@ pillow==11.0.0 # edxval pip-tools==7.4.1 # via -r requirements/edx/../pip-tools.txt -platformdirs==4.3.6 +platformdirs==4.3.8 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1478,7 +1484,7 @@ platformdirs==4.3.6 # snowflake-connector-python # tox # virtualenv -pluggy==1.5.0 +pluggy==1.6.0 # via # -r requirements/edx/testing.txt # diff-cover @@ -1489,23 +1495,24 @@ polib==1.2.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-i18n-tools -prompt-toolkit==3.0.48 +prompt-toolkit==3.0.51 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # click-repl -propcache==0.2.0 +propcache==0.3.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt + # aiohttp # yarl -proto-plus==1.25.0 +proto-plus==1.26.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # google-api-core # google-cloud-firestore -protobuf==5.28.3 +protobuf==6.31.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1514,7 +1521,7 @@ protobuf==5.28.3 # googleapis-common-protos # grpcio-status # proto-plus -psutil==6.1.0 +psutil==7.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1523,11 +1530,6 @@ psutil==6.1.0 # pytest-xdist py==1.11.0 # via -r requirements/edx/testing.txt -py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt pyasn1==0.6.1 # via # -r requirements/edx/doc.txt @@ -1535,7 +1537,7 @@ pyasn1==0.6.1 # pgpy # pyasn1-modules # rsa -pyasn1-modules==0.4.1 +pyasn1-modules==0.4.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1553,35 +1555,34 @@ pycparser==2.22 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # cffi -pycryptodomex==3.21.0 +pycryptodomex==3.23.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.9.2 +pydantic==2.11.5 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # camel-converter # fastapi -pydantic-core==2.23.4 +pydantic-core==2.33.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # pydantic -pydata-sphinx-theme==0.16.0 +pydata-sphinx-theme==0.15.4 # via # -r requirements/edx/doc.txt # sphinx-book-theme -pygments==2.18.0 +pygments==2.19.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # accessible-pygments # diff-cover - # py2neo # pydata-sphinx-theme # sphinx # sphinx-mdinclude @@ -1589,9 +1590,8 @@ pyjwkest==1.4.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt - # edx-token-utils # lti-consumer-xblock -pyjwt[crypto]==2.9.0 +pyjwt[crypto]==2.10.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1601,6 +1601,7 @@ pyjwt[crypto]==2.9.0 # edx-proctoring # edx-rest-api-client # firebase-admin + # lti-consumer-xblock # pylti1p3 # snowflake-connector-python # social-auth-core @@ -1609,9 +1610,8 @@ pylatexenc==2.10 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # olxcleaner -pylint==2.15.10 +pylint==3.3.7 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/testing.txt # edx-lint # pylint-celery @@ -1622,7 +1622,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 @@ -1631,7 +1631,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 @@ -1649,7 +1649,7 @@ pymongo==4.4.0 # edx-opaque-keys # event-tracking # mongoengine - # openedx-mongodbproxy + # openedx-forum pynacl==1.5.0 # via # -r requirements/edx/doc.txt @@ -1660,20 +1660,19 @@ pynliner==0.8.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -pyopenssl==24.2.1 +pyopenssl==25.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt - # optimizely-sdk # snowflake-connector-python -pyparsing==3.2.0 +pyparsing==3.2.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # chem # httplib2 # openedx-calc -pyproject-api==1.8.0 +pyproject-api==1.9.1 # via # -r requirements/edx/testing.txt # tox @@ -1694,7 +1693,7 @@ pysrt==1.1.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edxval -pytest==8.3.3 +pytest==8.2.0 # via # -r requirements/edx/testing.txt # pylint-pytest @@ -1707,19 +1706,19 @@ pytest==8.3.3 # pytest-xdist pytest-attrib==0.1.3 # via -r requirements/edx/testing.txt -pytest-cov==5.0.0 +pytest-cov==6.1.1 # via -r requirements/edx/testing.txt -pytest-django==4.9.0 +pytest-django==4.11.1 # 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 pytest-randomly==3.16.0 # via -r requirements/edx/testing.txt -pytest-xdist[psutil]==3.6.1 +pytest-xdist[psutil]==3.7.0 # via -r requirements/edx/testing.txt python-dateutil==2.9.0.post0 # via @@ -1731,7 +1730,6 @@ python-dateutil==2.9.0.post0 # edx-ace # edx-enterprise # edx-proctoring - # faker # freezegun # icalendar # olxcleaner @@ -1742,16 +1740,12 @@ python-ipware==3.0.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-ipware -python-memcached==1.62 - # via - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt python-slugify==8.0.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # code-annotations -python-swiftclient==4.6.0 +python-swiftclient==4.8.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1765,7 +1759,7 @@ python3-saml==1.16.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -pytz==2024.2 +pytz==2025.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1776,9 +1770,9 @@ pytz==2024.2 # edx-proctoring # edx-submissions # edx-tincan-py35 + # enterprise-integrated-channels # event-tracking # fs - # interchange # olxcleaner # ora2 # snowflake-connector-python @@ -1787,7 +1781,7 @@ pyuca==1.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -pywatchman==2.0.0 +pywatchman==3.0.0 # via -r requirements/edx/development.in pyyaml==6.0.2 # via @@ -1799,28 +1793,29 @@ pyyaml==6.0.2 # edx-django-release-util # edx-i18n-tools # jsondiff + # sphinx-autoapi # sphinxcontrib-openapi # xblock random2==1.0.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -recommender-xblock==3.0.0 +recommender-xblock==3.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -redis==5.2.0 +redis==6.2.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # walrus -referencing==0.35.1 +referencing==0.36.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # jsonschema # jsonschema-specifications -regex==2024.9.11 +regex==2024.11.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1829,7 +1824,6 @@ requests==2.32.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt - # algoliasearch # analytics-python # cachecontrol # django-oauth-toolkit @@ -1838,12 +1832,14 @@ requests==2.32.3 # edx-drf-extensions # edx-enterprise # edx-rest-api-client + # enterprise-integrated-channels # geoip2 # google-api-core # google-cloud-storage # mailsnake # meilisearch # openai + # openedx-forum # optimizely-sdk # pact-python # pyjwkest @@ -1861,13 +1857,21 @@ requests-oauthlib==2.0.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # social-auth-core -rpds-py==0.20.0 +rfc3986[idna2008]==1.5.0 + # via + # -r requirements/edx/testing.txt + # httpx +roman-numerals-py==3.1.0 + # via + # -r requirements/edx/doc.txt + # sphinx +rpds-py==0.25.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # jsonschema # referencing -rsa==4.9 +rsa==4.9.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1879,7 +1883,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-learning -s3transfer==0.10.3 +s3transfer==0.13.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1889,22 +1893,21 @@ sailthru-client==2.2.3 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-ace -scipy==1.14.1 +scipy==1.15.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # chem - # openedx-calc semantic-version==2.10.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-drf-extensions -shapely==2.0.6 +shapely==2.1.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -simplejson==3.19.3 +simplejson==3.20.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1912,15 +1915,14 @@ simplejson==3.19.3 # super-csv # xblock # xblock-utils -singledispatch==4.1.0 +singledispatch==4.1.2 # via -r requirements/edx/testing.txt -six==1.16.0 +six==1.17.0 # via # -r requirements/edx/assets.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # analytics-python - # bleach # codejail-includes # crowdsourcehinter-xblock # edx-ace @@ -1935,13 +1937,8 @@ six==1.16.0 # fs # fs-s3fs # html5lib - # interchange # libsass - # optimizely-sdk # pact-python - # pansi - # paver - # py2neo # pyjwkest # python-dateutil # sphinxcontrib-httpdomain @@ -1951,7 +1948,8 @@ slumber==0.7.1 # -r requirements/edx/testing.txt # edx-bulk-grades # edx-enterprise -smmap==5.0.1 + # enterprise-integrated-channels +smmap==5.0.2 # via # -r requirements/edx/doc.txt # gitdb @@ -1959,11 +1957,13 @@ sniffio==1.3.1 # via # -r requirements/edx/testing.txt # anyio -snowballstemmer==2.2.0 + # httpcore + # httpx +snowballstemmer==3.0.1 # via # -r requirements/edx/doc.txt # sphinx -snowflake-connector-python==3.12.3 +snowflake-connector-python==3.15.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1976,6 +1976,7 @@ social-auth-app-django==5.4.1 # edx-auth-backends social-auth-core==4.5.4 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-auth-backends @@ -1990,15 +1991,16 @@ sortedcontainers==2.4.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # snowflake-connector-python -soupsieve==2.6 +soupsieve==2.7 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # beautifulsoup4 -sphinx==8.1.3 +sphinx==8.2.3 # via # -r requirements/edx/doc.txt # pydata-sphinx-theme + # sphinx-autoapi # sphinx-book-theme # sphinx-design # sphinx-mdinclude @@ -2006,7 +2008,9 @@ sphinx==8.1.3 # sphinxcontrib-httpdomain # sphinxcontrib-openapi # sphinxext-rediraffe -sphinx-book-theme==1.1.3 +sphinx-autoapi==3.6.0 + # via -r requirements/edx/doc.txt +sphinx-book-theme==1.1.4 # via -r requirements/edx/doc.txt sphinx-design==0.6.1 # via -r requirements/edx/doc.txt @@ -2014,7 +2018,7 @@ sphinx-mdinclude==0.6.2 # via # -r requirements/edx/doc.txt # sphinxcontrib-openapi -sphinx-reredirects==0.1.5 +sphinx-reredirects==1.0.0 # via -r requirements/edx/doc.txt sphinxcontrib-applehelp==2.0.0 # via @@ -2048,21 +2052,21 @@ sphinxcontrib-serializinghtml==2.0.0 # sphinx sphinxext-rediraffe==0.2.7 # via -r requirements/edx/doc.txt -sqlparse==0.5.1 +sqlparse==0.5.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django # django-debug-toolbar -staff-graded-xblock==2.3.0 +staff-graded-xblock==3.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -starlette==0.41.2 +starlette==0.46.2 # via # -r requirements/edx/testing.txt # fastapi -stevedore==5.3.0 +stevedore==5.4.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2071,12 +2075,12 @@ stevedore==5.3.0 # edx-django-utils # edx-enterprise # edx-opaque-keys -super-csv==3.2.0 +super-csv==4.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-bulk-grades -sympy==1.13.3 +sympy==1.14.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2091,45 +2095,43 @@ text-unidecode==1.3 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # python-slugify -tinycss2==1.2.1 +tinycss2==1.4.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # bleach -tomli==2.0.2 - # via django-stubs tomlkit==0.13.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt + # openedx-learning # pylint # snowflake-connector-python -tox==4.23.2 +tox==4.26.0 # via -r requirements/edx/testing.txt -tqdm==4.66.6 +tqdm==4.67.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # nltk # openai -types-pytz==2024.2.0.20241003 - # via django-stubs -types-pyyaml==6.0.12.20240917 +types-pyyaml==6.0.12.20250516 # via # django-stubs # djangorestframework-stubs -types-requests==2.32.0.20241016 +types-requests==2.32.0.20250602 # via djangorestframework-stubs -typing-extensions==4.12.2 +typing-extensions==4.14.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt + # anyio + # beautifulsoup4 # django-countries # django-stubs # django-stubs-ext # djangorestframework-stubs # edx-opaque-keys - # faker # fastapi # grimp # import-linter @@ -2139,12 +2141,20 @@ typing-extensions==4.12.2 # pydantic-core # pydata-sphinx-theme # pylti1p3 + # pyopenssl + # referencing # snowflake-connector-python -tzdata==2024.2 + # typing-inspection +typing-inspection==0.4.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt - # celery + # pydantic +tzdata==2025.2 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # faker # icalendar # kombu unicodecsv==0.14.1 @@ -2152,9 +2162,14 @@ unicodecsv==0.14.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-enterprise + # enterprise-integrated-channels +unicodeit==0.7.5 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt unidiff==0.7.5 # via -r requirements/edx/testing.txt -uritemplate==4.1.1 +uritemplate==4.2.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2163,18 +2178,19 @@ uritemplate==4.1.1 # google-api-python-client urllib3==2.2.3 # via + # -c requirements/edx/../common_constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # botocore # elasticsearch - # py2neo + # pact-python # requests # types-requests -user-util==1.1.0 +user-util==2.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -uvicorn==0.32.0 +uvicorn==0.34.3 # via # -r requirements/edx/testing.txt # pact-python @@ -2185,7 +2201,7 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==20.27.1 +virtualenv==20.31.2 # via # -r requirements/edx/testing.txt # tox @@ -2194,24 +2210,21 @@ voluptuous==0.15.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # ora2 -vulture==2.13 +vulture==2.14 # via -r requirements/edx/development.in walrus==0.9.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-event-bus-redis -watchdog==5.0.3 - # via - # -r requirements/edx/development.in - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt +watchdog==6.0.0 + # via -r requirements/edx/development.in wcwidth==0.2.13 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # prompt-toolkit -web-fragments==2.2.0 +web-fragments==3.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2232,16 +2245,18 @@ webob==1.8.9 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # xblock -wheel==0.44.0 +wheel==0.45.1 # via # -r requirements/edx/../pip-tools.txt + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # django-pipeline # pip-tools -wrapt==1.16.0 +wrapt==1.17.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt - # astroid -xblock[django]==5.1.0 +xblock[django]==5.2.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2257,15 +2272,16 @@ xblock[django]==5.1.0 # xblock-drag-and-drop-v2 # xblock-google-drive # xblock-utils -xblock-drag-and-drop-v2==4.0.3 + # xblocks-contrib +xblock-drag-and-drop-v2==5.0.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -xblock-google-drive==0.7.0 +xblock-google-drive==0.8.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -xblock-poll==1.14.0 +xblock-poll==1.15.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2275,22 +2291,26 @@ xblock-utils==4.0.0 # -r requirements/edx/testing.txt # edx-sga # xblock-poll +xblocks-contrib==0.4.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt xmlsec==1.3.14 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # python3-saml -xss-utils==0.6.0 +xss-utils==0.8.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -yarl==1.17.0 +yarl==1.20.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # aiohttp - # pact-python -zipp==3.20.2 +zipp==3.22.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.in b/requirements/edx/doc.in index 013bafc42e..78afbf9d6b 100644 --- a/requirements/edx/doc.in +++ b/requirements/edx/doc.in @@ -10,3 +10,4 @@ sphinx-design # provides various responsive web-components sphinxcontrib-openapi[markdown] # Be able to render openapi schema in a sphinx project sphinxext-rediraffe # Quickly and easily redirect when we move pages around. sphinx-reredirects # Redirect from a sphinx project out to other places on the web including other sphinx projects +sphinx-autoapi diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index e4ece3a1f0..f7b686081c 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -10,32 +10,28 @@ accessible-pygments==0.0.5 # via pydata-sphinx-theme acid-xblock==0.4.1 # via -r requirements/edx/base.txt -aiohappyeyeballs==2.4.3 +aiohappyeyeballs==2.6.1 # via # -r requirements/edx/base.txt # aiohttp -aiohttp==3.10.10 +aiohttp==3.12.8 # via # -r requirements/edx/base.txt # geoip2 # openai -aiosignal==1.3.1 +aiosignal==1.3.2 # via # -r requirements/edx/base.txt # aiohttp alabaster==1.0.0 # via sphinx -algoliasearch==3.0.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/base.txt -amqp==5.2.0 +amqp==5.3.1 # via # -r requirements/edx/base.txt # kombu analytics-python==1.4.post1 # via -r requirements/edx/base.txt -aniso8601==9.0.1 +aniso8601==10.0.1 # via # -r requirements/edx/base.txt # edx-tincan-py35 @@ -57,7 +53,9 @@ asn1crypto==1.5.1 # via # -r requirements/edx/base.txt # snowflake-connector-python -attrs==24.2.0 +astroid==3.3.10 + # via sphinx-autoapi +attrs==25.3.0 # via # -r requirements/edx/base.txt # aiohttp @@ -67,7 +65,7 @@ attrs==24.2.0 # openedx-events # openedx-learning # referencing -babel==2.16.0 +babel==2.17.0 # via # -r requirements/edx/base.txt # enmerkar @@ -78,20 +76,21 @@ backoff==1.10.0 # via # -r requirements/edx/base.txt # analytics-python -bcrypt==4.2.0 +bcrypt==4.3.0 # via # -r requirements/edx/base.txt # paramiko -beautifulsoup4==4.12.3 +beautifulsoup4==4.13.4 # via # -r requirements/edx/base.txt + # openedx-forum # pydata-sphinx-theme # pynliner billiard==4.2.1 # via # -r requirements/edx/base.txt # celery -bleach[css]==6.1.0 +bleach[css]==6.2.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -102,32 +101,35 @@ bleach[css]==6.1.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/base.txt -boto3==1.35.50 +boto3==1.38.29 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.35.50 + # snowflake-connector-python +botocore==1.38.29 # via # -r requirements/edx/base.txt # boto3 # s3transfer + # snowflake-connector-python bridgekeeper==0.9 # via -r requirements/edx/base.txt -cachecontrol==0.14.0 +cachecontrol==0.14.3 # via # -r requirements/edx/base.txt # firebase-admin -cachetools==5.5.0 +cachetools==5.5.2 # via # -r requirements/edx/base.txt + # edxval # google-auth camel-converter[pydantic]==4.0.1 # via # -r requirements/edx/base.txt # meilisearch -celery==5.4.0 +celery==5.5.3 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -135,13 +137,13 @@ celery==5.4.0 # django-user-tasks # edx-celeryutils # edx-enterprise + # enterprise-integrated-channels # event-tracking # openedx-learning -certifi==2024.8.30 +certifi==2025.4.26 # via # -r requirements/edx/base.txt # elasticsearch - # py2neo # requests # snowflake-connector-python cffi==1.17.1 @@ -154,17 +156,15 @@ chardet==5.2.0 # via # -r requirements/edx/base.txt # pysrt -charset-normalizer==2.0.12 +charset-normalizer==3.4.2 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # requests # snowflake-connector-python -chem==1.3.0 +chem==2.0.0 # via -r requirements/edx/base.txt -click==8.1.6 +click==8.2.1 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # celery # click-didyoumean @@ -186,7 +186,7 @@ click-repl==0.3.0 # via # -r requirements/edx/base.txt # celery -code-annotations==1.8.0 +code-annotations==2.3.0 # via # -r requirements/edx/base.txt # -r requirements/edx/doc.in @@ -196,13 +196,12 @@ codejail-includes==1.0.0 # via -r requirements/edx/base.txt crowdsourcehinter-xblock==0.8 # via -r requirements/edx/base.txt -cryptography==43.0.3 +cryptography==45.0.3 # via # -r requirements/edx/base.txt # django-fernet-fields-v2 # edx-enterprise # jwcrypto - # optimizely-sdk # paramiko # pgpy # pyjwt @@ -222,12 +221,13 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -django==4.2.16 +django==4.2.22 # via # -c requirements/edx/../common_constraints.txt # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # django-appconf + # django-autocomplete-light # django-celery-results # django-classy-tags # django-config-models @@ -244,6 +244,7 @@ django==4.2.16 # django-push-notifications # django-sekizai # django-ses + # django-simple-history # django-statici18n # django-storages # django-user-tasks @@ -275,11 +276,11 @@ django==4.2.16 # edx-search # edx-submissions # edx-toggles - # edx-token-utils # edx-when # edxval # enmerkar # enmerkar-underscore + # enterprise-integrated-channels # event-tracking # help-tokens # jsonfield @@ -288,33 +289,37 @@ django==4.2.16 # openedx-django-wiki # openedx-events # openedx-filters + # openedx-forum # openedx-learning # ora2 # social-auth-app-django # super-csv # xblock-google-drive # xss-utils -django-appconf==1.0.6 +django-appconf==1.1.0 # via # -r requirements/edx/base.txt # django-statici18n -django-cache-memoize==0.2.0 +django-autocomplete-light==3.12.1 + # via -r requirements/edx/base.txt +django-cache-memoize==0.2.1 # via # -r requirements/edx/base.txt # edx-enterprise -django-celery-results==2.5.1 +django-celery-results==2.6.0 # via -r requirements/edx/base.txt django-classy-tags==4.1.0 # via # -r requirements/edx/base.txt # django-sekizai -django-config-models==2.7.0 +django-config-models==2.9.0 # via # -r requirements/edx/base.txt # edx-enterprise # edx-name-affirmation + # enterprise-integrated-channels # lti-consumer-xblock -django-cors-headers==4.5.0 +django-cors-headers==4.7.0 # via -r requirements/edx/base.txt django-countries==7.6.1 # via @@ -333,7 +338,8 @@ django-fernet-fields-v2==0.9 # via # -r requirements/edx/base.txt # edx-enterprise -django-filter==24.3 + # enterprise-integrated-channels +django-filter==25.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -343,7 +349,7 @@ django-ipware==7.0.1 # -r requirements/edx/base.txt # edx-enterprise # edx-proctoring -django-js-asset==2.2.0 +django-js-asset==3.1.2 # via # -r requirements/edx/base.txt # django-mptt @@ -365,30 +371,33 @@ django-model-utils==5.0.0 # edx-submissions # edx-when # edxval + # enterprise-integrated-channels # ora2 # super-csv -django-mptt==0.16.0 +django-mptt==0.17.0 # via # -r requirements/edx/base.txt # openedx-django-wiki -django-multi-email-field==0.7.0 +django-multi-email-field==0.8.0 # via # -r requirements/edx/base.txt # edx-enterprise -django-mysql==4.14.0 +django-mysql==4.17.0 # via -r requirements/edx/base.txt django-oauth-toolkit==1.7.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-enterprise -django-object-actions==4.3.0 + # enterprise-integrated-channels +django-object-actions==5.0.0 # via # -r requirements/edx/base.txt # edx-enterprise -django-pipeline==3.1.0 + # enterprise-integrated-channels +django-pipeline==4.0.0 # via -r requirements/edx/base.txt -django-push-notifications==3.1.0 +django-push-notifications==3.2.1 # via # -r requirements/edx/base.txt # edx-ace @@ -398,31 +407,31 @@ django-sekizai==4.1.0 # via # -r requirements/edx/base.txt # openedx-django-wiki -django-ses==4.2.0 +django-ses==4.4.0 # via -r requirements/edx/base.txt -django-simple-history==3.4.0 +django-simple-history==3.8.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-enterprise # edx-name-affirmation # edx-organizations # edx-proctoring + # enterprise-integrated-channels # ora2 -django-statici18n==2.5.0 +django-statici18n==2.6.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock # xblock-drag-and-drop-v2 # xblock-poll -django-storages==1.14.3 + # xblocks-contrib +django-storages==1.14.6 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edxval -django-user-tasks==3.2.0 +django-user-tasks==3.4.1 # via -r requirements/edx/base.txt -django-waffle==4.1.0 +django-waffle==4.2.0 # via # -r requirements/edx/base.txt # edx-django-utils @@ -452,6 +461,7 @@ djangorestframework==3.14.0 # edx-organizations # edx-proctoring # edx-submissions + # openedx-forum # openedx-learning # ora2 # super-csv @@ -468,57 +478,53 @@ docutils==0.21.2 # pydata-sphinx-theme # sphinx # sphinx-mdinclude -done-xblock==2.4.0 +done-xblock==2.5.0 # via -r requirements/edx/base.txt drf-jwt==1.19.2 # via # -r requirements/edx/base.txt # edx-drf-extensions -drf-spectacular==0.27.2 +drf-spectacular==0.28.0 # via -r requirements/edx/base.txt -drf-yasg==1.21.8 +drf-yasg==1.21.10 # via # -r requirements/edx/base.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.3 +edx-ace==1.15.0 # via -r requirements/edx/base.txt -edx-api-doc-tools==2.0.0 +edx-api-doc-tools==2.1.0 # via # -r requirements/edx/base.txt # edx-name-affirmation -edx-auth-backends==4.4.0 +edx-auth-backends==4.5.0 # via -r requirements/edx/base.txt -edx-braze-client==0.2.5 - # via - # -r requirements/edx/base.txt - # edx-enterprise -edx-bulk-grades==1.1.0 +edx-bulk-grades==1.2.0 # via # -r requirements/edx/base.txt # staff-graded-xblock -edx-ccx-keys==1.3.0 +edx-ccx-keys==2.0.2 # via # -r requirements/edx/base.txt # lti-consumer-xblock # openedx-events -edx-celeryutils==1.3.0 +edx-celeryutils==1.4.0 # via # -r requirements/edx/base.txt # edx-name-affirmation # super-csv -edx-codejail==3.5.1 +edx-codejail==4.0.0 # via -r requirements/edx/base.txt -edx-completion==4.7.3 +edx-completion==4.9 # via -r requirements/edx/base.txt -edx-django-release-util==1.4.0 +edx-django-release-util==1.5.0 # via # -r requirements/edx/base.txt # edx-submissions # edxval -edx-django-sites-extensions==4.2.0 +edx-django-sites-extensions==5.1.0 # via -r requirements/edx/base.txt -edx-django-utils==7.0.0 +edx-django-utils==8.0.0 # via # -r requirements/edx/base.txt # django-config-models @@ -531,11 +537,12 @@ edx-django-utils==7.0.0 # edx-rest-api-client # edx-toggles # edx-when + # enterprise-integrated-channels # event-tracking # openedx-events # ora2 # super-csv -edx-drf-extensions==10.5.0 +edx-drf-extensions==10.6.0 # via # -r requirements/edx/base.txt # edx-completion @@ -546,25 +553,26 @@ edx-drf-extensions==10.5.0 # edx-rbac # edx-when # edxval + # enterprise-integrated-channels # openedx-learning -edx-enterprise==4.32.2 +edx-enterprise==6.2.13 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -edx-event-bus-kafka==6.0.0 +edx-event-bus-kafka==6.1.0 # via -r requirements/edx/base.txt -edx-event-bus-redis==0.5.1 +edx-event-bus-redis==0.6.1 # via -r requirements/edx/base.txt -edx-i18n-tools==1.5.0 +edx-i18n-tools==1.9.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # ora2 -edx-milestones==0.6.0 + # xblocks-contrib +edx-milestones==1.1.0 # via -r requirements/edx/base.txt edx-name-affirmation==3.0.1 # via -r requirements/edx/base.txt -edx-opaque-keys[django]==2.11.0 +edx-opaque-keys[django]==3.0.0 # via # -r requirements/edx/base.txt # edx-bulk-grades @@ -576,37 +584,45 @@ edx-opaque-keys[django]==2.11.0 # edx-organizations # edx-proctoring # edx-when + # enterprise-integrated-channels # lti-consumer-xblock # openedx-events + # openedx-filters # ora2 + # xblocks-contrib edx-organizations==6.13.0 # via -r requirements/edx/base.txt -edx-proctoring==4.18.3 +edx-proctoring==5.2.0 # via # -r requirements/edx/base.txt # edx-proctoring-proctortrack -edx-rbac==1.10.0 +edx-rbac==2.1.0 # via # -r requirements/edx/base.txt # edx-enterprise -edx-rest-api-client==6.0.0 + # enterprise-integrated-channels +edx-rest-api-client==6.2.0 # via # -r requirements/edx/base.txt # edx-enterprise # edx-proctoring -edx-search==4.1.1 + # enterprise-integrated-channels +edx-search==4.1.3 + # via + # -r requirements/edx/base.txt + # openedx-forum +edx-sga==0.26.0 # via -r requirements/edx/base.txt -edx-sga==0.25.0 - # via -r requirements/edx/base.txt -edx-submissions==3.8.2 +edx-submissions==3.11.1 # via # -r requirements/edx/base.txt # ora2 -edx-tincan-py35==1.0.0 +edx-tincan-py35==2.0.0 # via # -r requirements/edx/base.txt # edx-enterprise -edx-toggles==5.2.0 + # enterprise-integrated-channels +edx-toggles==5.3.0 # via # -r requirements/edx/base.txt # edx-completion @@ -618,45 +634,46 @@ 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.0 +edx-when==3.0.0 # via # -r requirements/edx/base.txt # edx-proctoring -edxval==2.6.0 +edxval==3.0.0 # via -r requirements/edx/base.txt elasticsearch==7.9.1 # via # -c requirements/edx/../common_constraints.txt + # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-search + # openedx-forum enmerkar==0.7.1 # via # -r requirements/edx/base.txt # enmerkar-underscore -enmerkar-underscore==2.3.1 +enmerkar-underscore==2.4.0 # via -r requirements/edx/base.txt -event-tracking==3.0.0 +enterprise-integrated-channels==0.1.13 + # via -r requirements/edx/base.txt +event-tracking==3.3.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-completion # edx-proctoring # edx-search -fastavro==1.9.7 +fastavro==1.11.1 # via # -r requirements/edx/base.txt # openedx-events -filelock==3.16.1 +filelock==3.18.0 # via # -r requirements/edx/base.txt # snowflake-connector-python -firebase-admin==6.5.0 +firebase-admin==6.8.0 # via # -r requirements/edx/base.txt # edx-ace -frozenlist==1.5.0 +frozenlist==1.6.2 # via # -r requirements/edx/base.txt # aiohttp @@ -675,15 +692,15 @@ future==1.0.0 # via # -r requirements/edx/base.txt # pyjwkest -geoip2==4.8.0 +geoip2==5.1.0 # via -r requirements/edx/base.txt -gitdb==4.0.11 +gitdb==4.0.12 # via gitpython -gitpython==3.1.43 +gitpython==3.1.44 # via -r requirements/edx/doc.in glob2==0.7 # via -r requirements/edx/base.txt -google-api-core[grpc]==2.22.0 +google-api-core[grpc]==2.25.0 # via # -r requirements/edx/base.txt # firebase-admin @@ -691,11 +708,11 @@ google-api-core[grpc]==2.22.0 # google-cloud-core # google-cloud-firestore # google-cloud-storage -google-api-python-client==2.149.0 +google-api-python-client==2.171.0 # via # -r requirements/edx/base.txt # firebase-admin -google-auth==2.35.0 +google-auth==2.40.2 # via # -r requirements/edx/base.txt # google-api-core @@ -708,20 +725,20 @@ google-auth-httplib2==0.2.0 # via # -r requirements/edx/base.txt # google-api-python-client -google-cloud-core==2.4.1 +google-cloud-core==2.4.3 # via # -r requirements/edx/base.txt # google-cloud-firestore # google-cloud-storage -google-cloud-firestore==2.19.0 +google-cloud-firestore==2.21.0 # via # -r requirements/edx/base.txt # firebase-admin -google-cloud-storage==2.18.2 +google-cloud-storage==3.1.0 # via # -r requirements/edx/base.txt # firebase-admin -google-crc32c==1.6.0 +google-crc32c==1.7.1 # via # -r requirements/edx/base.txt # google-cloud-storage @@ -730,23 +747,23 @@ google-resumable-media==2.7.2 # via # -r requirements/edx/base.txt # google-cloud-storage -googleapis-common-protos==1.65.0 +googleapis-common-protos==1.70.0 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio==1.67.0 +grpcio==1.72.1 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio-status==1.67.0 +grpcio-status==1.72.1 # via # -r requirements/edx/base.txt # google-api-core gunicorn==23.0.0 # via -r requirements/edx/base.txt -help-tokens==2.4.0 +help-tokens==3.2.0 # via -r requirements/edx/base.txt html5lib==1.1 # via @@ -757,7 +774,7 @@ httplib2==0.22.0 # -r requirements/edx/base.txt # google-api-python-client # google-auth-httplib2 -icalendar==6.0.1 +icalendar==6.3.1 # via -r requirements/edx/base.txt idna==3.10 # via @@ -768,34 +785,31 @@ idna==3.10 # yarl imagesize==1.4.1 # via sphinx -importlib-metadata==8.5.0 +importlib-metadata==8.7.0 # via -r requirements/edx/base.txt inflection==0.5.1 # via # -r requirements/edx/base.txt # drf-spectacular # drf-yasg -interchange==2021.0.4 - # via - # -r requirements/edx/base.txt - # py2neo ipaddress==1.0.23 # via -r requirements/edx/base.txt isodate==0.7.2 # via # -r requirements/edx/base.txt # python3-saml -jinja2==3.1.4 +jinja2==3.1.6 # via # -r requirements/edx/base.txt # code-annotations # sphinx + # sphinx-autoapi jmespath==1.0.1 # via # -r requirements/edx/base.txt # boto3 # botocore -joblib==1.4.2 +joblib==1.5.1 # via # -r requirements/edx/base.txt # nltk @@ -810,15 +824,16 @@ jsonfield==3.1.0 # edx-enterprise # edx-proctoring # edx-submissions + # enterprise-integrated-channels # lti-consumer-xblock # ora2 -jsonschema==4.23.0 +jsonschema==4.24.0 # via # -r requirements/edx/base.txt # drf-spectacular # optimizely-sdk # sphinxcontrib-openapi -jsonschema-specifications==2024.10.1 +jsonschema-specifications==2025.4.1 # via # -r requirements/edx/base.txt # jsonschema @@ -827,7 +842,7 @@ jwcrypto==1.5.6 # -r requirements/edx/base.txt # django-oauth-toolkit # pylti1p3 -kombu==5.4.2 +kombu==5.5.4 # via # -r requirements/edx/base.txt # celery @@ -840,18 +855,15 @@ lazy==1.6 # lti-consumer-xblock # ora2 # xblock -libsass==0.10.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/base.txt loremipsum==1.0.5 # via # -r requirements/edx/base.txt # ora2 -lti-consumer-xblock==9.11.3 +lti-consumer-xblock==9.14.0 # via -r requirements/edx/base.txt -lxml[html-clean]==5.3.0 +lxml[html-clean]==5.3.2 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-i18n-tools # edxval @@ -863,22 +875,21 @@ lxml[html-clean]==5.3.0 # python3-saml # xblock # xmlsec -lxml-html-clean==0.3.1 +lxml-html-clean==0.4.2 # via # -r requirements/edx/base.txt # lxml mailsnake==1.6.4 # via -r requirements/edx/base.txt -mako==1.3.6 +mako==1.3.10 # via # -r requirements/edx/base.txt # acid-xblock # lti-consumer-xblock # xblock # xblock-utils -markdown==3.3.7 +markdown==3.8 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # openedx-django-wiki # staff-graded-xblock @@ -891,26 +902,23 @@ markupsafe==3.0.2 # mako # openedx-calc # xblock -maxminddb==2.6.2 +maxminddb==2.7.0 # via # -r requirements/edx/base.txt # geoip2 -meilisearch==0.31.6 +meilisearch==0.34.1 # via # -r requirements/edx/base.txt # edx-search -mistune==3.0.2 +mistune==3.1.3 # via sphinx-mdinclude -mock==5.1.0 - # via -r requirements/edx/base.txt mongoengine==0.29.1 # via -r requirements/edx/base.txt monotonic==1.6 # via # -r requirements/edx/base.txt # analytics-python - # py2neo -more-itertools==10.5.0 +more-itertools==10.7.0 # via # -r requirements/edx/base.txt # cssutils @@ -922,19 +930,19 @@ msgpack==1.1.0 # via # -r requirements/edx/base.txt # cachecontrol -multidict==6.1.0 +multidict==6.4.4 # via # -r requirements/edx/base.txt # aiohttp # yarl -mysqlclient==2.2.5 - # via -r requirements/edx/base.txt -newrelic==10.2.0 +mysqlclient==2.2.7 # via # -r requirements/edx/base.txt - # edx-django-utils -nh3==0.2.18 - # via -r requirements/edx/base.txt + # openedx-forum +nh3==0.2.21 + # via + # -r requirements/edx/base.txt + # xblocks-contrib nltk==3.9.1 # via # -r requirements/edx/base.txt @@ -956,6 +964,7 @@ oauthlib==3.2.2 # lti-consumer-xblock # requests-oauthlib # social-auth-core + # xblocks-contrib olxcleaner==0.3.0 # via -r requirements/edx/base.txt openai==0.28.1 @@ -963,20 +972,24 @@ openai==0.28.1 # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-enterprise -openedx-atlas==0.6.2 +openedx-atlas==0.7.0 + # via + # -r requirements/edx/base.txt + # enterprise-integrated-channels + # openedx-forum +openedx-calc==4.0.2 # via -r requirements/edx/base.txt -openedx-calc==3.1.2 - # via -r requirements/edx/base.txt -openedx-django-pyfs==3.7.0 +openedx-django-pyfs==3.8.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock # xblock + # xblocks-contrib openedx-django-require==2.1.0 # via -r requirements/edx/base.txt -openedx-django-wiki==2.1.0 +openedx-django-wiki==3.1.1 # via -r requirements/edx/base.txt -openedx-events==9.15.0 +openedx-events==10.2.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -985,36 +998,31 @@ openedx-events==9.15.0 # edx-name-affirmation # event-tracking # ora2 -openedx-filters==1.11.0 +openedx-filters==2.1.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.17.0 +openedx-forum==0.3.0 + # via -r requirements/edx/base.txt +openedx-learning==0.27.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -openedx-mongodbproxy==0.2.2 +optimizely-sdk==5.2.0 # via -r requirements/edx/base.txt -optimizely-sdk==4.1.1 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/base.txt -ora2==6.14.0 +ora2==6.16.3 # via -r requirements/edx/base.txt -packaging==24.1 +packaging==25.0 # via # -r requirements/edx/base.txt # drf-yasg # gunicorn - # py2neo + # kombu + # pydata-sphinx-theme # snowflake-connector-python # sphinx -pansi==2020.7.3 - # via - # -r requirements/edx/base.txt - # py2neo -paramiko==3.5.0 +paramiko==3.5.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1030,9 +1038,7 @@ path-py==12.5.0 # edx-enterprise # ora2 # staff-graded-xblock -paver==1.3.4 - # via -r requirements/edx/base.txt -pbr==6.1.0 +pbr==6.1.1 # via # -r requirements/edx/base.txt # stevedore @@ -1044,13 +1050,13 @@ picobox==4.0.0 # via sphinxcontrib-openapi piexif==1.1.3 # via -r requirements/edx/base.txt -pillow==11.0.0 +pillow==11.2.1 # via # -r requirements/edx/base.txt # edx-enterprise # edx-organizations # edxval -platformdirs==4.3.6 +platformdirs==4.3.8 # via # -r requirements/edx/base.txt # snowflake-connector-python @@ -1058,20 +1064,21 @@ polib==1.2.0 # via # -r requirements/edx/base.txt # edx-i18n-tools -prompt-toolkit==3.0.48 +prompt-toolkit==3.0.51 # via # -r requirements/edx/base.txt # click-repl -propcache==0.2.0 +propcache==0.3.1 # via # -r requirements/edx/base.txt + # aiohttp # yarl -proto-plus==1.25.0 +proto-plus==1.26.1 # via # -r requirements/edx/base.txt # google-api-core # google-cloud-firestore -protobuf==5.28.3 +protobuf==6.31.1 # via # -r requirements/edx/base.txt # google-api-core @@ -1079,21 +1086,17 @@ protobuf==5.28.3 # googleapis-common-protos # grpcio-status # proto-plus -psutil==6.1.0 +psutil==7.0.0 # via # -r requirements/edx/base.txt # edx-django-utils -py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/base.txt pyasn1==0.6.1 # via # -r requirements/edx/base.txt # pgpy # pyasn1-modules # rsa -pyasn1-modules==0.4.1 +pyasn1-modules==0.4.2 # via # -r requirements/edx/base.txt # google-auth @@ -1103,36 +1106,33 @@ pycparser==2.22 # via # -r requirements/edx/base.txt # cffi -pycryptodomex==3.21.0 +pycryptodomex==3.23.0 # via # -r requirements/edx/base.txt # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.9.2 +pydantic==2.11.5 # via # -r requirements/edx/base.txt # camel-converter -pydantic-core==2.23.4 +pydantic-core==2.33.2 # via # -r requirements/edx/base.txt # pydantic -pydata-sphinx-theme==0.16.0 +pydata-sphinx-theme==0.15.4 # via sphinx-book-theme -pygments==2.18.0 +pygments==2.19.1 # via - # -r requirements/edx/base.txt # accessible-pygments - # py2neo # pydata-sphinx-theme # sphinx # sphinx-mdinclude pyjwkest==1.4.2 # via # -r requirements/edx/base.txt - # edx-token-utils # lti-consumer-xblock -pyjwt[crypto]==2.9.0 +pyjwt[crypto]==2.10.1 # via # -r requirements/edx/base.txt # drf-jwt @@ -1141,6 +1141,7 @@ pyjwt[crypto]==2.9.0 # edx-proctoring # edx-rest-api-client # firebase-admin + # lti-consumer-xblock # pylti1p3 # snowflake-connector-python # social-auth-core @@ -1159,7 +1160,7 @@ pymongo==4.4.0 # edx-opaque-keys # event-tracking # mongoengine - # openedx-mongodbproxy + # openedx-forum pynacl==1.5.0 # via # -r requirements/edx/base.txt @@ -1167,12 +1168,11 @@ pynacl==1.5.0 # paramiko pynliner==0.8.0 # via -r requirements/edx/base.txt -pyopenssl==24.2.1 +pyopenssl==25.1.0 # via # -r requirements/edx/base.txt - # optimizely-sdk # snowflake-connector-python -pyparsing==3.2.0 +pyparsing==3.2.3 # via # -r requirements/edx/base.txt # chem @@ -1203,13 +1203,11 @@ python-ipware==3.0.0 # via # -r requirements/edx/base.txt # django-ipware -python-memcached==1.62 - # via -r requirements/edx/base.txt python-slugify==8.0.4 # via # -r requirements/edx/base.txt # code-annotations -python-swiftclient==4.6.0 +python-swiftclient==4.8.0 # via # -r requirements/edx/base.txt # ora2 @@ -1219,7 +1217,7 @@ python3-openid==3.2.0 ; python_version >= "3" # social-auth-core python3-saml==1.16.0 # via -r requirements/edx/base.txt -pytz==2024.2 +pytz==2025.2 # via # -r requirements/edx/base.txt # djangorestframework @@ -1229,9 +1227,9 @@ pytz==2024.2 # edx-proctoring # edx-submissions # edx-tincan-py35 + # enterprise-integrated-channels # event-tracking # fs - # interchange # olxcleaner # ora2 # snowflake-connector-python @@ -1247,29 +1245,29 @@ pyyaml==6.0.2 # edx-django-release-util # edx-i18n-tools # jsondiff + # sphinx-autoapi # sphinxcontrib-openapi # xblock random2==1.0.2 # via -r requirements/edx/base.txt -recommender-xblock==3.0.0 +recommender-xblock==3.1.0 # via -r requirements/edx/base.txt -redis==5.2.0 +redis==6.2.0 # via # -r requirements/edx/base.txt # walrus -referencing==0.35.1 +referencing==0.36.2 # via # -r requirements/edx/base.txt # jsonschema # jsonschema-specifications -regex==2024.9.11 +regex==2024.11.6 # via # -r requirements/edx/base.txt # nltk requests==2.32.3 # via # -r requirements/edx/base.txt - # algoliasearch # analytics-python # cachecontrol # django-oauth-toolkit @@ -1277,12 +1275,14 @@ requests==2.32.3 # edx-drf-extensions # edx-enterprise # edx-rest-api-client + # enterprise-integrated-channels # geoip2 # google-api-core # google-cloud-storage # mailsnake # meilisearch # openai + # openedx-forum # optimizely-sdk # pyjwkest # pylti1p3 @@ -1298,12 +1298,14 @@ requests-oauthlib==2.0.0 # via # -r requirements/edx/base.txt # social-auth-core -rpds-py==0.20.0 +roman-numerals-py==3.1.0 + # via sphinx +rpds-py==0.25.1 # via # -r requirements/edx/base.txt # jsonschema # referencing -rsa==4.9 +rsa==4.9.1 # via # -r requirements/edx/base.txt # google-auth @@ -1313,7 +1315,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-learning -s3transfer==0.10.3 +s3transfer==0.13.0 # via # -r requirements/edx/base.txt # boto3 @@ -1321,29 +1323,27 @@ sailthru-client==2.2.3 # via # -r requirements/edx/base.txt # edx-ace -scipy==1.14.1 +scipy==1.15.3 # via # -r requirements/edx/base.txt # chem - # openedx-calc semantic-version==2.10.0 # via # -r requirements/edx/base.txt # edx-drf-extensions -shapely==2.0.6 +shapely==2.1.1 # via -r requirements/edx/base.txt -simplejson==3.19.3 +simplejson==3.20.1 # via # -r requirements/edx/base.txt # sailthru-client # super-csv # xblock # xblock-utils -six==1.16.0 +six==1.17.0 # via # -r requirements/edx/base.txt # analytics-python - # bleach # codejail-includes # crowdsourcehinter-xblock # edx-ace @@ -1357,12 +1357,6 @@ six==1.16.0 # fs # fs-s3fs # html5lib - # interchange - # libsass - # optimizely-sdk - # pansi - # paver - # py2neo # pyjwkest # python-dateutil # sphinxcontrib-httpdomain @@ -1371,11 +1365,12 @@ slumber==0.7.1 # -r requirements/edx/base.txt # edx-bulk-grades # edx-enterprise -smmap==5.0.1 + # enterprise-integrated-channels +smmap==5.0.2 # via gitdb -snowballstemmer==2.2.0 +snowballstemmer==3.0.1 # via sphinx -snowflake-connector-python==3.12.3 +snowflake-connector-python==3.15.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1386,6 +1381,7 @@ social-auth-app-django==5.4.1 # edx-auth-backends social-auth-core==4.5.4 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-auth-backends # social-auth-app-django @@ -1397,14 +1393,15 @@ sortedcontainers==2.4.0 # via # -r requirements/edx/base.txt # snowflake-connector-python -soupsieve==2.6 +soupsieve==2.7 # via # -r requirements/edx/base.txt # beautifulsoup4 -sphinx==8.1.3 +sphinx==8.2.3 # via # -r requirements/edx/doc.in # pydata-sphinx-theme + # sphinx-autoapi # sphinx-book-theme # sphinx-design # sphinx-mdinclude @@ -1412,13 +1409,15 @@ sphinx==8.1.3 # sphinxcontrib-httpdomain # sphinxcontrib-openapi # sphinxext-rediraffe -sphinx-book-theme==1.1.3 +sphinx-autoapi==3.6.0 + # via -r requirements/edx/doc.in +sphinx-book-theme==1.1.4 # via -r requirements/edx/doc.in sphinx-design==0.6.1 # via -r requirements/edx/doc.in sphinx-mdinclude==0.6.2 # via sphinxcontrib-openapi -sphinx-reredirects==0.1.5 +sphinx-reredirects==1.0.0 # via -r requirements/edx/doc.in sphinxcontrib-applehelp==2.0.0 # via sphinx @@ -1438,13 +1437,13 @@ sphinxcontrib-serializinghtml==2.0.0 # via sphinx sphinxext-rediraffe==0.2.7 # via -r requirements/edx/doc.in -sqlparse==0.5.1 +sqlparse==0.5.3 # via # -r requirements/edx/base.txt # django -staff-graded-xblock==2.3.0 +staff-graded-xblock==3.1.0 # via -r requirements/edx/base.txt -stevedore==5.3.0 +stevedore==5.4.1 # via # -r requirements/edx/base.txt # code-annotations @@ -1452,11 +1451,11 @@ stevedore==5.3.0 # edx-django-utils # edx-enterprise # edx-opaque-keys -super-csv==3.2.0 +super-csv==4.1.0 # via # -r requirements/edx/base.txt # edx-bulk-grades -sympy==1.13.3 +sympy==1.14.0 # via # -r requirements/edx/base.txt # openedx-calc @@ -1468,22 +1467,24 @@ text-unidecode==1.3 # via # -r requirements/edx/base.txt # python-slugify -tinycss2==1.2.1 +tinycss2==1.4.0 # via # -r requirements/edx/base.txt # bleach tomlkit==0.13.2 # via # -r requirements/edx/base.txt + # openedx-learning # snowflake-connector-python -tqdm==4.66.6 +tqdm==4.67.1 # via # -r requirements/edx/base.txt # nltk # openai -typing-extensions==4.12.2 +typing-extensions==4.14.0 # via # -r requirements/edx/base.txt + # beautifulsoup4 # django-countries # edx-opaque-keys # jwcrypto @@ -1491,18 +1492,27 @@ typing-extensions==4.12.2 # pydantic-core # pydata-sphinx-theme # pylti1p3 + # pyopenssl + # referencing # snowflake-connector-python -tzdata==2024.2 + # typing-inspection +typing-inspection==0.4.1 + # via + # -r requirements/edx/base.txt + # pydantic +tzdata==2025.2 # via # -r requirements/edx/base.txt - # celery # icalendar # kombu unicodecsv==0.14.1 # via # -r requirements/edx/base.txt # edx-enterprise -uritemplate==4.1.1 + # enterprise-integrated-channels +unicodeit==0.7.5 + # via -r requirements/edx/base.txt +uritemplate==4.2.0 # via # -r requirements/edx/base.txt # drf-spectacular @@ -1510,12 +1520,12 @@ uritemplate==4.1.1 # google-api-python-client urllib3==2.2.3 # via + # -c requirements/edx/../common_constraints.txt # -r requirements/edx/base.txt # botocore # elasticsearch - # py2neo # requests -user-util==1.1.0 +user-util==2.0.0 # via -r requirements/edx/base.txt vine==5.1.0 # via @@ -1531,13 +1541,11 @@ walrus==0.9.4 # via # -r requirements/edx/base.txt # edx-event-bus-redis -watchdog==5.0.3 - # via -r requirements/edx/base.txt wcwidth==0.2.13 # via # -r requirements/edx/base.txt # prompt-toolkit -web-fragments==2.2.0 +web-fragments==3.1.0 # via # -r requirements/edx/base.txt # crowdsourcehinter-xblock @@ -1555,9 +1563,13 @@ webob==1.8.9 # via # -r requirements/edx/base.txt # xblock -wrapt==1.16.0 +wheel==0.45.1 + # via + # -r requirements/edx/base.txt + # django-pipeline +wrapt==1.17.2 # via -r requirements/edx/base.txt -xblock[django]==5.1.0 +xblock[django]==5.2.0 # via # -r requirements/edx/base.txt # acid-xblock @@ -1572,28 +1584,32 @@ xblock[django]==5.1.0 # xblock-drag-and-drop-v2 # xblock-google-drive # xblock-utils -xblock-drag-and-drop-v2==4.0.3 + # xblocks-contrib +xblock-drag-and-drop-v2==5.0.2 # via -r requirements/edx/base.txt -xblock-google-drive==0.7.0 +xblock-google-drive==0.8.1 # via -r requirements/edx/base.txt -xblock-poll==1.14.0 +xblock-poll==1.15.1 # via -r requirements/edx/base.txt xblock-utils==4.0.0 # via # -r requirements/edx/base.txt # edx-sga # xblock-poll +xblocks-contrib==0.4.0 + # via -r requirements/edx/base.txt xmlsec==1.3.14 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # python3-saml -xss-utils==0.6.0 +xss-utils==0.8.0 # via -r requirements/edx/base.txt -yarl==1.17.0 +yarl==1.20.0 # via # -r requirements/edx/base.txt # aiohttp -zipp==3.20.2 +zipp==3.22.0 # via # -r requirements/edx/base.txt # importlib-metadata diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index 7323c243ac..201ef61002 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -3,7 +3,6 @@ -c ../constraints.txt -r github.in # Forks and other dependencies not yet on PyPI --r paver.txt # Requirements for running paver commands # DON'T JUST ADD NEW DEPENDENCIES!!! # Please follow these guidelines whenever you change this file: @@ -34,6 +33,7 @@ codejail-includes # CodeJail manages execution of untrusted co cryptography # Implementations of assorted cryptography algorithms defusedxml Django # Web application framework +django-autocomplete-light # Enhances Django admin with single-select autocomplete dropdowns for a better user experience. django-celery-results # Only used for the CacheBackend for celery results django-config-models # Configuration models for Django allowing config management with auditing django-cors-headers # Used to allow to configure CORS headers for cross-domain requests @@ -66,7 +66,8 @@ edx-celeryutils edx-completion edx-django-release-util # Release utils for the edx release pipeline edx-django-sites-extensions -edx-codejail +# Codejail 4 brings important safety improvements (no unsafe mode by default) +edx-codejail>=4.0.0 # edx-django-utils 5.14.1 adds FrontendMonitoringMiddleware edx-django-utils>=5.14.1 # Utilities for cache, monitoring, and plugins edx-drf-extensions @@ -76,7 +77,7 @@ edx-event-bus-kafka>=5.6.0 # Kafka implementation of event bus edx-event-bus-redis edx-milestones edx-name-affirmation -edx-opaque-keys +edx-opaque-keys>=2.12.0 edx-organizations edx-proctoring>=2.0.1 # using hash to support django42 @@ -85,7 +86,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 @@ -119,12 +119,13 @@ openedx-calc # Library supporting mathematical calculatio openedx-django-require openedx-events # Open edX Events from Hooks Extension Framework (OEP-50) openedx-filters # Open edX Filters from Hooks Extension Framework (OEP-50) +openedx-forum # Open edX forum v2 application openedx-learning # Open edX Learning core (experimental) -openedx-mongodbproxy openedx-django-wiki path piexif # Exif image metadata manipulation, used in the profile_images app Pillow # Image manipulation library; used for course assets, profile images, invoice PDFs, etc. +psutil # Library for retrieving information on running processes and system utilization pycountry pycryptodomex pyjwkest @@ -132,6 +133,7 @@ pyjwkest # PyJWT 1.6.3 contains PyJWTError, which is required by Apple auth in social-auth-core PyJWT>=1.6.3 pylti1p3 # Required by content_libraries core library to support LTI 1.3 launches +pymemcache # Python interface to the memcached memory cache daemon pymongo # MongoDB driver pynliner # Inlines CSS styles into HTML for email notifications python-dateutil @@ -158,5 +160,7 @@ unicodecsv # Easier support for CSV files with unicode user-util # Functionality for retiring users (GDPR compliance) webob web-fragments # Provides the ability to render fragments of web pages +wrapt # Better functools.wrapped. TODO: functools has since improved, maybe we can switch? XBlock[django] # Courseware component architecture xss-utils # https://github.com/openedx/edx-platform/pull/20633 Fix XSS via Translations +unicodeit # Converts mathjax equation to plain text by using unicode symbols diff --git a/requirements/edx/paver.in b/requirements/edx/paver.in deleted file mode 100644 index 6987ede822..0000000000 --- a/requirements/edx/paver.in +++ /dev/null @@ -1,27 +0,0 @@ -# Requirements to run and test Paver -# -# DON'T JUST ADD NEW DEPENDENCIES!!! -# -# If you open a pull request that adds a new dependency, you should: -# * verify that the dependency has a license compatible with AGPLv3 -# * confirm that it has no system requirements beyond what we already install -# * run "make upgrade" to update the detailed requirements files -# - --c ../constraints.txt - -edx-opaque-keys # Create and introspect course and xblock identities -lazy # Lazily-evaluated attributes for Python objects -libsass # Python bindings for the LibSass CSS compiler -markupsafe # XML/HTML/XHTML Markup safe strings -mock # Stub out code with mock objects and make assertions about how they have been used -path # Easier manipulation of filesystem paths -paver # Build, distribution and deployment scripting tool -psutil # Library for retrieving information on running processes and system utilization -pymongo # via edx-opaque-keys -python-memcached # Python interface to the memcached memory cache daemon -pymemcache # Python interface to the memcached memory cache daemon -requests # Simple interface for making HTTP requests -stevedore # Support for runtime plugins, used for XBlocks and edx-platform Django app plugins -watchdog # Used in paver watch_assets -wrapt # Decorator utilities used in the @timed paver task decorator diff --git a/requirements/edx/paver.txt b/requirements/edx/paver.txt deleted file mode 100644 index f3dae3b0ef..0000000000 --- a/requirements/edx/paver.txt +++ /dev/null @@ -1,65 +0,0 @@ -# -# This file is autogenerated by pip-compile with Python 3.11 -# by the following command: -# -# make upgrade -# -certifi==2024.8.30 - # via requests -charset-normalizer==2.0.12 - # via - # -c requirements/edx/../constraints.txt - # requests -dnspython==2.7.0 - # via pymongo -edx-opaque-keys==2.11.0 - # via -r requirements/edx/paver.in -idna==3.10 - # via requests -lazy==1.6 - # via -r requirements/edx/paver.in -libsass==0.10.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/paver.in -markupsafe==3.0.2 - # via -r requirements/edx/paver.in -mock==5.1.0 - # via -r requirements/edx/paver.in -path==16.11.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/paver.in -paver==1.3.4 - # via -r requirements/edx/paver.in -pbr==6.1.0 - # via stevedore -psutil==6.1.0 - # via -r requirements/edx/paver.in -pymemcache==4.0.0 - # via -r requirements/edx/paver.in -pymongo==4.4.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/paver.in - # edx-opaque-keys -python-memcached==1.62 - # via -r requirements/edx/paver.in -requests==2.32.3 - # via -r requirements/edx/paver.in -six==1.16.0 - # via - # libsass - # paver -stevedore==5.3.0 - # via - # -r requirements/edx/paver.in - # edx-opaque-keys -typing-extensions==4.12.2 - # via edx-opaque-keys -urllib3==2.2.3 - # via requests -watchdog==5.0.3 - # via -r requirements/edx/paver.in -wrapt==1.16.0 - # via -r requirements/edx/paver.in diff --git a/requirements/edx/semgrep.txt b/requirements/edx/semgrep.txt index 61db06bbf1..79ebdf43c5 100644 --- a/requirements/edx/semgrep.txt +++ b/requirements/edx/semgrep.txt @@ -4,7 +4,7 @@ # # make upgrade # -attrs==24.2.0 +attrs==25.3.0 # via # glom # jsonschema @@ -17,42 +17,39 @@ boltons==21.0.0 # semgrep bracex==2.5.post1 # via wcmatch -certifi==2024.8.30 +certifi==2025.4.26 # via requests -charset-normalizer==2.0.12 +charset-normalizer==3.4.2 + # via requests +click==8.1.8 # via - # -c requirements/edx/../constraints.txt - # requests -click==8.1.6 - # via - # -c requirements/edx/../constraints.txt # click-option-group # semgrep -click-option-group==0.5.6 +click-option-group==0.5.7 # via semgrep colorama==0.4.6 # via semgrep defusedxml==0.7.1 # via semgrep -deprecated==1.2.14 +deprecated==1.2.18 # via # opentelemetry-api # opentelemetry-exporter-otlp-proto-http exceptiongroup==1.2.2 # via semgrep -face==22.0.0 +face==24.0.0 # via glom glom==22.1.0 # via semgrep -googleapis-common-protos==1.65.0 +googleapis-common-protos==1.70.0 # via opentelemetry-exporter-otlp-proto-http idna==3.10 # via requests importlib-metadata==7.1.0 # via opentelemetry-api -jsonschema==4.23.0 +jsonschema==4.24.0 # via semgrep -jsonschema-specifications==2024.10.1 +jsonschema-specifications==2025.4.1 # via jsonschema markdown-it-py==3.0.0 # via rich @@ -88,17 +85,17 @@ opentelemetry-semantic-conventions==0.46b0 # opentelemetry-sdk opentelemetry-util-http==0.46b0 # via opentelemetry-instrumentation-requests -packaging==24.1 +packaging==25.0 # via semgrep -peewee==3.17.7 +peewee==3.18.1 # via semgrep -protobuf==4.25.5 +protobuf==4.25.8 # via # googleapis-common-protos # opentelemetry-proto -pygments==2.18.0 +pygments==2.19.1 # via rich -referencing==0.35.1 +referencing==0.36.2 # via # jsonschema # jsonschema-specifications @@ -108,33 +105,35 @@ requests==2.32.3 # semgrep rich==13.5.3 # via semgrep -rpds-py==0.20.0 +rpds-py==0.25.1 # via # jsonschema # referencing -ruamel-yaml==0.17.40 +ruamel-yaml==0.18.12 # via semgrep ruamel-yaml-clib==0.2.12 # via ruamel-yaml -semgrep==1.93.0 +semgrep==1.123.0 # via -r requirements/edx/semgrep.in tomli==2.0.2 # via semgrep -typing-extensions==4.12.2 +typing-extensions==4.14.0 # via # opentelemetry-sdk + # referencing # semgrep urllib3==2.2.3 # via + # -c requirements/edx/../common_constraints.txt # requests # semgrep wcmatch==8.5.2 # via semgrep -wrapt==1.16.0 +wrapt==1.17.2 # via # deprecated # opentelemetry-instrumentation -zipp==3.20.2 +zipp==3.22.0 # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/edx/testing.in b/requirements/edx/testing.in index b903768f4d..14a0c781da 100644 --- a/requirements/edx/testing.in +++ b/requirements/edx/testing.in @@ -28,6 +28,7 @@ freezegun # Allows tests to mock the output of assorted datetime httpretty # Library for mocking HTTP requests, used in many tests import-linter # Tool for making assertions about which modules can import which others isort # For checking and fixing the order of imports +mock # Deprecated alias to standard library `unittest.mock` pycodestyle # Checker for compliance with the Python style guide (PEP 8) polib # Library for manipulating gettext translation files, used to test paver i18n commands pyquery # jQuery-like API for retrieving fragments of HTML and XML files in tests @@ -36,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 c56636395b..9c48c3c00a 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -8,30 +8,26 @@ # via -r requirements/edx/base.txt acid-xblock==0.4.1 # via -r requirements/edx/base.txt -aiohappyeyeballs==2.4.3 +aiohappyeyeballs==2.6.1 # via # -r requirements/edx/base.txt # aiohttp -aiohttp==3.10.10 +aiohttp==3.12.8 # via # -r requirements/edx/base.txt # geoip2 # openai -aiosignal==1.3.1 +aiosignal==1.3.2 # via # -r requirements/edx/base.txt # aiohttp -algoliasearch==3.0.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/base.txt -amqp==5.2.0 +amqp==5.3.1 # via # -r requirements/edx/base.txt # kombu analytics-python==1.4.post1 # via -r requirements/edx/base.txt -aniso8601==9.0.1 +aniso8601==10.0.1 # via # -r requirements/edx/base.txt # edx-tincan-py35 @@ -39,8 +35,10 @@ annotated-types==0.7.0 # via # -r requirements/edx/base.txt # pydantic -anyio==4.6.2.post1 - # via starlette +anyio==4.9.0 + # via + # httpcore + # starlette appdirs==1.4.4 # via # -r requirements/edx/base.txt @@ -55,11 +53,11 @@ asn1crypto==1.5.1 # via # -r requirements/edx/base.txt # snowflake-connector-python -astroid==2.13.5 +astroid==3.3.10 # via # pylint # pylint-celery -attrs==24.2.0 +attrs==25.3.0 # via # -r requirements/edx/base.txt # aiohttp @@ -69,7 +67,7 @@ attrs==24.2.0 # openedx-events # openedx-learning # referencing -babel==2.16.0 +babel==2.17.0 # via # -r requirements/edx/base.txt # enmerkar @@ -78,20 +76,21 @@ backoff==1.10.0 # via # -r requirements/edx/base.txt # analytics-python -bcrypt==4.2.0 +bcrypt==4.3.0 # via # -r requirements/edx/base.txt # paramiko -beautifulsoup4==4.12.3 +beautifulsoup4==4.13.4 # via # -r requirements/edx/base.txt # -r requirements/edx/testing.in + # openedx-forum # pynliner billiard==4.2.1 # via # -r requirements/edx/base.txt # celery -bleach[css]==6.1.0 +bleach[css]==6.2.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -102,33 +101,36 @@ bleach[css]==6.1.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/base.txt -boto3==1.35.50 +boto3==1.38.29 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.35.50 + # snowflake-connector-python +botocore==1.38.29 # via # -r requirements/edx/base.txt # boto3 # s3transfer + # snowflake-connector-python bridgekeeper==0.9 # via -r requirements/edx/base.txt -cachecontrol==0.14.0 +cachecontrol==0.14.3 # via # -r requirements/edx/base.txt # firebase-admin -cachetools==5.5.0 +cachetools==5.5.2 # via # -r requirements/edx/base.txt + # edxval # google-auth # tox camel-converter[pydantic]==4.0.1 # via # -r requirements/edx/base.txt # meilisearch -celery==5.4.0 +celery==5.5.3 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -136,20 +138,21 @@ celery==5.4.0 # django-user-tasks # edx-celeryutils # edx-enterprise + # enterprise-integrated-channels # event-tracking # openedx-learning -certifi==2024.8.30 +certifi==2025.4.26 # via # -r requirements/edx/base.txt # elasticsearch - # py2neo + # httpcore + # httpx # requests # snowflake-connector-python cffi==1.17.1 # via # -r requirements/edx/base.txt # cryptography - # pact-python # pynacl # snowflake-connector-python chardet==5.2.0 @@ -159,17 +162,15 @@ chardet==5.2.0 # diff-cover # pysrt # tox -charset-normalizer==2.0.12 +charset-normalizer==3.4.2 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # requests # snowflake-connector-python -chem==1.3.0 +chem==2.0.0 # via -r requirements/edx/base.txt -click==8.1.6 +click==8.2.1 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # celery # click-didyoumean @@ -198,7 +199,7 @@ click-repl==0.3.0 # via # -r requirements/edx/base.txt # celery -code-annotations==1.8.0 +code-annotations==2.3.0 # via # -r requirements/edx/base.txt # -r requirements/edx/testing.in @@ -209,26 +210,25 @@ codejail-includes==1.0.0 # via -r requirements/edx/base.txt colorama==0.4.6 # via tox -coverage[toml]==7.6.4 +coverage[toml]==7.8.2 # via # -r requirements/edx/coverage.txt # pytest-cov crowdsourcehinter-xblock==0.8 # via -r requirements/edx/base.txt -cryptography==43.0.3 +cryptography==45.0.3 # via # -r requirements/edx/base.txt # django-fernet-fields-v2 # edx-enterprise # jwcrypto - # optimizely-sdk # paramiko # pgpy # pyjwt # pyopenssl # snowflake-connector-python # social-auth-core -cssselect==1.2.0 +cssselect==1.3.0 # via # -r requirements/edx/testing.in # pyquery @@ -245,18 +245,19 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -diff-cover==9.2.0 +diff-cover==9.3.2 # via -r requirements/edx/coverage.txt -dill==0.3.9 +dill==0.4.0 # via pylint distlib==0.3.9 # via virtualenv -django==4.2.16 +django==4.2.22 # via # -c requirements/edx/../common_constraints.txt # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # django-appconf + # django-autocomplete-light # django-celery-results # django-classy-tags # django-config-models @@ -273,6 +274,7 @@ django==4.2.16 # django-push-notifications # django-sekizai # django-ses + # django-simple-history # django-statici18n # django-storages # django-user-tasks @@ -304,11 +306,11 @@ django==4.2.16 # edx-search # edx-submissions # edx-toggles - # edx-token-utils # edx-when # edxval # enmerkar # enmerkar-underscore + # enterprise-integrated-channels # event-tracking # help-tokens # jsonfield @@ -317,33 +319,37 @@ django==4.2.16 # openedx-django-wiki # openedx-events # openedx-filters + # openedx-forum # openedx-learning # ora2 # social-auth-app-django # super-csv # xblock-google-drive # xss-utils -django-appconf==1.0.6 +django-appconf==1.1.0 # via # -r requirements/edx/base.txt # django-statici18n -django-cache-memoize==0.2.0 +django-autocomplete-light==3.12.1 + # via -r requirements/edx/base.txt +django-cache-memoize==0.2.1 # via # -r requirements/edx/base.txt # edx-enterprise -django-celery-results==2.5.1 +django-celery-results==2.6.0 # via -r requirements/edx/base.txt django-classy-tags==4.1.0 # via # -r requirements/edx/base.txt # django-sekizai -django-config-models==2.7.0 +django-config-models==2.9.0 # via # -r requirements/edx/base.txt # edx-enterprise # edx-name-affirmation + # enterprise-integrated-channels # lti-consumer-xblock -django-cors-headers==4.5.0 +django-cors-headers==4.7.0 # via -r requirements/edx/base.txt django-countries==7.6.1 # via @@ -362,7 +368,8 @@ django-fernet-fields-v2==0.9 # via # -r requirements/edx/base.txt # edx-enterprise -django-filter==24.3 + # enterprise-integrated-channels +django-filter==25.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -372,7 +379,7 @@ django-ipware==7.0.1 # -r requirements/edx/base.txt # edx-enterprise # edx-proctoring -django-js-asset==2.2.0 +django-js-asset==3.1.2 # via # -r requirements/edx/base.txt # django-mptt @@ -394,30 +401,33 @@ django-model-utils==5.0.0 # edx-submissions # edx-when # edxval + # enterprise-integrated-channels # ora2 # super-csv -django-mptt==0.16.0 +django-mptt==0.17.0 # via # -r requirements/edx/base.txt # openedx-django-wiki -django-multi-email-field==0.7.0 +django-multi-email-field==0.8.0 # via # -r requirements/edx/base.txt # edx-enterprise -django-mysql==4.14.0 +django-mysql==4.17.0 # via -r requirements/edx/base.txt django-oauth-toolkit==1.7.1 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-enterprise -django-object-actions==4.3.0 + # enterprise-integrated-channels +django-object-actions==5.0.0 # via # -r requirements/edx/base.txt # edx-enterprise -django-pipeline==3.1.0 + # enterprise-integrated-channels +django-pipeline==4.0.0 # via -r requirements/edx/base.txt -django-push-notifications==3.1.0 +django-push-notifications==3.2.1 # via # -r requirements/edx/base.txt # edx-ace @@ -427,31 +437,31 @@ django-sekizai==4.1.0 # via # -r requirements/edx/base.txt # openedx-django-wiki -django-ses==4.2.0 +django-ses==4.4.0 # via -r requirements/edx/base.txt -django-simple-history==3.4.0 +django-simple-history==3.8.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-enterprise # edx-name-affirmation # edx-organizations # edx-proctoring + # enterprise-integrated-channels # ora2 -django-statici18n==2.5.0 +django-statici18n==2.6.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock # xblock-drag-and-drop-v2 # xblock-poll -django-storages==1.14.3 + # xblocks-contrib +django-storages==1.14.6 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edxval -django-user-tasks==3.2.0 +django-user-tasks==3.4.1 # via -r requirements/edx/base.txt -django-waffle==4.1.0 +django-waffle==4.2.0 # via # -r requirements/edx/base.txt # edx-django-utils @@ -481,6 +491,7 @@ djangorestframework==3.14.0 # edx-organizations # edx-proctoring # edx-submissions + # openedx-forum # openedx-learning # ora2 # super-csv @@ -492,57 +503,53 @@ dnspython==2.7.0 # via # -r requirements/edx/base.txt # pymongo -done-xblock==2.4.0 +done-xblock==2.5.0 # via -r requirements/edx/base.txt drf-jwt==1.19.2 # via # -r requirements/edx/base.txt # edx-drf-extensions -drf-spectacular==0.27.2 +drf-spectacular==0.28.0 # via -r requirements/edx/base.txt -drf-yasg==1.21.8 +drf-yasg==1.21.10 # via # -r requirements/edx/base.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.3 +edx-ace==1.15.0 # via -r requirements/edx/base.txt -edx-api-doc-tools==2.0.0 +edx-api-doc-tools==2.1.0 # via # -r requirements/edx/base.txt # edx-name-affirmation -edx-auth-backends==4.4.0 +edx-auth-backends==4.5.0 # via -r requirements/edx/base.txt -edx-braze-client==0.2.5 - # via - # -r requirements/edx/base.txt - # edx-enterprise -edx-bulk-grades==1.1.0 +edx-bulk-grades==1.2.0 # via # -r requirements/edx/base.txt # staff-graded-xblock -edx-ccx-keys==1.3.0 +edx-ccx-keys==2.0.2 # via # -r requirements/edx/base.txt # lti-consumer-xblock # openedx-events -edx-celeryutils==1.3.0 +edx-celeryutils==1.4.0 # via # -r requirements/edx/base.txt # edx-name-affirmation # super-csv -edx-codejail==3.5.1 +edx-codejail==4.0.0 # via -r requirements/edx/base.txt -edx-completion==4.7.3 +edx-completion==4.9 # via -r requirements/edx/base.txt -edx-django-release-util==1.4.0 +edx-django-release-util==1.5.0 # via # -r requirements/edx/base.txt # edx-submissions # edxval -edx-django-sites-extensions==4.2.0 +edx-django-sites-extensions==5.1.0 # via -r requirements/edx/base.txt -edx-django-utils==7.0.0 +edx-django-utils==8.0.0 # via # -r requirements/edx/base.txt # django-config-models @@ -555,11 +562,12 @@ edx-django-utils==7.0.0 # edx-rest-api-client # edx-toggles # edx-when + # enterprise-integrated-channels # event-tracking # openedx-events # ora2 # super-csv -edx-drf-extensions==10.5.0 +edx-drf-extensions==10.6.0 # via # -r requirements/edx/base.txt # edx-completion @@ -570,27 +578,28 @@ edx-drf-extensions==10.5.0 # edx-rbac # edx-when # edxval + # enterprise-integrated-channels # openedx-learning -edx-enterprise==4.32.2 +edx-enterprise==6.2.13 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -edx-event-bus-kafka==6.0.0 +edx-event-bus-kafka==6.1.0 # via -r requirements/edx/base.txt -edx-event-bus-redis==0.5.1 +edx-event-bus-redis==0.6.1 # via -r requirements/edx/base.txt -edx-i18n-tools==1.5.0 +edx-i18n-tools==1.9.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # ora2 -edx-lint==5.4.1 + # xblocks-contrib +edx-lint==5.6.0 # via -r requirements/edx/testing.in -edx-milestones==0.6.0 +edx-milestones==1.1.0 # via -r requirements/edx/base.txt edx-name-affirmation==3.0.1 # via -r requirements/edx/base.txt -edx-opaque-keys[django]==2.11.0 +edx-opaque-keys[django]==3.0.0 # via # -r requirements/edx/base.txt # edx-bulk-grades @@ -602,37 +611,45 @@ edx-opaque-keys[django]==2.11.0 # edx-organizations # edx-proctoring # edx-when + # enterprise-integrated-channels # lti-consumer-xblock # openedx-events + # openedx-filters # ora2 + # xblocks-contrib edx-organizations==6.13.0 # via -r requirements/edx/base.txt -edx-proctoring==4.18.3 +edx-proctoring==5.2.0 # via # -r requirements/edx/base.txt # edx-proctoring-proctortrack -edx-rbac==1.10.0 +edx-rbac==2.1.0 # via # -r requirements/edx/base.txt # edx-enterprise -edx-rest-api-client==6.0.0 + # enterprise-integrated-channels +edx-rest-api-client==6.2.0 # via # -r requirements/edx/base.txt # edx-enterprise # edx-proctoring -edx-search==4.1.1 + # enterprise-integrated-channels +edx-search==4.1.3 + # via + # -r requirements/edx/base.txt + # openedx-forum +edx-sga==0.26.0 # via -r requirements/edx/base.txt -edx-sga==0.25.0 - # via -r requirements/edx/base.txt -edx-submissions==3.8.2 +edx-submissions==3.11.1 # via # -r requirements/edx/base.txt # ora2 -edx-tincan-py35==1.0.0 +edx-tincan-py35==2.0.0 # via # -r requirements/edx/base.txt # edx-enterprise -edx-toggles==5.2.0 + # enterprise-integrated-channels +edx-toggles==5.3.0 # via # -r requirements/edx/base.txt # edx-completion @@ -644,57 +661,58 @@ 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.0 +edx-when==3.0.0 # via # -r requirements/edx/base.txt # edx-proctoring -edxval==2.6.0 +edxval==3.0.0 # via -r requirements/edx/base.txt elasticsearch==7.9.1 # via # -c requirements/edx/../common_constraints.txt + # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-search + # openedx-forum enmerkar==0.7.1 # via # -r requirements/edx/base.txt # enmerkar-underscore -enmerkar-underscore==2.3.1 +enmerkar-underscore==2.4.0 # via -r requirements/edx/base.txt -event-tracking==3.0.0 +enterprise-integrated-channels==0.1.13 + # via -r requirements/edx/base.txt +event-tracking==3.3.0 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-completion # edx-proctoring # edx-search execnet==2.1.1 # via pytest-xdist -factory-boy==3.3.1 +factory-boy==3.3.3 # via -r requirements/edx/testing.in -faker==30.8.1 +faker==37.3.0 # via factory-boy -fastapi==0.115.4 +fastapi==0.115.12 # via pact-python -fastavro==1.9.7 +fastavro==1.11.1 # via # -r requirements/edx/base.txt # openedx-events -filelock==3.16.1 +filelock==3.18.0 # via # -r requirements/edx/base.txt # snowflake-connector-python # tox # virtualenv -firebase-admin==6.5.0 +firebase-admin==6.8.0 # via # -r requirements/edx/base.txt # edx-ace -freezegun==1.5.1 +freezegun==1.5.2 # via -r requirements/edx/testing.in -frozenlist==1.5.0 +frozenlist==1.6.2 # via # -r requirements/edx/base.txt # aiohttp @@ -713,11 +731,11 @@ future==1.0.0 # via # -r requirements/edx/base.txt # pyjwkest -geoip2==4.8.0 +geoip2==5.1.0 # via -r requirements/edx/base.txt glob2==0.7 # via -r requirements/edx/base.txt -google-api-core[grpc]==2.22.0 +google-api-core[grpc]==2.25.0 # via # -r requirements/edx/base.txt # firebase-admin @@ -725,11 +743,11 @@ google-api-core[grpc]==2.22.0 # google-cloud-core # google-cloud-firestore # google-cloud-storage -google-api-python-client==2.149.0 +google-api-python-client==2.171.0 # via # -r requirements/edx/base.txt # firebase-admin -google-auth==2.35.0 +google-auth==2.40.2 # via # -r requirements/edx/base.txt # google-api-core @@ -742,20 +760,20 @@ google-auth-httplib2==0.2.0 # via # -r requirements/edx/base.txt # google-api-python-client -google-cloud-core==2.4.1 +google-cloud-core==2.4.3 # via # -r requirements/edx/base.txt # google-cloud-firestore # google-cloud-storage -google-cloud-firestore==2.19.0 +google-cloud-firestore==2.21.0 # via # -r requirements/edx/base.txt # firebase-admin -google-cloud-storage==2.18.2 +google-cloud-storage==3.1.0 # via # -r requirements/edx/base.txt # firebase-admin -google-crc32c==1.6.0 +google-crc32c==1.7.1 # via # -r requirements/edx/base.txt # google-cloud-storage @@ -764,32 +782,36 @@ google-resumable-media==2.7.2 # via # -r requirements/edx/base.txt # google-cloud-storage -googleapis-common-protos==1.65.0 +googleapis-common-protos==1.70.0 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status -grimp==3.5 +grimp==3.9 # via import-linter -grpcio==1.67.0 +grpcio==1.72.1 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio-status==1.67.0 +grpcio-status==1.72.1 # via # -r requirements/edx/base.txt # google-api-core gunicorn==23.0.0 # via -r requirements/edx/base.txt h11==0.14.0 - # via uvicorn -help-tokens==2.4.0 + # via + # httpcore + # uvicorn +help-tokens==3.2.0 # via -r requirements/edx/base.txt html5lib==1.1 # via # -r requirements/edx/base.txt # ora2 +httpcore==0.16.3 + # via httpx httplib2==0.22.0 # via # -r requirements/edx/base.txt @@ -797,7 +819,9 @@ httplib2==0.22.0 # google-auth-httplib2 httpretty==1.1.4 # via -r requirements/edx/testing.in -icalendar==6.0.1 +httpx==0.23.3 + # via pact-python +icalendar==6.3.1 # via -r requirements/edx/base.txt idna==3.10 # via @@ -805,34 +829,31 @@ idna==3.10 # anyio # optimizely-sdk # requests + # rfc3986 # snowflake-connector-python # yarl -import-linter==2.1 +import-linter==2.3 # via -r requirements/edx/testing.in -importlib-metadata==8.5.0 +importlib-metadata==8.7.0 # via -r requirements/edx/base.txt inflection==0.5.1 # via # -r requirements/edx/base.txt # drf-spectacular # drf-yasg -iniconfig==2.0.0 +iniconfig==2.1.0 # via pytest -interchange==2021.0.4 - # via - # -r requirements/edx/base.txt - # py2neo ipaddress==1.0.23 # via -r requirements/edx/base.txt isodate==0.7.2 # via # -r requirements/edx/base.txt # python3-saml -isort==5.13.2 +isort==6.0.1 # via # -r requirements/edx/testing.in # pylint -jinja2==3.1.4 +jinja2==3.1.6 # via # -r requirements/edx/base.txt # -r requirements/edx/coverage.txt @@ -843,9 +864,10 @@ jmespath==1.0.1 # -r requirements/edx/base.txt # boto3 # botocore -joblib==1.4.2 +joblib==1.5.1 # via # -r requirements/edx/base.txt + # grimp # nltk jsondiff==2.2.1 # via @@ -858,14 +880,15 @@ jsonfield==3.1.0 # edx-enterprise # edx-proctoring # edx-submissions + # enterprise-integrated-channels # lti-consumer-xblock # ora2 -jsonschema==4.23.0 +jsonschema==4.24.0 # via # -r requirements/edx/base.txt # drf-spectacular # optimizely-sdk -jsonschema-specifications==2024.10.1 +jsonschema-specifications==2025.4.1 # via # -r requirements/edx/base.txt # jsonschema @@ -874,7 +897,7 @@ jwcrypto==1.5.6 # -r requirements/edx/base.txt # django-oauth-toolkit # pylti1p3 -kombu==5.4.2 +kombu==5.5.4 # via # -r requirements/edx/base.txt # celery @@ -887,20 +910,15 @@ lazy==1.6 # lti-consumer-xblock # ora2 # xblock -lazy-object-proxy==1.10.0 - # via astroid -libsass==0.10.0 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/base.txt loremipsum==1.0.5 # via # -r requirements/edx/base.txt # ora2 -lti-consumer-xblock==9.11.3 +lti-consumer-xblock==9.14.0 # via -r requirements/edx/base.txt -lxml[html-clean]==5.3.0 +lxml[html-clean]==5.3.2 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-i18n-tools # edxval @@ -913,22 +931,21 @@ lxml[html-clean]==5.3.0 # python3-saml # xblock # xmlsec -lxml-html-clean==0.3.1 +lxml-html-clean==0.4.2 # via # -r requirements/edx/base.txt # lxml mailsnake==1.6.4 # via -r requirements/edx/base.txt -mako==1.3.6 +mako==1.3.10 # via # -r requirements/edx/base.txt # acid-xblock # lti-consumer-xblock # xblock # xblock-utils -markdown==3.3.7 +markdown==3.8 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # openedx-django-wiki # staff-graded-xblock @@ -942,26 +959,25 @@ markupsafe==3.0.2 # mako # openedx-calc # xblock -maxminddb==2.6.2 +maxminddb==2.7.0 # via # -r requirements/edx/base.txt # geoip2 mccabe==0.7.0 # via pylint -meilisearch==0.31.6 +meilisearch==0.34.1 # via # -r requirements/edx/base.txt # edx-search -mock==5.1.0 - # via -r requirements/edx/base.txt +mock==5.2.0 + # via -r requirements/edx/testing.in mongoengine==0.29.1 # via -r requirements/edx/base.txt monotonic==1.6 # via # -r requirements/edx/base.txt # analytics-python - # py2neo -more-itertools==10.5.0 +more-itertools==10.7.0 # via # -r requirements/edx/base.txt # cssutils @@ -973,19 +989,19 @@ msgpack==1.1.0 # via # -r requirements/edx/base.txt # cachecontrol -multidict==6.1.0 +multidict==6.4.4 # via # -r requirements/edx/base.txt # aiohttp # yarl -mysqlclient==2.2.5 - # via -r requirements/edx/base.txt -newrelic==10.2.0 +mysqlclient==2.2.7 # via # -r requirements/edx/base.txt - # edx-django-utils -nh3==0.2.18 - # via -r requirements/edx/base.txt + # openedx-forum +nh3==0.2.21 + # via + # -r requirements/edx/base.txt + # xblocks-contrib nltk==3.9.1 # via # -r requirements/edx/base.txt @@ -1007,6 +1023,7 @@ oauthlib==3.2.2 # lti-consumer-xblock # requests-oauthlib # social-auth-core + # xblocks-contrib olxcleaner==0.3.0 # via -r requirements/edx/base.txt openai==0.28.1 @@ -1014,20 +1031,24 @@ openai==0.28.1 # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-enterprise -openedx-atlas==0.6.2 +openedx-atlas==0.7.0 + # via + # -r requirements/edx/base.txt + # enterprise-integrated-channels + # openedx-forum +openedx-calc==4.0.2 # via -r requirements/edx/base.txt -openedx-calc==3.1.2 - # via -r requirements/edx/base.txt -openedx-django-pyfs==3.7.0 +openedx-django-pyfs==3.8.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock # xblock + # xblocks-contrib openedx-django-require==2.1.0 # via -r requirements/edx/base.txt -openedx-django-wiki==2.1.0 +openedx-django-wiki==3.1.1 # via -r requirements/edx/base.txt -openedx-events==9.15.0 +openedx-events==10.2.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1036,40 +1057,34 @@ openedx-events==9.15.0 # edx-name-affirmation # event-tracking # ora2 -openedx-filters==1.11.0 +openedx-filters==2.1.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock # ora2 -openedx-learning==0.17.0 +openedx-forum==0.3.0 + # via -r requirements/edx/base.txt +openedx-learning==0.27.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -openedx-mongodbproxy==0.2.2 +optimizely-sdk==5.2.0 # via -r requirements/edx/base.txt -optimizely-sdk==4.1.1 - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/base.txt -ora2==6.14.0 +ora2==6.16.3 # via -r requirements/edx/base.txt -packaging==24.1 +packaging==25.0 # via # -r requirements/edx/base.txt # drf-yasg # gunicorn - # py2neo + # kombu # pyproject-api # pytest # snowflake-connector-python # tox -pact-python==2.2.2 +pact-python==2.0.1 # via -r requirements/edx/testing.in -pansi==2020.7.3 - # via - # -r requirements/edx/base.txt - # py2neo -paramiko==3.5.0 +paramiko==3.5.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1085,9 +1100,7 @@ path-py==12.5.0 # edx-enterprise # ora2 # staff-graded-xblock -paver==1.3.4 - # via -r requirements/edx/base.txt -pbr==6.1.0 +pbr==6.1.1 # via # -r requirements/edx/base.txt # stevedore @@ -1097,20 +1110,20 @@ pgpy==0.6.0 # edx-enterprise piexif==1.1.3 # via -r requirements/edx/base.txt -pillow==11.0.0 +pillow==11.2.1 # via # -r requirements/edx/base.txt # edx-enterprise # edx-organizations # edxval -platformdirs==4.3.6 +platformdirs==4.3.8 # via # -r requirements/edx/base.txt # pylint # snowflake-connector-python # tox # virtualenv -pluggy==1.5.0 +pluggy==1.6.0 # via # -r requirements/edx/coverage.txt # diff-cover @@ -1121,20 +1134,21 @@ polib==1.2.0 # -r requirements/edx/base.txt # -r requirements/edx/testing.in # edx-i18n-tools -prompt-toolkit==3.0.48 +prompt-toolkit==3.0.51 # via # -r requirements/edx/base.txt # click-repl -propcache==0.2.0 +propcache==0.3.1 # via # -r requirements/edx/base.txt + # aiohttp # yarl -proto-plus==1.25.0 +proto-plus==1.26.1 # via # -r requirements/edx/base.txt # google-api-core # google-cloud-firestore -protobuf==5.28.3 +protobuf==6.31.1 # via # -r requirements/edx/base.txt # google-api-core @@ -1142,7 +1156,7 @@ protobuf==5.28.3 # googleapis-common-protos # grpcio-status # proto-plus -psutil==6.1.0 +psutil==7.0.0 # via # -r requirements/edx/base.txt # edx-django-utils @@ -1150,17 +1164,13 @@ psutil==6.1.0 # pytest-xdist py==1.11.0 # via -r requirements/edx/testing.in -py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz - # via - # -c requirements/edx/../constraints.txt - # -r requirements/edx/base.txt pyasn1==0.6.1 # via # -r requirements/edx/base.txt # pgpy # pyasn1-modules # rsa -pyasn1-modules==0.4.1 +pyasn1-modules==0.4.2 # via # -r requirements/edx/base.txt # google-auth @@ -1174,33 +1184,30 @@ pycparser==2.22 # via # -r requirements/edx/base.txt # cffi -pycryptodomex==3.21.0 +pycryptodomex==3.23.0 # via # -r requirements/edx/base.txt # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.9.2 +pydantic==2.11.5 # via # -r requirements/edx/base.txt # camel-converter # fastapi -pydantic-core==2.23.4 +pydantic-core==2.33.2 # via # -r requirements/edx/base.txt # pydantic -pygments==2.18.0 +pygments==2.19.1 # via - # -r requirements/edx/base.txt # -r requirements/edx/coverage.txt # diff-cover - # py2neo pyjwkest==1.4.2 # via # -r requirements/edx/base.txt - # edx-token-utils # lti-consumer-xblock -pyjwt[crypto]==2.9.0 +pyjwt[crypto]==2.10.1 # via # -r requirements/edx/base.txt # drf-jwt @@ -1209,6 +1216,7 @@ pyjwt[crypto]==2.9.0 # edx-proctoring # edx-rest-api-client # firebase-admin + # lti-consumer-xblock # pylti1p3 # snowflake-connector-python # social-auth-core @@ -1216,9 +1224,8 @@ pylatexenc==2.10 # via # -r requirements/edx/base.txt # olxcleaner -pylint==2.15.10 +pylint==3.3.7 # via - # -c requirements/edx/../constraints.txt # edx-lint # pylint-celery # pylint-django @@ -1226,13 +1233,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 @@ -1245,7 +1252,7 @@ pymongo==4.4.0 # edx-opaque-keys # event-tracking # mongoengine - # openedx-mongodbproxy + # openedx-forum pynacl==1.5.0 # via # -r requirements/edx/base.txt @@ -1253,18 +1260,17 @@ pynacl==1.5.0 # paramiko pynliner==0.8.0 # via -r requirements/edx/base.txt -pyopenssl==24.2.1 +pyopenssl==25.1.0 # via # -r requirements/edx/base.txt - # optimizely-sdk # snowflake-connector-python -pyparsing==3.2.0 +pyparsing==3.2.3 # via # -r requirements/edx/base.txt # chem # httplib2 # openedx-calc -pyproject-api==1.8.0 +pyproject-api==1.9.1 # via tox pyquery==2.0.1 # via -r requirements/edx/testing.in @@ -1276,7 +1282,7 @@ pysrt==1.1.2 # via # -r requirements/edx/base.txt # edxval -pytest==8.3.3 +pytest==8.2.0 # via # -r requirements/edx/testing.in # pylint-pytest @@ -1289,19 +1295,19 @@ pytest==8.3.3 # pytest-xdist pytest-attrib==0.1.3 # via -r requirements/edx/testing.in -pytest-cov==5.0.0 +pytest-cov==6.1.1 # via -r requirements/edx/testing.in -pytest-django==4.9.0 +pytest-django==4.11.1 # 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 pytest-randomly==3.16.0 # via -r requirements/edx/testing.in -pytest-xdist[psutil]==3.6.1 +pytest-xdist[psutil]==3.7.0 # via -r requirements/edx/testing.in python-dateutil==2.9.0.post0 # via @@ -1312,7 +1318,6 @@ python-dateutil==2.9.0.post0 # edx-ace # edx-enterprise # edx-proctoring - # faker # freezegun # icalendar # olxcleaner @@ -1322,13 +1327,11 @@ python-ipware==3.0.0 # via # -r requirements/edx/base.txt # django-ipware -python-memcached==1.62 - # via -r requirements/edx/base.txt python-slugify==8.0.4 # via # -r requirements/edx/base.txt # code-annotations -python-swiftclient==4.6.0 +python-swiftclient==4.8.0 # via # -r requirements/edx/base.txt # ora2 @@ -1338,7 +1341,7 @@ python3-openid==3.2.0 ; python_version >= "3" # social-auth-core python3-saml==1.16.0 # via -r requirements/edx/base.txt -pytz==2024.2 +pytz==2025.2 # via # -r requirements/edx/base.txt # djangorestframework @@ -1348,9 +1351,9 @@ pytz==2024.2 # edx-proctoring # edx-submissions # edx-tincan-py35 + # enterprise-integrated-channels # event-tracking # fs - # interchange # olxcleaner # ora2 # snowflake-connector-python @@ -1369,25 +1372,24 @@ pyyaml==6.0.2 # xblock random2==1.0.2 # via -r requirements/edx/base.txt -recommender-xblock==3.0.0 +recommender-xblock==3.1.0 # via -r requirements/edx/base.txt -redis==5.2.0 +redis==6.2.0 # via # -r requirements/edx/base.txt # walrus -referencing==0.35.1 +referencing==0.36.2 # via # -r requirements/edx/base.txt # jsonschema # jsonschema-specifications -regex==2024.9.11 +regex==2024.11.6 # via # -r requirements/edx/base.txt # nltk requests==2.32.3 # via # -r requirements/edx/base.txt - # algoliasearch # analytics-python # cachecontrol # django-oauth-toolkit @@ -1395,12 +1397,14 @@ requests==2.32.3 # edx-drf-extensions # edx-enterprise # edx-rest-api-client + # enterprise-integrated-channels # geoip2 # google-api-core # google-cloud-storage # mailsnake # meilisearch # openai + # openedx-forum # optimizely-sdk # pact-python # pyjwkest @@ -1416,12 +1420,14 @@ requests-oauthlib==2.0.0 # via # -r requirements/edx/base.txt # social-auth-core -rpds-py==0.20.0 +rfc3986[idna2008]==1.5.0 + # via httpx +rpds-py==0.25.1 # via # -r requirements/edx/base.txt # jsonschema # referencing -rsa==4.9 +rsa==4.9.1 # via # -r requirements/edx/base.txt # google-auth @@ -1431,7 +1437,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-learning -s3transfer==0.10.3 +s3transfer==0.13.0 # via # -r requirements/edx/base.txt # boto3 @@ -1439,31 +1445,29 @@ sailthru-client==2.2.3 # via # -r requirements/edx/base.txt # edx-ace -scipy==1.14.1 +scipy==1.15.3 # via # -r requirements/edx/base.txt # chem - # openedx-calc semantic-version==2.10.0 # via # -r requirements/edx/base.txt # edx-drf-extensions -shapely==2.0.6 +shapely==2.1.1 # via -r requirements/edx/base.txt -simplejson==3.19.3 +simplejson==3.20.1 # via # -r requirements/edx/base.txt # sailthru-client # super-csv # xblock # xblock-utils -singledispatch==4.1.0 +singledispatch==4.1.2 # via -r requirements/edx/testing.in -six==1.16.0 +six==1.17.0 # via # -r requirements/edx/base.txt # analytics-python - # bleach # codejail-includes # crowdsourcehinter-xblock # edx-ace @@ -1478,13 +1482,7 @@ six==1.16.0 # fs # fs-s3fs # html5lib - # interchange - # libsass - # optimizely-sdk # pact-python - # pansi - # paver - # py2neo # pyjwkest # python-dateutil slumber==0.7.1 @@ -1492,9 +1490,13 @@ slumber==0.7.1 # -r requirements/edx/base.txt # edx-bulk-grades # edx-enterprise + # enterprise-integrated-channels sniffio==1.3.1 - # via anyio -snowflake-connector-python==3.12.3 + # via + # anyio + # httpcore + # httpx +snowflake-connector-python==3.15.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1505,6 +1507,7 @@ social-auth-app-django==5.4.1 # edx-auth-backends social-auth-core==4.5.4 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # edx-auth-backends # social-auth-app-django @@ -1516,19 +1519,19 @@ sortedcontainers==2.4.0 # via # -r requirements/edx/base.txt # snowflake-connector-python -soupsieve==2.6 +soupsieve==2.7 # via # -r requirements/edx/base.txt # beautifulsoup4 -sqlparse==0.5.1 +sqlparse==0.5.3 # via # -r requirements/edx/base.txt # django -staff-graded-xblock==2.3.0 +staff-graded-xblock==3.1.0 # via -r requirements/edx/base.txt -starlette==0.41.2 +starlette==0.46.2 # via fastapi -stevedore==5.3.0 +stevedore==5.4.1 # via # -r requirements/edx/base.txt # code-annotations @@ -1536,11 +1539,11 @@ stevedore==5.3.0 # edx-django-utils # edx-enterprise # edx-opaque-keys -super-csv==3.2.0 +super-csv==4.1.0 # via # -r requirements/edx/base.txt # edx-bulk-grades -sympy==1.13.3 +sympy==1.14.0 # via # -r requirements/edx/base.txt # openedx-calc @@ -1553,28 +1556,30 @@ text-unidecode==1.3 # via # -r requirements/edx/base.txt # python-slugify -tinycss2==1.2.1 +tinycss2==1.4.0 # via # -r requirements/edx/base.txt # bleach tomlkit==0.13.2 # via # -r requirements/edx/base.txt + # openedx-learning # pylint # snowflake-connector-python -tox==4.23.2 +tox==4.26.0 # via -r requirements/edx/testing.in -tqdm==4.66.6 +tqdm==4.67.1 # via # -r requirements/edx/base.txt # nltk # openai -typing-extensions==4.12.2 +typing-extensions==4.14.0 # via # -r requirements/edx/base.txt + # anyio + # beautifulsoup4 # django-countries # edx-opaque-keys - # faker # fastapi # grimp # import-linter @@ -1582,20 +1587,30 @@ typing-extensions==4.12.2 # pydantic # pydantic-core # pylti1p3 + # pyopenssl + # referencing # snowflake-connector-python -tzdata==2024.2 + # typing-inspection +typing-inspection==0.4.1 # via # -r requirements/edx/base.txt - # celery + # pydantic +tzdata==2025.2 + # via + # -r requirements/edx/base.txt + # faker # icalendar # kombu unicodecsv==0.14.1 # via # -r requirements/edx/base.txt # edx-enterprise + # enterprise-integrated-channels +unicodeit==0.7.5 + # via -r requirements/edx/base.txt unidiff==0.7.5 # via -r requirements/edx/testing.in -uritemplate==4.1.1 +uritemplate==4.2.0 # via # -r requirements/edx/base.txt # drf-spectacular @@ -1603,14 +1618,15 @@ uritemplate==4.1.1 # google-api-python-client urllib3==2.2.3 # via + # -c requirements/edx/../common_constraints.txt # -r requirements/edx/base.txt # botocore # elasticsearch - # py2neo + # pact-python # requests -user-util==1.1.0 +user-util==2.0.0 # via -r requirements/edx/base.txt -uvicorn==0.32.0 +uvicorn==0.34.3 # via pact-python vine==5.1.0 # via @@ -1618,7 +1634,7 @@ vine==5.1.0 # amqp # celery # kombu -virtualenv==20.27.1 +virtualenv==20.31.2 # via tox voluptuous==0.15.2 # via @@ -1628,13 +1644,11 @@ walrus==0.9.4 # via # -r requirements/edx/base.txt # edx-event-bus-redis -watchdog==5.0.3 - # via -r requirements/edx/base.txt wcwidth==0.2.13 # via # -r requirements/edx/base.txt # prompt-toolkit -web-fragments==2.2.0 +web-fragments==3.1.0 # via # -r requirements/edx/base.txt # crowdsourcehinter-xblock @@ -1652,11 +1666,13 @@ webob==1.8.9 # via # -r requirements/edx/base.txt # xblock -wrapt==1.16.0 +wheel==0.45.1 # via # -r requirements/edx/base.txt - # astroid -xblock[django]==5.1.0 + # django-pipeline +wrapt==1.17.2 + # via -r requirements/edx/base.txt +xblock[django]==5.2.0 # via # -r requirements/edx/base.txt # acid-xblock @@ -1671,29 +1687,32 @@ xblock[django]==5.1.0 # xblock-drag-and-drop-v2 # xblock-google-drive # xblock-utils -xblock-drag-and-drop-v2==4.0.3 + # xblocks-contrib +xblock-drag-and-drop-v2==5.0.2 # via -r requirements/edx/base.txt -xblock-google-drive==0.7.0 +xblock-google-drive==0.8.1 # via -r requirements/edx/base.txt -xblock-poll==1.14.0 +xblock-poll==1.15.1 # via -r requirements/edx/base.txt xblock-utils==4.0.0 # via # -r requirements/edx/base.txt # edx-sga # xblock-poll +xblocks-contrib==0.4.0 + # via -r requirements/edx/base.txt xmlsec==1.3.14 # via + # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt # python3-saml -xss-utils==0.6.0 +xss-utils==0.8.0 # via -r requirements/edx/base.txt -yarl==1.17.0 +yarl==1.20.0 # via # -r requirements/edx/base.txt # aiohttp - # pact-python -zipp==3.20.2 +zipp==3.22.0 # via # -r requirements/edx/base.txt # importlib-metadata diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt index 110663ff6a..990b4234fa 100644 --- a/requirements/pip-tools.txt +++ b/requirements/pip-tools.txt @@ -6,11 +6,9 @@ # build==1.2.2.post1 # via pip-tools -click==8.1.6 - # via - # -c requirements/constraints.txt - # pip-tools -packaging==24.1 +click==8.2.1 + # via pip-tools +packaging==25.0 # via build pip-tools==7.4.1 # via -r requirements/pip-tools.in @@ -18,7 +16,7 @@ pyproject-hooks==1.2.0 # via # build # pip-tools -wheel==0.44.0 +wheel==0.45.1 # via pip-tools # The following packages are considered to be unsafe in a requirements file: diff --git a/requirements/pip.txt b/requirements/pip.txt index 797974efa4..dabfa8f0eb 100644 --- a/requirements/pip.txt +++ b/requirements/pip.txt @@ -4,7 +4,7 @@ # # make upgrade # -wheel==0.44.0 +wheel==0.45.1 # via -r requirements/pip.in # The following packages are considered to be unsafe in a requirements file: @@ -12,5 +12,5 @@ pip==24.2 # via # -c requirements/common_constraints.txt # -r requirements/pip.in -setuptools==75.2.0 +setuptools==80.9.0 # via -r requirements/pip.in diff --git a/scripts/all-tests.sh b/scripts/all-tests.sh index 2f343ccc82..e3741b87ce 100755 --- a/scripts/all-tests.sh +++ b/scripts/all-tests.sh @@ -11,8 +11,6 @@ set -e ############################################################################### # Violations thresholds for failing the build -source scripts/thresholds.sh - XSSLINT_THRESHOLDS=$(cat scripts/xsslint_thresholds.json) export XSSLINT_THRESHOLDS=${XSSLINT_THRESHOLDS//[[:space:]]/} diff --git a/scripts/ci-runner.Dockerfile b/scripts/ci-runner.Dockerfile deleted file mode 100644 index bbe7ef16db..0000000000 --- a/scripts/ci-runner.Dockerfile +++ /dev/null @@ -1,62 +0,0 @@ -FROM summerwind/actions-runner:v2.316.0-ubuntu-20.04-49490c4 as base - -USER root - -# Install system requirements -RUN apt-get update && \ - # Global requirements - DEBIAN_FRONTEND=noninteractive apt-get install --yes \ - build-essential git language-pack-en libmysqlclient-dev libssl-dev libxml2-dev \ - libxmlsec1-dev libxslt1-dev \ - # lynx: Required by https://github.com/openedx/edx-platform/blob/b489a4ecb122/openedx/core/lib/html_to_text.py#L16 - lynx xvfb pkg-config \ - python3-dev python3-venv \ - && rm -rf /var/lib/apt/lists/* - -# Install Mongodb 4.4 -RUN wget -qO - https://www.mongodb.org/static/pgp/server-4.4.asc | apt-key add - -RUN echo "deb https://repo.mongodb.org/apt/ubuntu focal/mongodb-org/4.4 multiverse" | tee /etc/apt/sources.list.d/mongodb-org-4.4.list -RUN apt-get update && apt-get install -y mongodb-org=4.4.13 -EXPOSE 27017 - -RUN locale-gen en_US.UTF-8 -ENV LANG en_US.UTF-8 -ENV LANGUAGE en_US:en -ENV LC_ALL en_US.UTF-8 - -WORKDIR /edx/app/edxapp/edx-platform - -ENV PATH /edx/app/edxapp/nodeenv/bin:${PATH} -ENV PATH ./node_modules/.bin:${PATH} -ENV CONFIG_ROOT /edx/etc/ -ENV PATH /edx/app/edxapp/edx-platform/bin:${PATH} -ENV SETTINGS production -RUN mkdir -p /edx/etc/ - -ENV VIRTUAL_ENV=/edx/app/edxapp/venvs/edxapp -RUN python3.8 -m venv $VIRTUAL_ENV -ENV PATH="$VIRTUAL_ENV/bin:$PATH" - - -FROM base as build - -# Install Python requirements -COPY setup.py setup.py -COPY openedx/core/lib openedx/core/lib -COPY lms lms -COPY cms cms -COPY common common -COPY xmodule xmodule -COPY requirements/pip.txt requirements/pip.txt -COPY requirements/pip-tools.txt requirements/pip-tools.txt -COPY requirements/edx/testing.txt requirements/edx/testing.txt -COPY Makefile Makefile -RUN make test-requirements - -FROM base as runner - -COPY --from=build /edx/app/edxapp/venvs/edxapp /edx/app/edxapp/venvs/edxapp - -USER runner - -CMD ["/entrypoint.sh"] diff --git a/scripts/compile_sass.py b/scripts/compile_sass.py index 14b84d003a..70d49d21cc 100755 --- a/scripts/compile_sass.py +++ b/scripts/compile_sass.py @@ -188,7 +188,7 @@ def main( Eventually, we may eschew libsass-python altogether by switching to SassC@3.3.2, a direct CLI for libsass@3.3.2. (ref: https://github.com/sass/sassc). This would be nice because it would allow us to remove Python from the Sass build pipeline entirely. However, it would mean explicitly compiling & installing both libsass and SassC - within the edx-platform Dockerfile, which has its own drawbacks. + within the edx-platform build environment, which has its own drawbacks. """ # Constants from libsass-python SASS_STYLE_NESTED = 0 diff --git a/scripts/copy-node-modules.sh b/scripts/copy-node-modules.sh index f49514b6de..16b38fc8fe 100755 --- a/scripts/copy-node-modules.sh +++ b/scripts/copy-node-modules.sh @@ -62,7 +62,6 @@ log_and_run cp --force \ "$node_modules/jquery/dist/jquery.js" \ "$node_modules/moment-timezone/builds/moment-timezone-with-data.js" \ "$node_modules/moment/min/moment-with-locales.js" \ - "$node_modules/picturefill/dist/picturefill.js" \ "$node_modules/requirejs/require.js" \ "$node_modules/underscore.string/dist/underscore.string.js" \ "$node_modules/underscore/underscore.js" \ diff --git a/scripts/generic-ci-tests.sh b/scripts/generic-ci-tests.sh deleted file mode 100755 index 54b9cbb9d5..0000000000 --- a/scripts/generic-ci-tests.sh +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env bash -set -e - -############################################################################### -# -# generic-ci-tests.sh -# -# Execute some tests for edx-platform. -# (Most other tests are run by invoking `pytest`, `pylint`, etc. directly) -# -# This script can be called from CI jobs that define -# these environment variables: -# -# `TEST_SUITE` defines which kind of test to run. -# Possible values are: -# -# - "quality": Run the quality (pycodestyle/pylint) checks -# - "js-unit": Run the JavaScript tests -# - "pavelib-js-unit": Run the JavaScript tests and the Python unit -# tests from the pavelib/lib directory -# -############################################################################### - -# Clean up previous builds -git clean -qxfd - -function emptyxunit { - - cat > "reports/$1.xml" < - - - -END - -} - -# if specified tox environment is supported, prepend paver commands -# with tox env invocation -if [ -z ${TOX_ENV+x} ] || [[ ${TOX_ENV} == 'null' ]]; then - echo "TOX_ENV: ${TOX_ENV}" - TOX="" -elif tox -l |grep -q "${TOX_ENV}"; then - if [[ "${TOX_ENV}" == 'quality' ]]; then - TOX="" - else - TOX="tox -r -e ${TOX_ENV} --" - fi -else - echo "${TOX_ENV} is not currently supported. Please review the" - echo "tox.ini file to see which environments are supported" - exit 1 -fi - -PAVER_ARGS="-v" -export SUBSET_JOB=$JOB_NAME - -function run_paver_quality { - QUALITY_TASK=$1 - shift - mkdir -p test_root/log/ - LOG_PREFIX="test_root/log/$QUALITY_TASK" - $TOX paver "$QUALITY_TASK" "$@" 2> "$LOG_PREFIX.err.log" > "$LOG_PREFIX.out.log" || { - echo "STDOUT (last 100 lines of $LOG_PREFIX.out.log):"; - tail -n 100 "$LOG_PREFIX.out.log" - echo "STDERR (last 100 lines of $LOG_PREFIX.err.log):"; - tail -n 100 "$LOG_PREFIX.err.log" - return 1; - } - return 0; -} - -case "$TEST_SUITE" in - - "quality") - EXIT=0 - - mkdir -p reports - - echo "Finding pycodestyle violations and storing report..." - run_paver_quality run_pep8 || { EXIT=1; } - echo "Finding ESLint violations and storing report..." - run_paver_quality run_eslint -l "$ESLINT_THRESHOLD" || { EXIT=1; } - echo "Finding Stylelint violations and storing report..." - run_paver_quality run_stylelint || { EXIT=1; } - echo "Running xss linter report." - run_paver_quality run_xsslint -t "$XSSLINT_THRESHOLDS" || { EXIT=1; } - echo "Running PII checker on all Django models..." - run_paver_quality run_pii_check || { EXIT=1; } - echo "Running reserved keyword checker on all Django models..." - run_paver_quality check_keywords || { EXIT=1; } - - # Need to create an empty test result so the post-build - # action doesn't fail the build. - emptyxunit "stub" - exit "$EXIT" - ;; - - "js-unit") - $TOX paver test_js --coverage - $TOX paver diff_coverage - ;; - - "pavelib-js-unit") - EXIT=0 - $TOX paver test_js --coverage --skip-clean || { EXIT=1; } - paver test_lib --skip-clean $PAVER_ARGS || { EXIT=1; } - - # This is to ensure that the build status of the shard is properly set. - # Because we are running two paver commands in a row, we need to capture - # their return codes in order to exit with a non-zero code if either of - # them fail. We put the || clause there because otherwise, when a paver - # command fails, this entire script will exit, and not run the second - # paver command in this case statement. So instead of exiting, the value - # of a variable named EXIT will be set to 1 if either of the paver - # commands fail. We then use this variable's value as our exit code. - # Note that by default the value of this variable EXIT is not set, so if - # neither command fails then the exit command resolves to simply exit - # which is considered successful. - exit "$EXIT" - ;; -esac diff --git a/scripts/paver_autocomplete.sh b/scripts/paver_autocomplete.sh deleted file mode 100644 index 8b4e811141..0000000000 --- a/scripts/paver_autocomplete.sh +++ /dev/null @@ -1,89 +0,0 @@ -# shellcheck disable=all -# ^ Paver in edx-platform is on the way out -# (https://github.com/openedx/edx-platform/issues/31798) -# so we're not going to bother fixing these shellcheck -# violations. - -# Courtesy of Gregory Nicholas - -_subcommand_opts() -{ - local awkfile command cur usage - command=$1 - cur=${COMP_WORDS[COMP_CWORD]} - awkfile=/tmp/paver-option-awkscript-$$.awk - echo ' -BEGIN { - opts = ""; -} - -{ - for (i = 1; i <= NF; i = i + 1) { - # Match short options (-a, -S, -3) - # or long options (--long-option, --another_option) - # in output from paver help [subcommand] - if ($i ~ /^(-[A-Za-z0-9]|--[A-Za-z][A-Za-z0-9_-]*)/) { - opt = $i; - # remove trailing , and = characters. - match(opt, "[,=]"); - if (RSTART > 0) { - opt = substr(opt, 0, RSTART); - } - opts = opts " " opt; - } - } -} - -END { - print opts -}' > $awkfile - - usage=`paver help $command` - options=`echo "$usage"|awk -f $awkfile` - - COMPREPLY=( $(compgen -W "$options" -- "$cur") ) -} - - -_paver() -{ - local cur prev - COMPREPLY=() - # Variable to hold the current word - cur="${COMP_WORDS[COMP_CWORD]}" - prev="${COMP_WORDS[COMP_CWORD - 1]}" - - # Build a list of the available tasks from: `paver --help --quiet` - local cmds=$(paver -hq | awk '/^ ([a-zA-Z][a-zA-Z0-9_]+)/ {print $1}') - - subcmd="${COMP_WORDS[1]}" - # Generate possible matches and store them in the - # array variable COMPREPLY - - if [[ -n $subcmd ]] - then - - if [[ ${#COMP_WORDS[*]} == 3 ]] - then - _subcommand_opts $subcmd - return 0 - else - if [[ "$cur" == -* ]] - then - _subcommand_opts $subcmd - return 0 - else - COMPREPLY=( $(compgen -o nospace -- "$cur") ) - fi - fi - fi - - if [[ ${#COMP_WORDS[*]} == 2 ]] - then - COMPREPLY=( $(compgen -W "${cmds}" -- "$cur") ) - fi -} - -# Assign the auto-completion function for our command. - -complete -F _paver -o default paver diff --git a/scripts/structures_pruning/requirements/base.txt b/scripts/structures_pruning/requirements/base.txt index a3fcacad2f..dc07616e0e 100644 --- a/scripts/structures_pruning/requirements/base.txt +++ b/scripts/structures_pruning/requirements/base.txt @@ -4,25 +4,27 @@ # # make upgrade # -click==8.1.6 +click==8.2.1 # via - # -c scripts/structures_pruning/requirements/../../../requirements/constraints.txt # -r scripts/structures_pruning/requirements/base.in # click-log click-log==0.4.0 # via -r scripts/structures_pruning/requirements/base.in dnspython==2.7.0 # via pymongo -edx-opaque-keys==2.11.0 +edx-opaque-keys==3.0.0 # via -r scripts/structures_pruning/requirements/base.in -pbr==6.1.0 +pbr==6.1.1 # via stevedore pymongo==4.4.0 # via # -c scripts/structures_pruning/requirements/../../../requirements/constraints.txt # -r scripts/structures_pruning/requirements/base.in # edx-opaque-keys -stevedore==5.3.0 +stevedore==5.4.1 # via edx-opaque-keys -typing-extensions==4.12.2 +typing-extensions==4.14.0 # via edx-opaque-keys + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/scripts/structures_pruning/requirements/testing.txt b/scripts/structures_pruning/requirements/testing.txt index 94c6ac6982..e175266c77 100644 --- a/scripts/structures_pruning/requirements/testing.txt +++ b/scripts/structures_pruning/requirements/testing.txt @@ -4,7 +4,7 @@ # # make upgrade # -click==8.1.6 +click==8.2.1 # via # -r scripts/structures_pruning/requirements/base.txt # click-log @@ -16,29 +16,34 @@ dnspython==2.7.0 # via # -r scripts/structures_pruning/requirements/base.txt # pymongo -edx-opaque-keys==2.11.0 +edx-opaque-keys==3.0.0 # via -r scripts/structures_pruning/requirements/base.txt -iniconfig==2.0.0 +iniconfig==2.1.0 # via pytest -packaging==24.1 +packaging==25.0 # via pytest -pbr==6.1.0 +pbr==6.1.1 # via # -r scripts/structures_pruning/requirements/base.txt # stevedore -pluggy==1.5.0 +pluggy==1.6.0 + # via pytest +pygments==2.19.1 # via pytest pymongo==4.4.0 # via # -r scripts/structures_pruning/requirements/base.txt # edx-opaque-keys -pytest==8.3.3 +pytest==8.4.0 # via -r scripts/structures_pruning/requirements/testing.in -stevedore==5.3.0 +stevedore==5.4.1 # via # -r scripts/structures_pruning/requirements/base.txt # edx-opaque-keys -typing-extensions==4.12.2 +typing-extensions==4.14.0 # via # -r scripts/structures_pruning/requirements/base.txt # edx-opaque-keys + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/scripts/thresholds.sh b/scripts/thresholds.sh deleted file mode 100755 index a2045763c1..0000000000 --- a/scripts/thresholds.sh +++ /dev/null @@ -1,4 +0,0 @@ -#!/usr/bin/env bash -set -e - -export ESLINT_THRESHOLD=4950 diff --git a/scripts/user_retirement/docs/driver_setup.rst b/scripts/user_retirement/docs/driver_setup.rst index c6d7b8e6f2..d699d73ae8 100644 --- a/scripts/user_retirement/docs/driver_setup.rst +++ b/scripts/user_retirement/docs/driver_setup.rst @@ -109,26 +109,5 @@ several INI files, each containing a single line in the form of ``USERNAME --username= -************************************************** -Using the Driver Scripts in an Automated Framework -************************************************** - -At edX, we call the user retirement scripts from -`Jenkins `_ jobs on one of our internal Jenkins -services. The user retirement driver scripts are intended to be agnostic -about which automation framework you use, but they were only fully tested -from Jenkins. - -For more information about how we execute these scripts at edX, see the -following wiki articles: - -* `User Retirement Jenkins Implementation `_ -* `How to: retirement Jenkins jobs development and testing `_ - -And check out the Groovy DSL files we use to seed these jobs: - -* `platform/jobs/RetirementJobs.groovy in edx/jenkins-job-dsl `_ -* `platform/jobs/RetirementJobEdxTriggers.groovy in edx/jenkins-job-dsl `_ - .. include:: ../../../../links/links.rst diff --git a/scripts/user_retirement/docs/implementation_overview.rst b/scripts/user_retirement/docs/implementation_overview.rst index 37a814c1d5..0f5e6ee85a 100644 --- a/scripts/user_retirement/docs/implementation_overview.rst +++ b/scripts/user_retirement/docs/implementation_overview.rst @@ -49,27 +49,27 @@ possible states required by all members of the Open edX community. This example state diagram outlines the pathways users follow throughout the workflow: -.. digraph:: retirement_states_example - :align: center +.. graphviz:: + digraph retirement_states_example { + ranksep = "0.3"; - ranksep = "0.3"; + node[fontname=Courier,fontsize=12,shape=box,group=main] + { rank = same INIT[style=invis] PENDING } + INIT -> PENDING; + "..."[shape=none] + PENDING -> RETIRING_ENROLLMENTS -> ENROLLMENTS_COMPLETE -> RETIRING_FORUMS -> FORUMS_COMPLETE -> "..." -> COMPLETE; - node[fontname=Courier,fontsize=12,shape=box,group=main] - { rank = same INIT[style=invis] PENDING } - INIT -> PENDING; - "..."[shape=none] - PENDING -> RETIRING_ENROLLMENTS -> ENROLLMENTS_COMPLETE -> RETIRING_FORUMS -> FORUMS_COMPLETE -> "..." -> COMPLETE; + node[group=""]; + RETIRING_ENROLLMENTS -> ERRORED; + RETIRING_FORUMS -> ERRORED; + PENDING -> ABORTED; - node[group=""]; - RETIRING_ENROLLMENTS -> ERRORED; - RETIRING_FORUMS -> ERRORED; - PENDING -> ABORTED; - - subgraph cluster_terminal_states { - label = "Terminal States"; - labelloc = b // put label at bottom - {rank = same ERRORED COMPLETE ABORTED} - } + subgraph cluster_terminal_states { + label = "Terminal States"; + labelloc = b // put label at bottom + {rank = same ERRORED COMPLETE ABORTED} + } + } Unless an error occurs internal to the user retirement tooling, a user's retirement state should always land in one of the terminal states. At that diff --git a/scripts/user_retirement/docs/special_cases.rst b/scripts/user_retirement/docs/special_cases.rst index ae544c3208..9367aa8979 100644 --- a/scripts/user_retirement/docs/special_cases.rst +++ b/scripts/user_retirement/docs/special_cases.rst @@ -19,9 +19,8 @@ re-tried. You can do this using the Django admin. In this example, a user retirement errored during forums retirement, so we manually reset their state from ``ERRORED`` to ``ENROLLMENTS_COMPLETE``. -.. digraph:: retirement_states_example - :align: center - +.. graphviz:: + digraph G { //rankdir=LR; // Rank Direction Left to Right ranksep = "0.3"; @@ -49,6 +48,7 @@ from ``ERRORED`` to ``ENROLLMENTS_COMPLETE``. } ERRORED -> ENROLLMENTS_COMPLETE[style="bold,dashed",color=black,label=" via django\nadmin"] + } Now, the user retirement driver scripts will automatically resume this user's retirement the next time they are executed. diff --git a/scripts/user_retirement/requirements/base.txt b/scripts/user_retirement/requirements/base.txt index 704baaff2c..274e4cc400 100644 --- a/scripts/user_retirement/requirements/base.txt +++ b/scripts/user_retirement/requirements/base.txt @@ -6,36 +6,33 @@ # asgiref==3.8.1 # via django -attrs==24.2.0 +attrs==25.3.0 # via zeep backoff==2.2.1 # via -r scripts/user_retirement/requirements/base.in -boto3==1.35.50 +boto3==1.38.29 # via -r scripts/user_retirement/requirements/base.in -botocore==1.35.50 +botocore==1.38.29 # via # boto3 # s3transfer -cachetools==5.5.0 +cachetools==5.5.2 # via google-auth -certifi==2024.8.30 +certifi==2025.4.26 # via requests cffi==1.17.1 # via # cryptography # pynacl -charset-normalizer==2.0.12 +charset-normalizer==3.4.2 + # via requests +click==8.2.1 # via - # -c scripts/user_retirement/requirements/../../../requirements/constraints.txt - # requests -click==8.1.6 - # via - # -c scripts/user_retirement/requirements/../../../requirements/constraints.txt # -r scripts/user_retirement/requirements/base.in # edx-django-utils -cryptography==43.0.3 +cryptography==45.0.3 # via pyjwt -django==4.2.16 +django==4.2.22 # via # -c scripts/user_retirement/requirements/../../../requirements/common_constraints.txt # -c scripts/user_retirement/requirements/../../../requirements/constraints.txt @@ -44,24 +41,24 @@ django==4.2.16 # edx-django-utils django-crum==0.7.9 # via edx-django-utils -django-waffle==4.1.0 +django-waffle==4.2.0 # via edx-django-utils -edx-django-utils==7.0.0 +edx-django-utils==8.0.0 # via edx-rest-api-client -edx-rest-api-client==6.0.0 +edx-rest-api-client==6.2.0 # via -r scripts/user_retirement/requirements/base.in -google-api-core==2.22.0 +google-api-core==2.25.0 # via google-api-python-client -google-api-python-client==2.149.0 +google-api-python-client==2.171.0 # via -r scripts/user_retirement/requirements/base.in -google-auth==2.35.0 +google-auth==2.40.2 # via # google-api-core # google-api-python-client # google-auth-httplib2 google-auth-httplib2==0.2.0 # via google-api-python-client -googleapis-common-protos==1.65.0 +googleapis-common-protos==1.70.0 # via google-api-core httplib2==0.22.0 # via @@ -71,50 +68,50 @@ idna==3.10 # via requests isodate==0.7.2 # via zeep -jenkinsapi==0.3.13 +jenkinsapi==0.3.14 # via -r scripts/user_retirement/requirements/base.in jmespath==1.0.1 # via # boto3 # botocore -lxml==5.3.0 - # via zeep -more-itertools==10.5.0 +lxml==5.3.2 + # via + # -c scripts/user_retirement/requirements/../../../requirements/constraints.txt + # zeep +more-itertools==10.7.0 # via simple-salesforce -newrelic==10.2.0 - # via edx-django-utils -pbr==6.1.0 +pbr==6.1.1 # via stevedore -platformdirs==4.3.6 +platformdirs==4.3.8 # via zeep -proto-plus==1.25.0 +proto-plus==1.26.1 # via google-api-core -protobuf==5.28.3 +protobuf==6.31.1 # via # google-api-core # googleapis-common-protos # proto-plus -psutil==6.1.0 +psutil==7.0.0 # via edx-django-utils pyasn1==0.6.1 # via # pyasn1-modules # rsa -pyasn1-modules==0.4.1 +pyasn1-modules==0.4.2 # via google-auth pycparser==2.22 # via cffi -pyjwt[crypto]==2.9.0 +pyjwt[crypto]==2.10.1 # via # edx-rest-api-client # simple-salesforce pynacl==1.5.0 # via edx-django-utils -pyparsing==3.2.0 +pyparsing==3.2.3 # via httplib2 python-dateutil==2.9.0.post0 # via botocore -pytz==2024.2 +pytz==2025.2 # via # jenkinsapi # zeep @@ -134,32 +131,36 @@ requests-file==2.1.0 # via zeep requests-toolbelt==1.0.0 # via zeep -rsa==4.9 +rsa==4.9.1 # via google-auth -s3transfer==0.10.3 +s3transfer==0.13.0 # via boto3 simple-salesforce==1.12.6 # via -r scripts/user_retirement/requirements/base.in -simplejson==3.19.3 +simplejson==3.20.1 # via -r scripts/user_retirement/requirements/base.in -six==1.16.0 +six==1.17.0 # via # jenkinsapi # python-dateutil -sqlparse==0.5.1 +sqlparse==0.5.3 # via django -stevedore==5.3.0 +stevedore==5.4.1 # via edx-django-utils -typing-extensions==4.12.2 +typing-extensions==4.14.0 # via simple-salesforce unicodecsv==0.14.1 # via -r scripts/user_retirement/requirements/base.in -uritemplate==4.1.1 +uritemplate==4.2.0 # via google-api-python-client urllib3==1.26.20 # via + # -c scripts/user_retirement/requirements/../../../requirements/common_constraints.txt # -r scripts/user_retirement/requirements/base.in # botocore # requests zeep==4.3.1 # via simple-salesforce + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/scripts/user_retirement/requirements/testing.in b/scripts/user_retirement/requirements/testing.in index d20d3af589..49a4297b22 100644 --- a/scripts/user_retirement/requirements/testing.in +++ b/scripts/user_retirement/requirements/testing.in @@ -1,6 +1,6 @@ -r base.txt -moto<5.0 # moto==5.0 contains breaking changes. needs to be fixed separately. +moto pytest requests_mock responses diff --git a/scripts/user_retirement/requirements/testing.txt b/scripts/user_retirement/requirements/testing.txt index 4cb3de607d..d917c51526 100644 --- a/scripts/user_retirement/requirements/testing.txt +++ b/scripts/user_retirement/requirements/testing.txt @@ -8,27 +8,27 @@ asgiref==3.8.1 # via # -r scripts/user_retirement/requirements/base.txt # django -attrs==24.2.0 +attrs==25.3.0 # via # -r scripts/user_retirement/requirements/base.txt # zeep backoff==2.2.1 # via -r scripts/user_retirement/requirements/base.txt -boto3==1.35.50 +boto3==1.38.29 # via # -r scripts/user_retirement/requirements/base.txt # moto -botocore==1.35.50 +botocore==1.38.29 # via # -r scripts/user_retirement/requirements/base.txt # boto3 # moto # s3transfer -cachetools==5.5.0 +cachetools==5.5.2 # via # -r scripts/user_retirement/requirements/base.txt # google-auth -certifi==2024.8.30 +certifi==2025.4.26 # via # -r scripts/user_retirement/requirements/base.txt # requests @@ -37,22 +37,22 @@ cffi==1.17.1 # -r scripts/user_retirement/requirements/base.txt # cryptography # pynacl -charset-normalizer==2.0.12 +charset-normalizer==3.4.2 # via # -r scripts/user_retirement/requirements/base.txt # requests -click==8.1.6 +click==8.2.1 # via # -r scripts/user_retirement/requirements/base.txt # edx-django-utils -cryptography==43.0.3 +cryptography==45.0.3 # via # -r scripts/user_retirement/requirements/base.txt # moto # pyjwt ddt==1.7.2 # via -r scripts/user_retirement/requirements/testing.in -django==4.2.16 +django==4.2.22 # via # -r scripts/user_retirement/requirements/base.txt # django-crum @@ -62,23 +62,23 @@ django-crum==0.7.9 # via # -r scripts/user_retirement/requirements/base.txt # edx-django-utils -django-waffle==4.1.0 +django-waffle==4.2.0 # via # -r scripts/user_retirement/requirements/base.txt # edx-django-utils -edx-django-utils==7.0.0 +edx-django-utils==8.0.0 # via # -r scripts/user_retirement/requirements/base.txt # edx-rest-api-client -edx-rest-api-client==6.0.0 +edx-rest-api-client==6.2.0 # via -r scripts/user_retirement/requirements/base.txt -google-api-core==2.22.0 +google-api-core==2.25.0 # via # -r scripts/user_retirement/requirements/base.txt # google-api-python-client -google-api-python-client==2.149.0 +google-api-python-client==2.171.0 # via -r scripts/user_retirement/requirements/base.txt -google-auth==2.35.0 +google-auth==2.40.2 # via # -r scripts/user_retirement/requirements/base.txt # google-api-core @@ -88,7 +88,7 @@ google-auth-httplib2==0.2.0 # via # -r scripts/user_retirement/requirements/base.txt # google-api-python-client -googleapis-common-protos==1.65.0 +googleapis-common-protos==1.70.0 # via # -r scripts/user_retirement/requirements/base.txt # google-api-core @@ -101,22 +101,22 @@ idna==3.10 # via # -r scripts/user_retirement/requirements/base.txt # requests -iniconfig==2.0.0 +iniconfig==2.1.0 # via pytest isodate==0.7.2 # via # -r scripts/user_retirement/requirements/base.txt # zeep -jenkinsapi==0.3.13 +jenkinsapi==0.3.14 # via -r scripts/user_retirement/requirements/base.txt -jinja2==3.1.4 +jinja2==3.1.6 # via moto jmespath==1.0.1 # via # -r scripts/user_retirement/requirements/base.txt # boto3 # botocore -lxml==5.3.0 +lxml==5.3.2 # via # -r scripts/user_retirement/requirements/base.txt # zeep @@ -124,41 +124,37 @@ markupsafe==3.0.2 # via # jinja2 # werkzeug -mock==5.1.0 +mock==5.2.0 # via -r scripts/user_retirement/requirements/testing.in -more-itertools==10.5.0 +more-itertools==10.7.0 # via # -r scripts/user_retirement/requirements/base.txt # simple-salesforce -moto==4.2.14 +moto==5.1.5 # via -r scripts/user_retirement/requirements/testing.in -newrelic==10.2.0 - # via - # -r scripts/user_retirement/requirements/base.txt - # edx-django-utils -packaging==24.1 +packaging==25.0 # via pytest -pbr==6.1.0 +pbr==6.1.1 # via # -r scripts/user_retirement/requirements/base.txt # stevedore -platformdirs==4.3.6 +platformdirs==4.3.8 # via # -r scripts/user_retirement/requirements/base.txt # zeep -pluggy==1.5.0 +pluggy==1.6.0 # via pytest -proto-plus==1.25.0 +proto-plus==1.26.1 # via # -r scripts/user_retirement/requirements/base.txt # google-api-core -protobuf==5.28.3 +protobuf==6.31.1 # via # -r scripts/user_retirement/requirements/base.txt # google-api-core # googleapis-common-protos # proto-plus -psutil==6.1.0 +psutil==7.0.0 # via # -r scripts/user_retirement/requirements/base.txt # edx-django-utils @@ -167,7 +163,7 @@ pyasn1==0.6.1 # -r scripts/user_retirement/requirements/base.txt # pyasn1-modules # rsa -pyasn1-modules==0.4.1 +pyasn1-modules==0.4.2 # via # -r scripts/user_retirement/requirements/base.txt # google-auth @@ -175,7 +171,9 @@ pycparser==2.22 # via # -r scripts/user_retirement/requirements/base.txt # cffi -pyjwt[crypto]==2.9.0 +pygments==2.19.1 + # via pytest +pyjwt[crypto]==2.10.1 # via # -r scripts/user_retirement/requirements/base.txt # edx-rest-api-client @@ -184,18 +182,18 @@ pynacl==1.5.0 # via # -r scripts/user_retirement/requirements/base.txt # edx-django-utils -pyparsing==3.2.0 +pyparsing==3.2.3 # via # -r scripts/user_retirement/requirements/base.txt # httplib2 -pytest==8.3.3 +pytest==8.4.0 # via -r scripts/user_retirement/requirements/testing.in python-dateutil==2.9.0.post0 # via # -r scripts/user_retirement/requirements/base.txt # botocore # moto -pytz==2024.2 +pytz==2025.2 # via # -r scripts/user_retirement/requirements/base.txt # jenkinsapi @@ -227,42 +225,42 @@ requests-toolbelt==1.0.0 # via # -r scripts/user_retirement/requirements/base.txt # zeep -responses==0.25.3 +responses==0.25.7 # via # -r scripts/user_retirement/requirements/testing.in # moto -rsa==4.9 +rsa==4.9.1 # via # -r scripts/user_retirement/requirements/base.txt # google-auth -s3transfer==0.10.3 +s3transfer==0.13.0 # via # -r scripts/user_retirement/requirements/base.txt # boto3 simple-salesforce==1.12.6 # via -r scripts/user_retirement/requirements/base.txt -simplejson==3.19.3 +simplejson==3.20.1 # via -r scripts/user_retirement/requirements/base.txt -six==1.16.0 +six==1.17.0 # via # -r scripts/user_retirement/requirements/base.txt # jenkinsapi # python-dateutil -sqlparse==0.5.1 +sqlparse==0.5.3 # via # -r scripts/user_retirement/requirements/base.txt # django -stevedore==5.3.0 +stevedore==5.4.1 # via # -r scripts/user_retirement/requirements/base.txt # edx-django-utils -typing-extensions==4.12.2 +typing-extensions==4.14.0 # via # -r scripts/user_retirement/requirements/base.txt # simple-salesforce unicodecsv==0.14.1 # via -r scripts/user_retirement/requirements/base.txt -uritemplate==4.1.1 +uritemplate==4.2.0 # via # -r scripts/user_retirement/requirements/base.txt # google-api-python-client @@ -272,7 +270,7 @@ urllib3==1.26.20 # botocore # requests # responses -werkzeug==3.0.6 +werkzeug==3.1.3 # via moto xmltodict==0.14.2 # via moto @@ -280,3 +278,6 @@ zeep==4.3.1 # via # -r scripts/user_retirement/requirements/base.txt # simple-salesforce + +# The following packages are considered to be unsafe in a requirements file: +# setuptools diff --git a/scripts/user_retirement/tests/test_retirement_archive_and_cleanup.py b/scripts/user_retirement/tests/test_retirement_archive_and_cleanup.py index aa6715ba7d..12c48dda9f 100644 --- a/scripts/user_retirement/tests/test_retirement_archive_and_cleanup.py +++ b/scripts/user_retirement/tests/test_retirement_archive_and_cleanup.py @@ -10,7 +10,7 @@ import boto3 import pytest from botocore.exceptions import ClientError from click.testing import CliRunner -from moto import mock_ec2, mock_s3 +from moto import mock_aws from scripts.user_retirement.retirement_archive_and_cleanup import ( ERR_ARCHIVING, @@ -89,7 +89,7 @@ def fake_learners_to_retire(): get_learners_by_date_and_status=DEFAULT, bulk_cleanup_retirements=DEFAULT ) -@mock_s3 +@mock_aws def test_successful(*args, **kwargs): conn = boto3.resource('s3') conn.create_bucket(Bucket=FAKE_BUCKET_NAME) @@ -118,8 +118,7 @@ def test_successful(*args, **kwargs): get_learners_by_date_and_status=DEFAULT, bulk_cleanup_retirements=DEFAULT ) -@mock_ec2 -@mock_s3 +@mock_aws def test_successful_with_batching(*args, **kwargs): conn = boto3.resource('s3') conn.create_bucket(Bucket=FAKE_BUCKET_NAME) @@ -149,7 +148,7 @@ def test_successful_with_batching(*args, **kwargs): get_learners_by_date_and_status=DEFAULT, bulk_cleanup_retirements=DEFAULT ) -@mock_s3 +@mock_aws def test_successful_dry_run(*args, **kwargs): mock_get_access_token = args[0] mock_get_learners = kwargs['get_learners_by_date_and_status'] @@ -253,7 +252,7 @@ def test_conflicting_cool_off_date(*_): assert 'End date cannot occur within the cool_off_days period' in result.output -@mock_s3 +@mock_aws def test_s3_upload_data(): """ Test case to verify s3 upload and download. diff --git a/scripts/vulture/find-dead-code.sh b/scripts/vulture/find-dead-code.sh index e24882595e..aec5a4316a 100755 --- a/scripts/vulture/find-dead-code.sh +++ b/scripts/vulture/find-dead-code.sh @@ -35,9 +35,9 @@ mkdir -p "$OUTPUT_DIR" OUTPUT_FILE="${OUTPUT_DIR}/vulture-report.txt" echo '' > "$OUTPUT_FILE" # exclude test code from analysis, as it isn't explicitly called by other -# code. Additionally, application code that is only called by tests +# code. Additionally, application code that is only called by tests # should be considered dead -EXCLUSIONS='/test,/acceptance,cms/envs,lms/envs,/terrain,migrations/,signals.py' +EXCLUSIONS='/test,/acceptance,cms/envs,lms/envs,openedx/envs,migrations/,signals.py' MIN_CONFIDENCE=90 # paths to the code on which to run the analysis CODE_PATHS=('cms' 'common' 'lms' 'openedx') diff --git a/scripts/xblock/list-installed.py b/scripts/xblock/list-installed.py index c1152d749d..fb19bf6db9 100644 --- a/scripts/xblock/list-installed.py +++ b/scripts/xblock/list-installed.py @@ -1,7 +1,7 @@ """ Lookup list of installed XBlocks, to aid XBlock developers """ -import pkg_resources +from importlib.metadata import entry_points def get_without_builtins(): @@ -12,11 +12,10 @@ def get_without_builtins(): """ xblocks = [ entry_point.name - for entry_point in pkg_resources.iter_entry_points('xblock.v1') - if not entry_point.module_name.startswith('xmodule') + for entry_point in entry_points(group='xblock.v1') + if not entry_point.value.startswith('xmodule') ] - xblocks = sorted(xblocks) - return xblocks + return sorted(xblocks) def main(): diff --git a/scripts/xblock/requirements.txt b/scripts/xblock/requirements.txt index 920cf0cf6a..e135ac1d8b 100644 --- a/scripts/xblock/requirements.txt +++ b/scripts/xblock/requirements.txt @@ -4,15 +4,15 @@ # # make upgrade # -certifi==2024.8.30 +certifi==2025.4.26 + # via requests +charset-normalizer==3.4.2 # via requests -charset-normalizer==2.0.12 - # via - # -c scripts/xblock/../../requirements/constraints.txt - # requests idna==3.10 # via requests requests==2.32.3 # via -r scripts/xblock/requirements.in urllib3==2.2.3 - # via requests + # via + # -c scripts/xblock/../../requirements/common_constraints.txt + # requests diff --git a/scripts/xsslint/xss_linter.py b/scripts/xsslint/xss_linter.py index a35038c3de..76de199f09 100755 --- a/scripts/xsslint/xss_linter.py +++ b/scripts/xsslint/xss_linter.py @@ -4,6 +4,316 @@ A linting tool to check for xss vulnerabilities. """ +import argparse +import importlib +import json +import os +import re +import sys + +from functools import reduce +from io import StringIO +from xsslint.reporting import SummaryResults +from xsslint.rules import RuleSet +from xsslint.utils import is_skip_dir + + +class BuildFailure(Exception): + pass + + +def fail_quality(message): + """ + Fail the specified quality check. + """ + + raise BuildFailure(message) + + +def _load_config_module(module_path): + cwd = os.getcwd() + if cwd not in sys.path: + # Enable config module to be imported relative to wherever the script was run from. + sys.path.append(cwd) + return importlib.import_module(module_path) + + +def _build_ruleset(template_linters): + """ + Combines the RuleSets from the provided template_linters into a single, aggregate RuleSet. + + Arguments: + template_linters: A list of linting objects. + + Returns: + The combined RuleSet. + """ + return reduce( + lambda combined, current: combined + current.ruleset, + template_linters, + RuleSet() + ) + + +def _process_file(full_path, template_linters, options, summary_results, out): + """ + For each linter, lints the provided file. This means finding and printing + violations. + + Arguments: + full_path: The full path of the file to lint. + template_linters: A list of linting objects. + options: A list of the options. + summary_results: A SummaryResults with a summary of the violations. + out: output file + + """ + num_violations = 0 + directory = os.path.dirname(full_path) + file_name = os.path.basename(full_path) + try: + for template_linter in template_linters: + results = template_linter.process_file(directory, file_name) + results.print_results(options, summary_results, out) + except BaseException as e: + raise Exception(f"Failed to process path: {full_path}") from e + + +def _process_os_dir(directory, files, template_linters, options, summary_results, out): + """ + Calls out to lint each file in the passed list of files. + + Arguments: + directory: Directory being linted. + files: All files in the directory to be linted. + template_linters: A list of linting objects. + options: A list of the options. + summary_results: A SummaryResults with a summary of the violations. + out: output file + + """ + for current_file in sorted(files, key=lambda s: s.lower()): + full_path = os.path.join(directory, current_file) + _process_file(full_path, template_linters, options, summary_results, out) + + +def _process_os_dirs(starting_dir, template_linters, options, summary_results, out): + """ + For each linter, lints all the directories in the starting directory. + + Arguments: + starting_dir: The initial directory to begin the walk. + template_linters: A list of linting objects. + options: A list of the options. + summary_results: A SummaryResults with a summary of the violations. + out: output file + + """ + skip_dirs = options.get('skip_dirs', ()) + for root, dirs, files in os.walk(starting_dir): + if is_skip_dir(skip_dirs, root): + del dirs + continue + dirs.sort(key=lambda s: s.lower()) + _process_os_dir(root, files, template_linters, options, summary_results, out) + + +def _get_xsslint_counts(result_contents): + """ + This returns a dict of violations from the xsslint report. + + Arguments: + filename: The name of the xsslint report. + + Returns: + A dict containing the following: + rules: A dict containing the count for each rule as follows: + violation-rule-id: N, where N is the number of violations + total: M, where M is the number of total violations + + """ + + rule_count_regex = re.compile(r"^(?P[a-z-]+):\s+(?P\d+) violations", re.MULTILINE) + total_count_regex = re.compile(r"^(?P\d+) violations total", re.MULTILINE) + violations = {'rules': {}} + for violation_match in rule_count_regex.finditer(result_contents): + try: + violations['rules'][violation_match.group('rule_id')] = int(violation_match.group('count')) + except ValueError: + violations['rules'][violation_match.group('rule_id')] = None + try: + violations['total'] = int(total_count_regex.search(result_contents).group('count')) + # An AttributeError will occur if the regex finds no matches. + # A ValueError will occur if the returned regex cannot be cast as a float. + except (AttributeError, ValueError): + violations['total'] = None + return violations + + +def _check_violations(options, results): + xsslint_script = "xss_linter.py" + try: + thresholds_option = options['thresholds'] + # Read the JSON file + with open(thresholds_option, 'r') as file: + violation_thresholds = json.load(file) + + except ValueError: + violation_thresholds = None + if isinstance(violation_thresholds, dict) is False or \ + any(key not in ("total", "rules") for key in violation_thresholds.keys()): + print('xsslint') + fail_quality("""FAILURE: Thresholds option "{thresholds_option}" was not supplied using proper format.\n""" + """Here is a properly formatted example, '{{"total":100,"rules":{{"javascript-escape":0}}}}' """ + """with property names in double-quotes.""".format(thresholds_option=thresholds_option)) + + try: + metrics_str = "Number of {xsslint_script} violations: {num_violations}\n".format( + xsslint_script=xsslint_script, num_violations=int(results['total']) + ) + if 'rules' in results and any(results['rules']): + metrics_str += "\n" + rule_keys = sorted(results['rules'].keys()) + for rule in rule_keys: + metrics_str += "{rule} violations: {count}\n".format( + rule=rule, + count=int(results['rules'][rule]) + ) + except TypeError: + print('xsslint') + fail_quality("FAILURE: Number of {xsslint_script} violations could not be found".format( + xsslint_script=xsslint_script + )) + + error_message = "" + # Test total violations against threshold. + if 'total' in list(violation_thresholds.keys()): + if violation_thresholds['total'] < results['total']: + error_message = "Too many violations total ({count}).\nThe limit is {violations_limit}.".format( + count=results['total'], violations_limit=violation_thresholds['total'] + ) + + # Test rule violations against thresholds. + if 'rules' in violation_thresholds: + threshold_keys = sorted(violation_thresholds['rules'].keys()) + for threshold_key in threshold_keys: + if threshold_key not in results['rules']: + error_message += ( + "\nNumber of {xsslint_script} violations for {rule} could not be found" + ).format( + xsslint_script=xsslint_script, rule=threshold_key + ) + elif violation_thresholds['rules'][threshold_key] < results['rules'][threshold_key]: + error_message += \ + "\nToo many {rule} violations ({count}).\nThe {rule} limit is {violations_limit}.".format( + rule=threshold_key, count=results['rules'][threshold_key], + violations_limit=violation_thresholds['rules'][threshold_key], + ) + + if error_message: + print('xsslint') + fail_quality("FAILURE: XSSLinter Failed.\n{error_message}\n" + "run the following command to hone in on the problem:\n" + "./scripts/xss-commit-linter.sh -h".format(error_message=error_message)) + else: + print("successfully run xsslint") + + +def _lint(file_or_dir, template_linters, options, summary_results, out): + """ + For each linter, lints the provided file or directory. + + Arguments: + file_or_dir: The file or initial directory to lint. + template_linters: A list of linting objects. + options: A list of the options. + summary_results: A SummaryResults with a summary of the violations. + out: output file + + """ + + if file_or_dir is not None and os.path.isfile(file_or_dir): + _process_file(file_or_dir, template_linters, options, summary_results, out) + else: + directory = "." + if file_or_dir is not None: + if os.path.exists(file_or_dir): + directory = file_or_dir + else: + raise ValueError(f"Path [{file_or_dir}] is not a valid file or directory.") + _process_os_dirs(directory, template_linters, options, summary_results, out) + + summary_results.print_results(options, out) + result_output = _get_xsslint_counts(out.getvalue()) + _check_violations(options, result_output) + + +def main(): + """ + Used to execute the linter. Use --help option for help. + + Prints all violations. + """ + epilog = "For more help using the xss linter, including details on how to\n" + epilog += "understand and fix any violations, read the docs here:\n" + epilog += "\n" + # pylint: disable=line-too-long + epilog += " https://docs.openedx.org/en/latest/developers/references/developer_guide/preventing_xss/preventing_xss.html#xss-linter\n" + + parser = argparse.ArgumentParser( + formatter_class=argparse.RawDescriptionHelpFormatter, + description='Checks that templates are safe.', + epilog=epilog, + ) + parser.add_argument( + '--list-files', dest='list_files', action='store_true', + help='Only display the filenames that contain violations.' + ) + parser.add_argument( + '--rule-totals', dest='rule_totals', action='store_true', + help='Display the totals for each rule.' + ) + parser.add_argument( + '--summary-format', dest='summary_format', + choices=['eslint', 'json'], default='eslint', + help='Choose the display format for the summary.' + ) + parser.add_argument( + '--verbose', dest='verbose', action='store_true', + help='Print multiple lines where possible for additional context of violations.' + ) + parser.add_argument( + '--config', dest='config', action='store', default='xsslint.default_config', + help='Specifies the config module to use. The config module should be in Python package syntax.' + ) + parser.add_argument( + '--thresholds', dest='thresholds', action='store', + help='Specifies the config module to use. The config module should be in Python package syntax.' + ) + parser.add_argument('path', nargs="?", default=None, help='A file to lint or directory to recursively lint.') + + args = parser.parse_args() + config = _load_config_module(args.config) + options = { + 'list_files': args.list_files, + 'rule_totals': args.rule_totals, + 'summary_format': args.summary_format, + 'verbose': args.verbose, + 'skip_dirs': getattr(config, 'SKIP_DIRS', ()), + 'thresholds': args.thresholds + } + template_linters = getattr(config, 'LINTERS', ()) + if not template_linters: + raise ValueError(f"LINTERS is empty or undefined in the config module ({args.config}).") + + ruleset = _build_ruleset(template_linters) + summary_results = SummaryResults(ruleset) + _lint(args.path, template_linters, options, summary_results, out=StringIO()) + + if __name__ == "__main__": - from xsslint.main import main - main() + try: + main() + except BuildFailure as e: + print(e) + sys.exit(1) diff --git a/scripts/xsslint/xsslint/main.py b/scripts/xsslint/xsslint/main.py deleted file mode 100644 index f8f8672b74..0000000000 --- a/scripts/xsslint/xsslint/main.py +++ /dev/null @@ -1,187 +0,0 @@ -""" -The main function for the XSS linter. -""" - - -import argparse -import importlib -import os -import sys -from functools import reduce - -from xsslint.reporting import SummaryResults -from xsslint.rules import RuleSet -from xsslint.utils import is_skip_dir - - -def _load_config_module(module_path): - cwd = os.getcwd() - if cwd not in sys.path: - # Enable config module to be imported relative to wherever the script was run from. - sys.path.append(cwd) - return importlib.import_module(module_path) - - -def _build_ruleset(template_linters): - """ - Combines the RuleSets from the provided template_linters into a single, aggregate RuleSet. - - Arguments: - template_linters: A list of linting objects. - - Returns: - The combined RuleSet. - """ - return reduce( - lambda combined, current: combined + current.ruleset, - template_linters, - RuleSet() - ) - - -def _process_file(full_path, template_linters, options, summary_results, out): - """ - For each linter, lints the provided file. This means finding and printing - violations. - - Arguments: - full_path: The full path of the file to lint. - template_linters: A list of linting objects. - options: A list of the options. - summary_results: A SummaryResults with a summary of the violations. - out: output file - - """ - num_violations = 0 - directory = os.path.dirname(full_path) - file_name = os.path.basename(full_path) - try: - for template_linter in template_linters: - results = template_linter.process_file(directory, file_name) - results.print_results(options, summary_results, out) - except BaseException as e: - raise Exception(f"Failed to process path: {full_path}") from e - - -def _process_os_dir(directory, files, template_linters, options, summary_results, out): - """ - Calls out to lint each file in the passed list of files. - - Arguments: - directory: Directory being linted. - files: All files in the directory to be linted. - template_linters: A list of linting objects. - options: A list of the options. - summary_results: A SummaryResults with a summary of the violations. - out: output file - - """ - for current_file in sorted(files, key=lambda s: s.lower()): - full_path = os.path.join(directory, current_file) - _process_file(full_path, template_linters, options, summary_results, out) - - -def _process_os_dirs(starting_dir, template_linters, options, summary_results, out): - """ - For each linter, lints all the directories in the starting directory. - - Arguments: - starting_dir: The initial directory to begin the walk. - template_linters: A list of linting objects. - options: A list of the options. - summary_results: A SummaryResults with a summary of the violations. - out: output file - - """ - skip_dirs = options.get('skip_dirs', ()) - for root, dirs, files in os.walk(starting_dir): - if is_skip_dir(skip_dirs, root): - del dirs - continue - dirs.sort(key=lambda s: s.lower()) - _process_os_dir(root, files, template_linters, options, summary_results, out) - - -def _lint(file_or_dir, template_linters, options, summary_results, out): - """ - For each linter, lints the provided file or directory. - - Arguments: - file_or_dir: The file or initial directory to lint. - template_linters: A list of linting objects. - options: A list of the options. - summary_results: A SummaryResults with a summary of the violations. - out: output file - - """ - - if file_or_dir is not None and os.path.isfile(file_or_dir): - _process_file(file_or_dir, template_linters, options, summary_results, out) - else: - directory = "." - if file_or_dir is not None: - if os.path.exists(file_or_dir): - directory = file_or_dir - else: - raise ValueError(f"Path [{file_or_dir}] is not a valid file or directory.") - _process_os_dirs(directory, template_linters, options, summary_results, out) - - summary_results.print_results(options, out) - - -def main(): - """ - Used to execute the linter. Use --help option for help. - - Prints all violations. - """ - epilog = "For more help using the xss linter, including details on how to\n" - epilog += "understand and fix any violations, read the docs here:\n" - epilog += "\n" - # pylint: disable=line-too-long - epilog += " https://edx.readthedocs.org/projects/edx-developer-guide/en/latest/conventions/preventing_xss.html#xss-linter\n" - - parser = argparse.ArgumentParser( - formatter_class=argparse.RawDescriptionHelpFormatter, - description='Checks that templates are safe.', - epilog=epilog, - ) - parser.add_argument( - '--list-files', dest='list_files', action='store_true', - help='Only display the filenames that contain violations.' - ) - parser.add_argument( - '--rule-totals', dest='rule_totals', action='store_true', - help='Display the totals for each rule.' - ) - parser.add_argument( - '--summary-format', dest='summary_format', - choices=['eslint', 'json'], default='eslint', - help='Choose the display format for the summary.' - ) - parser.add_argument( - '--verbose', dest='verbose', action='store_true', - help='Print multiple lines where possible for additional context of violations.' - ) - parser.add_argument( - '--config', dest='config', action='store', default='xsslint.default_config', - help='Specifies the config module to use. The config module should be in Python package syntax.' - ) - parser.add_argument('path', nargs="?", default=None, help='A file to lint or directory to recursively lint.') - - args = parser.parse_args() - config = _load_config_module(args.config) - options = { - 'list_files': args.list_files, - 'rule_totals': args.rule_totals, - 'summary_format': args.summary_format, - 'verbose': args.verbose, - 'skip_dirs': getattr(config, 'SKIP_DIRS', ()) - } - template_linters = getattr(config, 'LINTERS', ()) - if not template_linters: - raise ValueError(f"LINTERS is empty or undefined in the config module ({args.config}).") - - ruleset = _build_ruleset(template_linters) - summary_results = SummaryResults(ruleset) - _lint(args.path, template_linters, options, summary_results, out=sys.stdout) diff --git a/scripts/xsslint_thresholds.json b/scripts/xsslint_thresholds.json index 26c267c074..97e4a1b60f 100644 --- a/scripts/xsslint_thresholds.json +++ b/scripts/xsslint_thresholds.json @@ -37,4 +37,4 @@ "underscore-not-escaped": 2 }, "total": 64 -} +} \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 987f447453..e4419bd149 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,14 +17,10 @@ filterwarnings = ignore:Instead access HTTPResponse.headers directly.*:DeprecationWarning:elasticsearch # ABC deprecation Warning comes from libsass ignore:Using or importing the ABCs from 'collections' instead of from 'collections.abc' is deprecated.*:DeprecationWarning:sass - # declare_namespace Warning comes from XBlock https://github.com/openedx/XBlock/issues/641 - # and also due to dependency: https://github.com/PyFilesystem/pyfilesystem2 - ignore:Deprecated call to `pkg_resources.declare_namespace.*:DeprecationWarning - ignore:.*pkg_resources is deprecated as an API.*:DeprecationWarning ignore:'etree' is deprecated. Use 'xml.etree.ElementTree' instead.:DeprecationWarning:wiki junit_family = xunit2 -norecursedirs = .* *.egg build conf dist node_modules test_root cms/envs lms/envs +norecursedirs = .* *.egg build conf dist node_modules test_root cms/envs lms/envs openedx/envs python_classes = python_files = tests.py test_*.py tests_*.py *_tests.py __init__.py @@ -102,11 +98,6 @@ ignore_imports = # -> openedx.core.djangoapps.course_groups.cohorts # -> lms.djangoapps.courseware.courses openedx.core.djangoapps.course_groups.cohorts -> lms.djangoapps.courseware.courses - # cms.djangoapps.models.settings.course_metadata - # -> openedx.features.course_experience - # -> openedx.features.course_experience.url_helpers - # -> lms.djangoapps.courseware.toggles - openedx.features.course_experience.url_helpers -> lms.djangoapps.courseware.toggles # cms.djangoapps.contentstore.[various] # -> openedx.features.content_type_gating.partitions # -> lms.djangoapps.commerce.utils @@ -187,6 +178,7 @@ allowed_modules = # See https://open-edx-proposals.readthedocs.io/en/latest/best-practices/oep-0049-django-app-patterns.html#api-py api data + tests [importlinter:contract:3] name = Do not import apps from openedx-learning (only import from openedx_learning.api.* and openedx_learning.lib.*). @@ -198,3 +190,36 @@ source_modules = forbidden_modules = openedx_learning.apps allow_indirect_imports = True + +[importlinter:contract:4] +name = Low-level apps should not depend on high-level apps +type = layers +layers = + # Layers from high-level to low-level. Imports should only occur from higher to lower. + cms.lib.xblock.upstream_sync | openedx.core.djangoapps.content.search | openedx.core.djangoapps.olx_rest_api + openedx.core.djangoapps.content_libraries + openedx.core.djangoapps.content_staging + openedx.core.djangoapps.xblock + openedx.core.lib.xblock_serializer + openedx.core.djangoapps.content_tagging +ignore_imports = + # Test code can break these layering rules + **.tests.** -> ** + + # FIXME: the exceptions below are from before we added this import linting rule. Should refactor to eliminate them. + # In particular, the contentstore.helpers module is too big and has too many imports - split it up? + + # The CSV export hard-codes support for courses and libraries. Refactor to do something like learning_context.get_children() + openedx.core.djangoapps.content_tagging.helpers.objecttag_export_helpers -> openedx.core.djangoapps.content_libraries.api + # The permissions checking code for tagging requires libraries model data to get all the orgs that a user is using: + openedx.core.djangoapps.content_tagging.utils -> openedx.core.djangoapps.content_libraries.api + # Content staging POST to clipboard API uses libraries APIs. We're working on moving this code to content_libraries + openedx.core.djangoapps.content_staging.views -> openedx.core.djangoapps.content_libraries.api + # content_staging.serializers imports contentstore.helpers which imports contentstore.utils which imports the libraries API. + openedx.core.djangoapps.content_staging.serializers -> cms.djangoapps.contentstore.helpers + # content_libraries.rest_api.libraries imports cms.djangoapps.contentstore.views.course which imports + # contentstore.toggles which imports djangoapps.content.search.api + openedx.core.djangoapps.content_libraries.rest_api.libraries -> cms.djangoapps.contentstore.views.course + # Content libraries imports contentstore.helpers which imports upstream_sync + openedx.core.djangoapps.content_libraries.api.blocks -> cms.djangoapps.contentstore.helpers + openedx.core.djangoapps.content_libraries.api.libraries -> cms.djangoapps.contentstore.helpers diff --git a/setup.py b/setup.py index 3b8f8c5949..5b9f020ac3 100644 --- a/setup.py +++ b/setup.py @@ -132,6 +132,7 @@ setup( "openedx.ace.policy": [ "bulk_email_optout = lms.djangoapps.bulk_email.policies:CourseEmailOptout", "course_push_notification_optout = openedx.core.djangoapps.notifications.policies:CoursePushNotificationOptout", # lint-amnesty, pylint: disable=line-too-long + "disabled_user_optout = openedx.core.djangoapps.ace_common.policies:DisableUserOptout", ], "openedx.call_to_action": [ "personalized_learner_schedules = openedx.features.personalized_learner_schedules.call_to_action:PersonalizedLearnerScheduleCallToAction" # lint-amnesty, pylint: disable=line-too-long diff --git a/setupTests.js b/setupTests.js index 070a140e18..6a3cf8595f 100644 --- a/setupTests.js +++ b/setupTests.js @@ -1,8 +1,4 @@ -// eslint-disable-next-line import/no-extraneous-dependencies -import {configure} from 'enzyme'; -// eslint-disable-next-line import/no-extraneous-dependencies -import Adapter from 'enzyme-adapter-react-16'; - -configure({adapter: new Adapter()}); +// setupTests.js +import '@testing-library/jest-dom'; global.gettext = (text) => text; diff --git a/stylelint.config.js b/stylelint.config.js deleted file mode 100644 index bd77699117..0000000000 --- a/stylelint.config.js +++ /dev/null @@ -1,3 +0,0 @@ -module.exports = { - extends: '@edx/stylelint-config-edx' -}; diff --git a/test_root/semgrep/celery-code-owner.yml b/test_root/semgrep/celery-code-owner.yml index c8cf417e43..a1693752d2 100644 --- a/test_root/semgrep/celery-code-owner.yml +++ b/test_root/semgrep/celery-code-owner.yml @@ -5,7 +5,7 @@ rules: # https://github.com/returntocorp/semgrep/issues/8608 # # Here's the intended URL, for reference: - # https://edx.readthedocs.io/projects/edx-django-utils/en/latest/monitoring/how_tos/add_code_owner_custom_attribute_to_an_ida.html#handling-celery-tasks + # https://docs.openedx.org/projects/edx-django-utils/en/latest/monitoring/how_tos/add_code_owner_custom_attribute_to_an_ida.html#handling-celery-tasks message: | Celery tasks need to be decorated with `@set_code_owner_attribute` (from the `edx_django_utils.monitoring` module) in order for us @@ -13,7 +13,7 @@ rules: For more information, see the Celery section of "Add Code_Owner Custom Attributes to an IDA" in the Monitoring How-Tos of - . + . languages: - python patterns: @@ -68,7 +68,7 @@ rules: For more information, see the Celery section of "Add Code_Owner Custom Attributes to an IDA" in the Monitoring How-Tos of - . + . languages: - python patterns: diff --git a/themes/README.rst b/themes/README.rst index ad09d932ea..75bae91493 100644 --- a/themes/README.rst +++ b/themes/README.rst @@ -65,7 +65,7 @@ There are two example themes provided within edx-platform's themes directory: For more details, see `Changing Themes for an Open edX Site`_. -.. _Changing Themes for an Open edX Site: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/changing_appearance/theming/index.html +.. _Changing Themes for an Open edX Site: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/changing_appearance/index.html HTML Templates -------------- diff --git a/themes/open-edx/README.rst b/themes/open-edx/README.rst index 5e4f97acdf..2293941935 100644 --- a/themes/open-edx/README.rst +++ b/themes/open-edx/README.rst @@ -7,8 +7,10 @@ provide any overrides, which means that it adopts the built-in themes, templates etc. The `Red Theme`_ is provided as an example of building a simple new theme. +Another is `Tutor Indigo`_. For more information on building your own theme, see `Changing Themes for an Open edX Site`_. -.. _Changing Themes for an Open edX Site: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/changing_appearance/theming/index.html +.. _Changing Themes for an Open edX Site: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/changing_appearance/index.html .. _Red Theme: https://github.com/openedx/edx-platform/tree/master/themes/red-theme +.. _Tutor Indigo: https://github.com/overhangio/tutor-indigo \ No newline at end of file diff --git a/themes/open-edx/cms/README.rst b/themes/open-edx/cms/README.rst index a6ed83112d..e0fe4272b5 100644 --- a/themes/open-edx/cms/README.rst +++ b/themes/open-edx/cms/README.rst @@ -7,8 +7,10 @@ provide any overrides, which means that it adopts the built-in themes, templates etc. The `Red Theme`_ is provided as an example of building a simple new theme. +Another is `Tutor Indigo`_. For more information on building your own theme, see `Changing Themes for an Open edX Site`_. -.. _Changing Themes for an Open edX Site: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/changing_appearance/theming/index.html +.. _Changing Themes for an Open edX Site: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/changing_appearance/index.html .. _Red Theme: https://github.com/openedx/edx-platform/tree/master/themes/red-theme +.. _Tutor Indigo: https://github.com/overhangio/tutor-indigo \ No newline at end of file diff --git a/themes/open-edx/lms/README.rst b/themes/open-edx/lms/README.rst index 12881f443b..c40581bc61 100644 --- a/themes/open-edx/lms/README.rst +++ b/themes/open-edx/lms/README.rst @@ -7,8 +7,10 @@ provide any overrides, which means that it adopts the built-in themes, templates etc. The `Red Theme`_ is provided as an example of building a simple new theme. +Another is `Tutor Indigo`_. For more information on building your own theme, see `Changing Themes for an Open edX Site`_. -.. _Changing Themes for an Open edX Site: https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/changing_appearance/theming/index.html +.. _Changing Themes for an Open edX Site: https://docs.openedx.org/en/latest/site_ops/install_configure_run_guide/configuration/changing_appearance/index.html .. _Red Theme: https://github.com/openedx/edx-platform/tree/master/themes/red-theme +.. _Tutor Indigo: https://github.com/overhangio/tutor-indigo diff --git a/tox.ini b/tox.ini index 1b4252fd19..e5df7f0fbd 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py{38,311} quality +envlist = py{311} quality # This is needed to prevent the lms, cms, and openedx packages inside the "Open # edX" package (defined in setup.py) from getting installed into site-packages diff --git a/webpack-config/file-lists.js b/webpack-config/file-lists.js index 7167a6f5dd..d9e818f912 100644 --- a/webpack-config/file-lists.js +++ b/webpack-config/file-lists.js @@ -79,9 +79,6 @@ module.exports = { path.resolve(__dirname, '../lms/static/js/learner_dashboard/views/program_header_view.js'), path.resolve(__dirname, '../lms/static/js/learner_dashboard/views/sidebar_view.js'), path.resolve(__dirname, '../lms/static/js/learner_dashboard/views/upgrade_message_view.js'), - path.resolve(__dirname, '../lms/static/js/student_account/views/account_section_view.js'), - path.resolve(__dirname, '../lms/static/js/student_account/views/account_settings_fields.js'), - path.resolve(__dirname, '../lms/static/js/student_account/views/account_settings_view.js'), path.resolve(__dirname, '../lms/static/js/student_account/views/FormView.js'), path.resolve(__dirname, '../lms/static/js/student_account/views/LoginView.js'), path.resolve(__dirname, '../lms/static/js/student_account/views/RegisterView.js'), diff --git a/webpack.common.config.js b/webpack.common.config.js index 322e252c6a..8deea2b4f6 100644 --- a/webpack.common.config.js +++ b/webpack.common.config.js @@ -26,6 +26,27 @@ var defineFooter = new RegExp('(' + defineCallFooter.source + ')|(' var staticRootLms = process.env.STATIC_ROOT_LMS || './test_root/staticfiles'; var staticRootCms = process.env.STATIC_ROOT_CMS || (staticRootLms + '/studio'); +class DieHardPlugin { + /* A small plugin which ensures that if Webpack fails, it causes the surrounding process to fail + * as well. This helps us prevent JavaScript CI from "false passing" upon build failures--that is, + * we want to avoid having another situation where the Webpack build breaks under Karma (our + * test runner) but Karma just lets it slide and moves on to the next test suite. + * + * One would imagine that this would be Webpack's default behavior (and maybe it is?) but, + * regardless, karma-webpack does not seem to consider Webpack build failures to be fatal errors + * without this plugin. We don't fully understand it, but this is good enough given that we plan + * to remove all JS in this repo soon (https://github.com/openedx/edx-platform/issues/31620). + * + * Inpsired by: https://github.com/codymikol/karma-webpack/issues/49#issuecomment-842682050 + */ + apply(compiler) { + compiler.hooks.failed.tap('DieHardPlugin', (error) => { + console.error(error); + process.exit(1); + }); + } +} + var workerConfig = function() { try { return { @@ -46,6 +67,8 @@ var workerConfig = function() { }), new webpack.DefinePlugin({ 'process.env.JS_ENV_EXTRA_CONFIG': JSON.parse(process.env.JS_ENV_EXTRA_CONFIG), + 'CAPTIONS_CONTENT_TO_REPLACE': JSON.stringify(process.env.CAPTIONS_CONTENT_TO_REPLACE || ''), + 'CAPTIONS_CONTENT_REPLACEMENT': JSON.stringify(process.env.CAPTIONS_CONTENT_REPLACEMENT || '') }) ], module: { @@ -109,7 +132,6 @@ module.exports = Merge.smart({ CompletionOnViewService: './lms/static/completion/js/CompletionOnViewService.js', // Features - CourseSock: './openedx/features/course_experience/static/course_experience/js/CourseSock.js', Currency: './openedx/features/course_experience/static/course_experience/js/currency.js', AnnouncementsView: './openedx/features/announcements/static/announcements/jsx/Announcements.jsx', @@ -154,6 +176,7 @@ module.exports = Merge.smart({ // any other way to declare that dependency. $script: 'scriptjs' }), + new DieHardPlugin(), ], module: { @@ -172,19 +195,19 @@ module.exports = Merge.smart({ multiple: [ { search: defineHeader, replace: '' }, { search: defineFooter, replace: '' }, - { + { search: /(\/\* RequireJS) \*\//g, replace(match, p1, offset, string) { return p1; } }, - { + { search: /\/\* Webpack/g, replace(match, p1, offset, string) { return match + ' */'; } }, - { + { search: /text!(.*?\.underscore)/g, replace(match, p1, offset, string) { return p1; @@ -635,13 +658,13 @@ module.exports = Merge.smart({ // We used to have node: { fs: 'empty' } in this file, // that is no longer supported. Adding this based on the recommendation in // https://stackoverflow.com/questions/64361940/webpack-error-configuration-node-has-an-unknown-property-fs - // + // // With this uncommented tests fail // Tests failed in the following suites: // * lms javascript // * xmodule-webpack javascript // Error: define cannot be used indirect - // + // // fallback: { // fs: false // } diff --git a/webpack.dev.config.js b/webpack.dev.config.js index 45550a7041..6fe7a0199a 100644 --- a/webpack.dev.config.js +++ b/webpack.dev.config.js @@ -21,7 +21,9 @@ module.exports = _.values(Merge.smart(commonConfig, { }), new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify('development'), - 'process.env.JS_ENV_EXTRA_CONFIG': process.env.JS_ENV_EXTRA_CONFIG || '{}' + 'process.env.JS_ENV_EXTRA_CONFIG': process.env.JS_ENV_EXTRA_CONFIG || '{}', + 'CAPTIONS_CONTENT_TO_REPLACE': JSON.stringify(process.env.CAPTIONS_CONTENT_TO_REPLACE || ''), + 'CAPTIONS_CONTENT_REPLACEMENT': JSON.stringify(process.env.CAPTIONS_CONTENT_REPLACEMENT || '') }) ], module: { diff --git a/webpack.prod.config.js b/webpack.prod.config.js index c8da4ff193..4626700130 100644 --- a/webpack.prod.config.js +++ b/webpack.prod.config.js @@ -19,7 +19,9 @@ var optimizedConfig = Merge.smart(commonConfig, { plugins: [ new webpack.DefinePlugin({ 'process.env.NODE_ENV': JSON.stringify('production'), - 'process.env.JS_ENV_EXTRA_CONFIG': process.env.JS_ENV_EXTRA_CONFIG || '{}' + 'process.env.JS_ENV_EXTRA_CONFIG': process.env.JS_ENV_EXTRA_CONFIG || '{}', + 'CAPTIONS_CONTENT_TO_REPLACE': JSON.stringify(process.env.CAPTIONS_CONTENT_TO_REPLACE || ''), + 'CAPTIONS_CONTENT_REPLACEMENT': JSON.stringify(process.env.CAPTIONS_CONTENT_REPLACEMENT || '') }), new webpack.LoaderOptionsPlugin({ // This may not be needed; legacy option for loaders written for webpack 1 minimize: true diff --git a/xmodule/annotatable_block.py b/xmodule/annotatable_block.py index cec677f6c5..9dcc27198d 100644 --- a/xmodule/annotatable_block.py +++ b/xmodule/annotatable_block.py @@ -3,22 +3,24 @@ import logging import textwrap +from django.conf import settings from lxml import etree from web_fragments.fragment import Fragment from xblock.core import XBlock from xblock.fields import Scope, String +from xblocks_contrib.annotatable import AnnotatableBlock as _ExtractedAnnotatableBlock from openedx.core.djangolib.markup import HTML, Text from xmodule.editing_block import EditingMixin from xmodule.raw_block import RawMixin from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_css_to_fragment -from xmodule.xml_block import XmlMixin from xmodule.x_module import ( ResourceTemplates, shim_xmodule_js, XModuleMixin, XModuleToXBlockMixin, ) +from xmodule.xml_block import XmlMixin log = logging.getLogger(__name__) @@ -28,7 +30,7 @@ _ = lambda text: text @XBlock.needs('mako') -class AnnotatableBlock( +class _BuiltInAnnotatableBlock( RawMixin, XmlMixin, EditingMixin, @@ -40,6 +42,8 @@ class AnnotatableBlock( Annotatable XBlock. """ + is_extracted = False + data = String( help=_("XML data for the annotation"), scope=Scope.content, @@ -136,9 +140,8 @@ class AnnotatableBlock( """ Renders annotatable content with annotation spans and returns HTML. """ xmltree = etree.fromstring(self.data) - content = etree.tostring(xmltree, encoding='unicode') + self._extract_instructions(xmltree) - xmltree = etree.fromstring(content) xmltree.tag = 'div' if 'display_name' in xmltree.attrib: del xmltree.attrib['display_name'] @@ -197,3 +200,10 @@ class AnnotatableBlock( add_webpack_js_to_fragment(fragment, 'AnnotatableBlockEditor') shim_xmodule_js(fragment, self.studio_js_module_name) return fragment + + +AnnotatableBlock = ( + _ExtractedAnnotatableBlock if settings.USE_EXTRACTED_ANNOTATABLE_BLOCK + else _BuiltInAnnotatableBlock +) +AnnotatableBlock.__name__ = "AnnotatableBlock" diff --git a/xmodule/annotator_mixin.py b/xmodule/annotator_mixin.py index 8e7617e1ca..b3aaefa31c 100644 --- a/xmodule/annotator_mixin.py +++ b/xmodule/annotator_mixin.py @@ -37,6 +37,13 @@ class MLStripper(HTMLParser): # lint-amnesty, pylint: disable=abstract-method self.reset() self.fed = [] + def handle_starttag(self, tag, attrs): + if tag != 'img': + return + for attr in attrs: + if len(attr) >= 2 and attr[0] == 'alt': + self.fed.append(attr[1]) + def handle_data(self, data): """takes the data in separate chunks""" self.fed.append(data) diff --git a/xmodule/assets/word_cloud/.eslintrc.js b/xmodule/assets/word_cloud/.eslintrc.js deleted file mode 100644 index b3039be23a..0000000000 --- a/xmodule/assets/word_cloud/.eslintrc.js +++ /dev/null @@ -1,14 +0,0 @@ -module.exports = { - extends: '@edx/eslint-config', - root: true, - settings: { - 'import/resolver': 'webpack', - }, - rules: { - indent: ['error', 4], - 'react/jsx-indent': ['error', 4], - 'react/jsx-indent-props': ['error', 4], - 'import/extensions': 'off', - 'import/no-unresolved': 'off', - }, -}; diff --git a/xmodule/capa/capa_problem.py b/xmodule/capa/capa_problem.py index 6d78e156f2..3bf5b78cd4 100644 --- a/xmodule/capa/capa_problem.py +++ b/xmodule/capa/capa_problem.py @@ -96,6 +96,7 @@ class LoncapaSystem(object): See :class:`DescriptorSystem` for documentation of other attributes. """ + def __init__( self, ajax_url, @@ -130,6 +131,7 @@ class LoncapaProblem(object): """ Main class for capa Problems. """ + def __init__(self, problem_text, id, capa_system, capa_block, # pylint: disable=redefined-builtin state=None, seed=None, minimal_init=False, extract_tree=True): """ diff --git a/xmodule/capa/errors.py b/xmodule/capa/errors.py new file mode 100644 index 0000000000..bb2783f891 --- /dev/null +++ b/xmodule/capa/errors.py @@ -0,0 +1,56 @@ +# errors.py +""" +Custom error handling for the XQueue submission interface. +""" + + +class XQueueSubmissionError(Exception): + """Base class for all XQueue submission errors.""" + + +class JSONParsingError(XQueueSubmissionError): + """Raised when JSON parsing fails.""" + MESSAGE = "Error parsing {name}: {error}" + + def __init__(self, name, error): + super().__init__(self.MESSAGE.format(name=name, error=error)) + + +class MissingKeyError(XQueueSubmissionError): + """Raised when a required key is missing.""" + MESSAGE = "Missing key: {key}" + + def __init__(self, key): + super().__init__(self.MESSAGE.format(key=key)) + + +class ValidationError(XQueueSubmissionError): + """Raised when a validation check fails.""" + MESSAGE = "Validation error: {error}" + + def __init__(self, error): + super().__init__(self.MESSAGE.format(error=error)) + + +class TypeErrorSubmission(XQueueSubmissionError): + """Raised when an invalid type is encountered.""" + MESSAGE = "Type error: {error}" + + def __init__(self, error): + super().__init__(self.MESSAGE.format(error=error)) + + +class RuntimeErrorSubmission(XQueueSubmissionError): + """Raised for runtime errors.""" + MESSAGE = "Runtime error: {error}" + + def __init__(self, error): + super().__init__(self.MESSAGE.format(error=error)) + + +class GetSubmissionParamsError(XQueueSubmissionError): + """Raised when there is an issue getting submission parameters.""" + MESSAGE = "Submission parameters error: {error}" + + def __init__(self, error="Block instance is not defined!"): + super().__init__(self.MESSAGE.format(error=error)) diff --git a/xmodule/capa/inputtypes.py b/xmodule/capa/inputtypes.py index 8dff577686..03a9a59134 100644 --- a/xmodule/capa/inputtypes.py +++ b/xmodule/capa/inputtypes.py @@ -66,8 +66,6 @@ from .util import sanitize_html log = logging.getLogger(__name__) -######################################################################### - registry = TagRegistry() # pylint: disable=invalid-name @@ -408,7 +406,7 @@ class OptionInput(InputTypeBase): Example: - The location of the sky + The location of the sky # TODO: allow ordering to be randomized """ @@ -505,7 +503,7 @@ class ChoiceGroup(InputTypeBase): raise Exception(msg) self.choices = self.extract_choices(self.xml, i18n) - self._choices_map = dict(self.choices,) + self._choices_map = dict(self.choices, ) @classmethod def get_attributes(cls): @@ -602,16 +600,16 @@ class JSInput(InputTypeBase): Register the attributes. """ return [ - Attribute('params', None), # extra iframe params + Attribute('params', None), # extra iframe params Attribute('html_file', None), Attribute('gradefn', "gradefn"), Attribute('get_statefn', None), # Function to call in iframe - # to get current state. + # to get current state. Attribute('initial_state', None), # JSON string to be used as initial state Attribute('set_statefn', None), # Function to call iframe to - # set state - Attribute('width', "400"), # iframe width - Attribute('height', "300"), # iframe height + # set state + Attribute('width', "400"), # iframe width + Attribute('height', "300"), # iframe height # Title for the iframe, which should be supplied by the author of the problem. Not translated # because we are in a class method and therefore do not have access to capa_system.i18n. # Note that the default "display name" for the problem is also not translated. @@ -1626,7 +1624,7 @@ class ChoiceTextGroup(InputTypeBase): CheckboxProblem: - A person randomly selects 100 times, with replacement, from the list of numbers \(\sqrt{2}\) , 2, 3, 4 ,5 ,6 + A person randomly selects 100 times, with replacement, from the list of numbers \(\sqrt{2}\), 2, 3, 4, 5, 6 and records the results. The first number they pick is \(\sqrt{2}\) Given this information select the correct choices and fill in numbers to make them accurate. diff --git a/xmodule/capa/registry.py b/xmodule/capa/registry.py index 1f0674771f..fc8b853ee6 100644 --- a/xmodule/capa/registry.py +++ b/xmodule/capa/registry.py @@ -7,6 +7,7 @@ class TagRegistry(object): (A dictionary with some extra error checking.) """ + def __init__(self): self._mapping = {} diff --git a/xmodule/capa/responsetypes.py b/xmodule/capa/responsetypes.py index 73378e7c0a..7521adf0cf 100644 --- a/xmodule/capa/responsetypes.py +++ b/xmodule/capa/responsetypes.py @@ -2140,6 +2140,9 @@ class CustomResponse(LoncapaResponse): globals_dict, python_path=self.context['python_path'], extra_files=self.context['extra_files'], + limit_overrides_context=get_course_id_from_capa_block( + self.capa_block + ), slug=self.id, random_seed=self.context['seed'], unsafely=self.capa_system.can_execute_unsafe_code(), @@ -2291,6 +2294,9 @@ class CustomResponse(LoncapaResponse): cache=self.capa_system.cache, python_path=self.context['python_path'], extra_files=self.context['extra_files'], + limit_overrides_context=get_course_id_from_capa_block( + self.capa_block + ), slug=self.id, random_seed=self.context['seed'], unsafely=self.capa_system.can_execute_unsafe_code(), @@ -3274,6 +3280,9 @@ class SchematicResponse(LoncapaResponse): cache=self.capa_system.cache, python_path=self.context['python_path'], extra_files=self.context['extra_files'], + limit_overrides_context=get_course_id_from_capa_block( + self.capa_block + ), slug=self.id, random_seed=self.context['seed'], unsafely=self.capa_system.can_execute_unsafe_code(), @@ -3390,7 +3399,7 @@ class ImageResponse(LoncapaResponse): parsed_region = [parsed_region] for region in parsed_region: polygon = MultiPoint(region).convex_hull - if (polygon.type == 'Polygon' and + if (polygon.geom_type == 'Polygon' and polygon.contains(Point(ans_x, ans_y))): correct_map.set(aid, 'correct') break diff --git a/xmodule/capa/safe_exec/remote_exec.py b/xmodule/capa/safe_exec/remote_exec.py index a7aa7f8344..fb086b1064 100644 --- a/xmodule/capa/safe_exec/remote_exec.py +++ b/xmodule/capa/safe_exec/remote_exec.py @@ -7,7 +7,7 @@ import logging from importlib import import_module import requests -from codejail.safe_exec import SafeExecException +from codejail.safe_exec import SafeExecException, json_safe from django.conf import settings from edx_toggles.toggles import SettingToggle from requests.exceptions import RequestException, HTTPError @@ -29,11 +29,35 @@ ENABLE_CODEJAIL_REST_SERVICE = SettingToggle( "ENABLE_CODEJAIL_REST_SERVICE", default=False, module_name=__name__ ) +# .. toggle_name: ENABLE_CODEJAIL_DARKLAUNCH +# .. toggle_implementation: SettingToggle +# .. toggle_default: False +# .. toggle_description: Turn on to send requests to both the codejail service and the installed codejail library for +# testing and evaluation purposes. The results from the installed codejail library will be the ones used. +# .. toggle_warning: This toggle will only behave as expected when ENABLE_CODEJAIL_REST_SERVICE is not enabled and when +# CODE_JAIL_REST_SERVICE_REMOTE_EXEC, CODE_JAIL_REST_SERVICE_HOST, CODE_JAIL_REST_SERVICE_READ_TIMEOUT, +# and CODE_JAIL_REST_SERVICE_CONNECT_TIMEOUT are configured. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2025-04-03 +# .. toggle_target_removal_date: 2025-05-01 +ENABLE_CODEJAIL_DARKLAUNCH = SettingToggle( + "ENABLE_CODEJAIL_DARKLAUNCH", default=False, module_name=__name__ +) + def is_codejail_rest_service_enabled(): return ENABLE_CODEJAIL_REST_SERVICE.is_enabled() +def is_codejail_in_darklaunch(): + """ + Returns whether codejail dark launch is enabled. + + Codejail dark launch can only be enabled if ENABLE_CODEJAIL_REST_SERVICE is not enabled. + """ + return not is_codejail_rest_service_enabled() and ENABLE_CODEJAIL_DARKLAUNCH.is_enabled() + + def get_remote_exec(*args, **kwargs): """Get remote exec function based on setting and executes it.""" remote_exec_function_name = settings.CODE_JAIL_REST_SERVICE_REMOTE_EXEC @@ -66,7 +90,21 @@ def send_safe_exec_request_v0(data): extra_files = data.pop("extra_files") codejail_service_endpoint = get_codejail_rest_service_endpoint() - payload = json.dumps(data) + + # In rare cases an XBlock might introduce `bytes` objects (or other + # non-JSON-serializable objects) into the globals dict. The codejail service + # (via the codejail library) will call `json_safe` on the globals before + # JSON-encoding for the sandbox input, but here we need to call it earlier + # in the process so we can even transport the globals *to* the codejail + # service. Otherwise, we may get a TypeError when constructing the payload. + # + # This is a lossy operation (non-serializable objects will be dropped, and + # bytes converted to strings) but it is the same lossy operation that + # codejail will perform anyhow -- and it should be idempotent. + data_send = {**data} + data_send['globals_dict'] = json_safe(data_send['globals_dict']) + + payload = json.dumps(data_send) try: response = requests.post( diff --git a/xmodule/capa/safe_exec/safe_exec.py b/xmodule/capa/safe_exec/safe_exec.py index d843a02831..cd7b553579 100644 --- a/xmodule/capa/safe_exec/safe_exec.py +++ b/xmodule/capa/safe_exec/safe_exec.py @@ -1,13 +1,24 @@ """Capa's specialized use of codejail.safe_exec.""" +import copy import hashlib +import logging +import re +from functools import lru_cache +from typing import assert_type from codejail.safe_exec import SafeExecException, json_safe from codejail.safe_exec import not_safe_exec as codejail_not_safe_exec from codejail.safe_exec import safe_exec as codejail_safe_exec -from edx_django_utils.monitoring import function_trace +from django.conf import settings +from django.dispatch import receiver +from django.test.signals import setting_changed +from edx_django_utils.monitoring import function_trace, record_exception, set_custom_attribute from . import lazymod -from .remote_exec import is_codejail_rest_service_enabled, get_remote_exec +from .remote_exec import get_remote_exec, is_codejail_in_darklaunch, is_codejail_rest_service_enabled + +log = logging.getLogger(__name__) + # Establish the Python environment for Capa. # Capa assumes float-friendly division always. @@ -16,7 +27,23 @@ CODE_PROLOG = """\ from __future__ import absolute_import, division import os -os.environ["OPENBLAS_NUM_THREADS"] = "1" # See TNL-6456 + +# openblas is a math library used by numpy. It will try to allocate multiple +# threads by default, but this may exceed resource limits and cause a segfault. +# Limiting to 1 thread will prevent this in all configurations. +os.environ["OPENBLAS_NUM_THREADS"] = "1" + +# Any code that uses the tempfile module to create temporary files should use +# the ./tmp directory that codejail creates in each sandbox, rather than trying +# to use a global temp dir (which should be blocked by AppArmor anyhow). +# This is needed for matplotlib among other things. +# +# matplotlib will complain on stderr about the non-standard temp dir if we +# don't explicitly tell it "no really, use this". This pollutes the output +# when codejail returns an error message (which includes stderr). So we also +# set MPLCONFIGDIR as a special case. +os.environ["TMPDIR"] = os.getcwd() + "/tmp" +os.environ["MPLCONFIGDIR"] = os.environ["TMPDIR"] import random2 as random_module import sys @@ -89,7 +116,7 @@ def safe_exec( limit_overrides_context=None, slug=None, unsafely=False, -): +): # pylint: disable=too-many-statements """ Execute python code safely. @@ -138,6 +165,8 @@ def safe_exec( raise SafeExecException(emsg) return + cacheable = True # unless we get an unexpected error + # Create the complete code we'll run. code_prolog = CODE_PROLOG % random_seed @@ -152,9 +181,16 @@ def safe_exec( "extra_files": extra_files, } - emsg, exception = get_remote_exec(data) + with function_trace('safe_exec.remote_exec'): + emsg, exception = get_remote_exec(data) else: + + # Create a copy so the originals are not modified as part of this call. + # This has to happen before local exec is run, since globals are modified + # as a side effect. + darklaunch_globals = copy.deepcopy(globals_dict) + # Decide which code executor to use. if unsafely: exec_fn = codejail_not_safe_exec @@ -163,27 +199,286 @@ def safe_exec( # Run the code! Results are side effects in globals_dict. try: - exec_fn( - code_prolog + LAZY_IMPORTS + code, - globals_dict, - python_path=python_path, - extra_files=extra_files, - limit_overrides_context=limit_overrides_context, - slug=slug, - ) - except SafeExecException as e: + trace_name = 'safe_exec.local_exec_darklaunch' if is_codejail_in_darklaunch() else 'safe_exec.local_exec' + with function_trace(trace_name): + exec_fn( + code_prolog + LAZY_IMPORTS + code, + globals_dict, + python_path=python_path, + extra_files=extra_files, + limit_overrides_context=limit_overrides_context, + slug=slug, + ) + except BaseException as e: # Saving SafeExecException e in exception to be used later. exception = e emsg = str(e) + if not isinstance(exception, SafeExecException): + # Something unexpected happened, so don't cache this evaluation. + # (We may decide to cache these in the future as well; this is just + # preserving existing behavior during a refactor of error handling.) + cacheable = False else: + exception = None emsg = None + # Run the code in both the remote codejail service as well as the local codejail + # when in darklaunch mode. + if is_codejail_in_darklaunch(): + # Start adding attributes only once we're in a darklaunch + # comparison, even though these particular ones aren't specific to + # darklaunch. There can be multiple codejail calls per trace, and + # these attrs will overwrite previous values in the same trace. When + # that happens, we need to ensure we overwrite *all* of them, + # otherwise we could end up with inconsistent combinations of values. + + # .. custom_attribute_name: codejail.slug + # .. custom_attribute_description: Value of the slug parameter. This + # might be a problem ID, if present. + set_custom_attribute('codejail.slug', slug) + # .. custom_attribute_name: codejail.limit_overrides_context + # .. custom_attribute_description: Value of the limit_overrides_context + # parameter to this code execution. Generally this will be the + # course name, if present at all. + set_custom_attribute('codejail.limit_overrides_context', limit_overrides_context) + # .. custom_attribute_name: codejail.extra_files_count + # .. custom_attribute_description: Number of extra_files included + # in request. This should be 0 or 1, the latter indicating a + # python_lib.zip was present. + set_custom_attribute('codejail.extra_files_count', len(extra_files) if extra_files else 0) + + try: + data = { + "code": code_prolog + LAZY_IMPORTS + code, + "globals_dict": darklaunch_globals, + "python_path": python_path, + "limit_overrides_context": limit_overrides_context, + "slug": slug, + "unsafely": unsafely, + "extra_files": extra_files, + } + with function_trace('safe_exec.remote_exec_darklaunch'): + # Ignore the returned exception, because it's just a + # SafeExecException wrapped around emsg (if present). + remote_emsg, _ = get_remote_exec(data) + remote_exception = None + except BaseException as e: # pragma: no cover # pylint: disable=broad-except + # Swallow all exceptions and log it in monitoring so that dark launch doesn't cause issues during + # deploy. + remote_emsg = None + remote_exception = e + + try: + local_exc_unexpected = None if isinstance(exception, SafeExecException) else exception + + report_darklaunch_results( + limit_overrides_context=limit_overrides_context, slug=slug, + globals_local=globals_dict, emsg_local=emsg, unexpected_exc_local=local_exc_unexpected, + globals_remote=darklaunch_globals, emsg_remote=remote_emsg, unexpected_exc_remote=remote_exception, + ) + except BaseException as e: # pragma: no cover # pylint: disable=broad-except + log.exception("Error occurred while trying to report codejail darklaunch data.") + record_exception() + # Put the result back in the cache. This is complicated by the fact that # the globals dict might not be entirely serializable. - if cache: + if cache and cacheable: cleaned_results = json_safe(globals_dict) cache.set(key, (emsg, cleaned_results)) # If an exception happened, raise it now. - if emsg: + if exception: raise exception + + +def _compile_normalizers(normalizer_setting): + """ + Compile emsg normalizer search/replace pairs into regex. + + Raises exception on bad settings. + """ + compiled = [] + for pair in normalizer_setting: + search = re.compile(assert_type(pair['search'], str)) + replace = assert_type(pair['replace'], str) + + # Test the replacement string (might contain errors) + re.sub(search, replace, "example") + + compiled.append({'search': search, 'replace': replace}) + return compiled + + +@lru_cache(maxsize=1) +def emsg_normalizers(): + """ + Load emsg normalization settings. + + The output is like the setting value, except the 'search' patterns have + been compiled. + """ + default_setting = [ + { + # Character range should be at least as broad as what Python's `tempfile` uses. + 'search': r'/tmp/codejail-[0-9a-zA-Z_]+', + 'replace': r'/tmp/codejail-', + }, + + # These are useful for eliding differences in environments due to Python version: + + { + # Python 3.8 doesn't include the dir here, but Python 3.12 + # does. Normalize to the 3.8 version. + 'search': r'File "/tmp/codejail-/jailed_code"', + 'replace': r'File "jailed_code"' + }, + { + # Python version shows up in stack traces in the virtualenv paths + 'search': r'python3\.[0-9]+', + 'replace': r'python3.XX' + }, + { + # Line numbers in stack traces differ between Python versions + 'search': r', line [0-9]+, in ', + 'replace': r', line XXX, in ' + }, + { + # Some time after 3.8, Python started adding '^^^' indicators to stack traces + 'search': r'\\n\s*\^+\s*\\n', + 'replace': r'\\n' + }, + { + # Python3.8 had these stack trace elements but 3.12 does not + 'search': r'\\n File "[^"]+", line [0-9]+, in \\n', + 'replace': r'\\n' + }, + ] + default_normalizers = _compile_normalizers(default_setting) + + # .. setting_name: CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS + # .. setting_default: [] + # .. setting_description: A list of patterns to search and replace in codejail error + # messages during comparison in codejail-service darklaunch. Each entry is a dict + # of 'search' (a regular expression string) and 'replace' (the replacement string). + # Deployers may also need to add a search/replace pair for the location of the sandbox + # virtualenv, or any other paths that show up in stack traces. + # .. setting_warning: Note that `replace' is a pattern, allowing for + # backreferences. Any backslashes in the replacement pattern that are not + # intended as backreferences should be escaped as `\\`. + # The default list suppresses differences due to the randomly-named sandboxes + # or to differences due to Python version. See setting + # ``CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS_COMBINE`` for information on how + # this setting interacts with the defaults. + custom_setting = getattr(settings, 'CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS', []) + try: + custom_normalizers = _compile_normalizers(custom_setting) + except BaseException as e: + log.error("Could not load custom codejail darklaunch emsg normalizers") + record_exception() + return default_normalizers + + # .. setting_name: CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS_COMBINE + # .. setting_default: 'append' + # .. setting_description: How to combine ``CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS`` + # with the defaults. If the value is 'replace', the defaults will be replaced + # with the specified patterns. If the value is 'append' (the default), the + # specified replacements will be run after the defaults. + combine = getattr(settings, 'CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS_COMBINE', 'append') + if combine == 'replace': + return custom_normalizers + else: # 'append', or unknown + return default_normalizers + custom_normalizers + + +def normalize_error_message(emsg): + """ + Remove any uninteresting sources of discrepancy from an emsg. + """ + if emsg is None: + return None + + for replacer in emsg_normalizers(): + emsg = re.sub(replacer['search'], replacer['replace'], emsg, count=0) + + return emsg + + +def report_darklaunch_results( + *, limit_overrides_context, slug, + globals_local, emsg_local, unexpected_exc_local, + globals_remote, emsg_remote, unexpected_exc_remote, +): + """Send telemetry for results of darklaunch.""" + can_compare_output = True + + def report_arm(arm, emsg, unexpected_exception): + """ + Set custom attributes for each arm of the darklaunch experiment. + + `arm` should be 'local' or 'remote'. + """ + nonlocal can_compare_output + if unexpected_exception: + # .. custom_attribute_name: codejail.darklaunch.status.{local,remote} + # .. custom_attribute_description: Outcome of this arm of the + # darklaunch comparison. Values can be 'ok' (normal execution), + # 'safe_error' (submitted code raised an exception), or + # 'unexpected_error' (uncaught error in submitting or evaluating code). + set_custom_attribute(f'codejail.darklaunch.status.{arm}', 'unexpected_error') + # .. custom_attribute_name: codejail.darklaunch.exception.{local,remote} + # .. custom_attribute_description: When the status attribute indicates + # an unexpected error, this is a string representation of the error, + # otherwise None. + set_custom_attribute(f'codejail.darklaunch.exception.{arm}', repr(unexpected_exception)) + can_compare_output = False + else: + set_custom_attribute(f'codejail.darklaunch.status.{arm}', 'ok' if emsg is None else 'safe_error') + set_custom_attribute(f'codejail.darklaunch.exception.{arm}', None) + + report_arm('local', emsg_local, unexpected_exc_local) + report_arm('remote', emsg_remote, unexpected_exc_remote) + + # If the arms can't be compared (unexpected errors), stop early -- the rest + # is about output comparison. + if not can_compare_output: + set_custom_attribute('codejail.darklaunch.globals_match', 'N/A') + set_custom_attribute('codejail.darklaunch.emsg_match', 'N/A') + log.info( + "Codejail darklaunch had unexpected exception for " + f"course={limit_overrides_context!r}, slug={slug!r}:\n" + f"Local exception: {unexpected_exc_local!r}\n" + f"Remote exception: {unexpected_exc_remote!r}" + ) + return + + globals_match = globals_local == globals_remote + emsg_match = normalize_error_message(emsg_local) == normalize_error_message(emsg_remote) + + if not globals_match or not emsg_match: + log.info( + f"Codejail darklaunch had mismatch for course={limit_overrides_context!r}, slug={slug!r}:\n" + f"{emsg_match=}, {globals_match=}\n" + f"Local: globals={globals_local!r}, emsg={emsg_local!r}\n" + f"Remote: globals={globals_remote!r}, emsg={emsg_remote!r}" + ) + + # .. custom_attribute_name: codejail.darklaunch.globals_match + # .. custom_attribute_description: True if local and remote globals_dict + # values match, False otherwise. 'N/A' when either arm raised an + # uncaught error. + set_custom_attribute('codejail.darklaunch.globals_match', globals_match) + # .. custom_attribute_name: codejail.darklaunch.emsg_match + # .. custom_attribute_description: True if the local and remote emsg values + # (errors returned from sandbox) match, False otherwise. Differences due + # to known irrelevant factors are suppressed in this comparison, such as + # the randomized directory names used for sandboxes. 'N/A' when either + # arm raised an uncaught error. + set_custom_attribute('codejail.darklaunch.emsg_match', emsg_match) + + +@receiver(setting_changed) +def reset_caches(sender, **kwargs): + """ + Reset cached settings during unit tests. + """ + emsg_normalizers.cache_clear() diff --git a/xmodule/capa/safe_exec/tests/test_remote_exec.py b/xmodule/capa/safe_exec/tests/test_remote_exec.py new file mode 100644 index 0000000000..ee1ee49383 --- /dev/null +++ b/xmodule/capa/safe_exec/tests/test_remote_exec.py @@ -0,0 +1,32 @@ +""" +Tests for remote codejail execution. +""" + +import json +from unittest import TestCase +from unittest.mock import patch + +from django.test import override_settings + +from xmodule.capa.safe_exec.remote_exec import get_remote_exec + + +class TestRemoteExec(TestCase): + """Tests for remote_exec.""" + + @override_settings( + ENABLE_CODEJAIL_REST_SERVICE=True, + CODE_JAIL_REST_SERVICE_HOST='http://localhost', + ) + @patch('requests.post') + def test_json_encode(self, mock_post): + get_remote_exec({ + 'code': "out = 1 + 1", + 'globals_dict': {'some_data': b'bytes', 'unusable': object()}, + 'extra_files': None, + }) + + mock_post.assert_called_once() + data_arg = mock_post.call_args_list[0][1]['data'] + payload = json.loads(data_arg['payload']) + assert payload['globals_dict'] == {'some_data': 'bytes'} diff --git a/xmodule/capa/safe_exec/tests/test_safe_exec.py b/xmodule/capa/safe_exec/tests/test_safe_exec.py index 36d2cc9657..d7679b66aa 100644 --- a/xmodule/capa/safe_exec/tests/test_safe_exec.py +++ b/xmodule/capa/safe_exec/tests/test_safe_exec.py @@ -1,11 +1,13 @@ """Test safe_exec.py""" +import copy import hashlib import os import os.path import textwrap import unittest +from unittest.mock import call, patch import pytest import random2 as random @@ -20,9 +22,12 @@ from six.moves import range from openedx.core.djangolib.testing.utils import skip_unless_lms from xmodule.capa.safe_exec import safe_exec, update_hash -from xmodule.capa.safe_exec.remote_exec import is_codejail_rest_service_enabled +from xmodule.capa.safe_exec.remote_exec import is_codejail_in_darklaunch, is_codejail_rest_service_enabled +from xmodule.capa.safe_exec.safe_exec import emsg_normalizers, normalize_error_message +from xmodule.capa.tests.test_util import use_unsafe_codejail +@use_unsafe_codejail() class TestSafeExec(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring def test_set_values(self): g = {} @@ -125,6 +130,316 @@ class TestSafeOrNot(unittest.TestCase): # lint-amnesty, pylint: disable=missing assert "SystemExit" in str(cm) +class TestCodeJailDarkLaunch(unittest.TestCase): + """ + Test that the behavior of the dark launched code behaves as expected. + """ + @patch('xmodule.capa.safe_exec.safe_exec.get_remote_exec') + @patch('xmodule.capa.safe_exec.safe_exec.codejail_safe_exec') + def test_default_code_execution(self, mock_local_exec, mock_remote_exec): + + # Test default only runs local exec. + g = {} + safe_exec('a=1', g) + assert mock_local_exec.called + assert not mock_remote_exec.called + + @override_settings(ENABLE_CODEJAIL_REST_SERVICE=True) + @patch('xmodule.capa.safe_exec.safe_exec.get_remote_exec') + @patch('xmodule.capa.safe_exec.safe_exec.codejail_safe_exec') + def test_code_execution_only_codejail_service(self, mock_local_exec, mock_remote_exec): + # Set return values to empty values to indicate no error. + mock_remote_exec.return_value = (None, None) + # Test with only the service enabled. + g = {} + safe_exec('a=1', g) + assert not mock_local_exec.called + assert mock_remote_exec.called + + @override_settings(ENABLE_CODEJAIL_DARKLAUNCH=True) + @patch('xmodule.capa.safe_exec.safe_exec.get_remote_exec') + @patch('xmodule.capa.safe_exec.safe_exec.codejail_safe_exec') + def test_code_execution_darklaunch_misconfig(self, mock_local_exec, mock_remote_exec): + """Test that darklaunch doesn't run when remote service is generally enabled.""" + mock_remote_exec.return_value = (None, None) + + with override_settings(ENABLE_CODEJAIL_REST_SERVICE=True): + safe_exec('a=1', {}) + + assert not mock_local_exec.called + assert mock_remote_exec.called + + @override_settings(ENABLE_CODEJAIL_DARKLAUNCH=True) + def run_dark_launch( + self, globals_dict, local, remote, + expect_attr_calls, expect_log_info_calls, expect_globals_contains, + ): + """ + Run a darklaunch scenario with mocked out local and remote execution. + + Asserts set_custom_attribute and log.info calls and (partial) contents + of globals dict. + + Return value is a dictionary of: + + - 'raised': Exception that safe_exec raised, or None. + """ + + assert is_codejail_in_darklaunch() + + with ( + patch('xmodule.capa.safe_exec.safe_exec.codejail_safe_exec') as mock_local_exec, + patch('xmodule.capa.safe_exec.safe_exec.get_remote_exec') as mock_remote_exec, + patch('xmodule.capa.safe_exec.safe_exec.set_custom_attribute') as mock_set_custom_attribute, + patch('xmodule.capa.safe_exec.safe_exec.log.info') as mock_log_info, + ): + mock_local_exec.side_effect = local + mock_remote_exec.side_effect = remote + + try: + safe_exec( + "", globals_dict, + limit_overrides_context="course-v1:org+course+run", slug="hw1", + ) + except BaseException as e: + safe_exec_e = e + else: + safe_exec_e = None + + # Always want both sides to be called + assert mock_local_exec.called + assert mock_remote_exec.called + + mock_set_custom_attribute.assert_has_calls(expect_attr_calls, any_order=True) + mock_log_info.assert_has_calls(expect_log_info_calls, any_order=True) + + for (k, v) in expect_globals_contains.items(): + assert globals_dict[k] == v + + return {'raised': safe_exec_e} + + # These don't change between the tests + standard_codejail_attr_calls = [ + call('codejail.slug', 'hw1'), + call('codejail.limit_overrides_context', 'course-v1:org+course+run'), + call('codejail.extra_files_count', 0), + ] + + def test_separate_globals(self): + """Test that local and remote globals are isolated from each other's side effects.""" + # Both will attempt to read and write the 'overwrite' key. + globals_dict = {'overwrite': 'original'} + + local_globals = None + remote_globals = None + + def local_exec(code, globals_dict, **kwargs): + # Preserve what local exec saw + nonlocal local_globals + local_globals = copy.deepcopy(globals_dict) + + globals_dict['overwrite'] = 'mock local' + + def remote_exec(data): + # Preserve what remote exec saw + nonlocal remote_globals + remote_globals = copy.deepcopy(data['globals_dict']) + + data['globals_dict']['overwrite'] = 'mock remote' + return (None, None) + + results = self.run_dark_launch( + globals_dict=globals_dict, local=local_exec, remote=remote_exec, + expect_attr_calls=[ + *self.standard_codejail_attr_calls, + call('codejail.darklaunch.status.local', 'ok'), + call('codejail.darklaunch.status.remote', 'ok'), + call('codejail.darklaunch.exception.local', None), + call('codejail.darklaunch.exception.remote', None), + call('codejail.darklaunch.globals_match', False), # mismatch revealed here + call('codejail.darklaunch.emsg_match', True), + ], + expect_log_info_calls=[ + call( + "Codejail darklaunch had mismatch for " + "course='course-v1:org+course+run', slug='hw1':\n" + "emsg_match=True, globals_match=False\n" + "Local: globals={'overwrite': 'mock local'}, emsg=None\n" + "Remote: globals={'overwrite': 'mock remote'}, emsg=None" + ), + ], + # Should only see behavior of local exec + expect_globals_contains={'overwrite': 'mock local'}, + ) + assert results['raised'] is None + + # Both arms should have only seen the original globals object, untouched + # by the other arm. + assert local_globals == {'overwrite': 'original'} + assert remote_globals == {'overwrite': 'original'} + + def test_remote_runs_even_if_local_raises(self): + """Test that remote exec runs even if local raises.""" + def local_exec(code, globals_dict, **kwargs): + # Raise something other than a SafeExecException. + raise BaseException("unexpected") + + def remote_exec(data): + return (None, None) + + results = self.run_dark_launch( + globals_dict={}, local=local_exec, remote=remote_exec, + expect_attr_calls=[ + *self.standard_codejail_attr_calls, + call('codejail.darklaunch.status.local', 'unexpected_error'), + call('codejail.darklaunch.status.remote', 'ok'), + call('codejail.darklaunch.exception.local', "BaseException('unexpected')"), + call('codejail.darklaunch.exception.remote', None), + call('codejail.darklaunch.globals_match', "N/A"), + call('codejail.darklaunch.emsg_match', "N/A"), + ], + expect_log_info_calls=[ + call( + "Codejail darklaunch had unexpected exception " + "for course='course-v1:org+course+run', slug='hw1':\n" + "Local exception: BaseException('unexpected')\n" + "Remote exception: None" + ), + ], + expect_globals_contains={}, + ) + + # Unexpected errors from local safe_exec propagate up. + assert isinstance(results['raised'], BaseException) + assert 'unexpected' in repr(results['raised']) + + def test_emsg_mismatch(self): + """Test that local and remote error messages are compared.""" + def local_exec(code, globals_dict, **kwargs): + raise SafeExecException("oops") + + def remote_exec(data): + return ("OH NO", SafeExecException("OH NO")) + + results = self.run_dark_launch( + globals_dict={}, local=local_exec, remote=remote_exec, + expect_attr_calls=[ + *self.standard_codejail_attr_calls, + call('codejail.darklaunch.status.local', 'safe_error'), + call('codejail.darklaunch.status.remote', 'safe_error'), + call('codejail.darklaunch.exception.local', None), + call('codejail.darklaunch.exception.remote', None), + call('codejail.darklaunch.globals_match', True), + call('codejail.darklaunch.emsg_match', False), # mismatch revealed here + ], + expect_log_info_calls=[ + call( + "Codejail darklaunch had mismatch for " + "course='course-v1:org+course+run', slug='hw1':\n" + "emsg_match=False, globals_match=True\n" + "Local: globals={}, emsg='oops'\n" + "Remote: globals={}, emsg='OH NO'" + ), + ], + expect_globals_contains={}, + ) + assert isinstance(results['raised'], SafeExecException) + assert 'oops' in repr(results['raised']) + + def test_ignore_sandbox_dir_mismatch(self): + """Mismatch due only to differences in sandbox directory should be ignored.""" + def local_exec(code, globals_dict, **kwargs): + raise SafeExecException("stack trace involving /tmp/codejail-1234567/whatever.py") + + def remote_exec(data): + emsg = "stack trace involving /tmp/codejail-abcd_EFG/whatever.py" + return (emsg, SafeExecException(emsg)) + + results = self.run_dark_launch( + globals_dict={}, local=local_exec, remote=remote_exec, + expect_attr_calls=[ + *self.standard_codejail_attr_calls, + call('codejail.darklaunch.status.local', 'safe_error'), + call('codejail.darklaunch.status.remote', 'safe_error'), + call('codejail.darklaunch.exception.local', None), + call('codejail.darklaunch.exception.remote', None), + call('codejail.darklaunch.globals_match', True), + call('codejail.darklaunch.emsg_match', True), # even though not exact match + ], + expect_log_info_calls=[], + expect_globals_contains={}, + ) + assert isinstance(results['raised'], SafeExecException) + assert 'whatever.py' in repr(results['raised']) + + def test_default_normalizers(self): + """ + Default normalizers handle false mismatches we've observed. + + This just provides coverage for some of the more complicated patterns. + """ + side_1 = ( + 'Couldn\'t execute jailed code: stdout: b\'\', stderr: b\'Traceback' + ' (most recent call last):\\n File "/tmp/codejail-9g9715g_/jailed_code"' + ', line 19, in \\n exec(code, g_dict)\\n File ""' + ', line 1, in \\n File "", line 89, in test_add\\n' + ' File "", line 1\\n import random random.choice(range(10))' + '\\n ^\\nSyntaxError: invalid syntax\\n\' with status code: 1' + ) + side_2 = ( + 'Couldn\'t execute jailed code: stdout: b\'\', stderr: b\'Traceback' + ' (most recent call last):\\n File "jailed_code"' + ', line 19, in \\n exec(code, g_dict)\\n File ""' + ', line 203, in \\n File "", line 89, in test_add\\n' + ' File "", line 1\\n import random random.choice(range(10))' + '\\n ^^^^^^\\nSyntaxError: invalid syntax\\n\' with status code: 1' + ) + assert normalize_error_message(side_1) == normalize_error_message(side_2) + + @override_settings(CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS=[ + { + 'search': r'[0-9]+', + 'replace': r'', + }, + ]) + def test_configurable_normalizers(self): + """We can augment the normalizers, and they run in order.""" + emsg_in = "Error in /tmp/codejail-1234abcd/whatever.py: something 12 34 other" + expect_out = "Error in /tmp/codejail-/whatever.py: something other" + assert expect_out == normalize_error_message(emsg_in) + + @override_settings( + CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS=[ + { + 'search': r'[0-9]+', + 'replace': r'', + }, + ], + CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS_COMBINE='replace', + ) + def test_can_replace_normalizers(self): + """We can replace the normalizers.""" + emsg_in = "Error in /tmp/codejail-1234abcd/whatever.py: something 12 34 other" + expect_out = "Error in /tmp/codejail-abcd/whatever.py: something other" + assert expect_out == normalize_error_message(emsg_in) + + @override_settings(CODEJAIL_DARKLAUNCH_EMSG_NORMALIZERS=[ + { + 'search': r'broken', + 'replace': r'replace \g<>', # invalid replacement pattern + }, + ]) + @patch('xmodule.capa.safe_exec.safe_exec.record_exception') + @patch('xmodule.capa.safe_exec.safe_exec.log.error') + def test_normalizers_validate(self, mock_log_error, mock_record_exception): + """Normalizers are validated, and fall back to default list on error.""" + assert len(emsg_normalizers()) > 0 # pylint: disable=use-implicit-booleaness-not-comparison + mock_log_error.assert_called_once_with( + "Could not load custom codejail darklaunch emsg normalizers" + ) + mock_record_exception.assert_called_once() + + class TestLimitConfiguration(unittest.TestCase): """ Test that resource limits can be configured and overriden via Django settings. @@ -217,6 +532,7 @@ class DictCache(object): self.cache[key] = value +@use_unsafe_codejail() class TestSafeExecCaching(unittest.TestCase): """Test that caching works on safe_exec.""" @@ -341,6 +657,7 @@ class TestUpdateHash(unittest.TestCase): assert h1 == h2 +@use_unsafe_codejail() class TestRealProblems(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring def test_802x(self): code = textwrap.dedent("""\ diff --git a/xmodule/capa/tests/helpers.py b/xmodule/capa/tests/helpers.py index 540d459c5a..9708f42625 100644 --- a/xmodule/capa/tests/helpers.py +++ b/xmodule/capa/tests/helpers.py @@ -58,10 +58,9 @@ class StubXQueueService: return dispatch -def test_capa_system(render_template=None): +def mock_capa_system(render_template=None): """ Construct a mock LoncapaSystem instance. - """ the_system = Mock( spec=LoncapaSystem, @@ -102,7 +101,7 @@ def mock_capa_block(): def new_loncapa_problem(xml, problem_id='1', capa_system=None, seed=723, use_capa_render_template=False): """Construct a `LoncapaProblem` suitable for unit tests.""" render_template = capa_render_template if use_capa_render_template else None - return LoncapaProblem(xml, id=problem_id, seed=seed, capa_system=capa_system or test_capa_system(render_template), + return LoncapaProblem(xml, id=problem_id, seed=seed, capa_system=capa_system or mock_capa_system(render_template), capa_block=mock_capa_block()) diff --git a/xmodule/capa/tests/test_answer_pool.py b/xmodule/capa/tests/test_answer_pool.py index f4f65d3ee9..28de918580 100644 --- a/xmodule/capa/tests/test_answer_pool.py +++ b/xmodule/capa/tests/test_answer_pool.py @@ -8,14 +8,15 @@ import textwrap import unittest from xmodule.capa.responsetypes import LoncapaProblemError -from xmodule.capa.tests.helpers import new_loncapa_problem, test_capa_system +from xmodule.capa.tests.helpers import new_loncapa_problem, mock_capa_system class CapaAnswerPoolTest(unittest.TestCase): """Capa Answer Pool Test""" + def setUp(self): super(CapaAnswerPoolTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments - self.system = test_capa_system() + self.system = mock_capa_system() # XML problem setup used by a few tests. common_question_xml = textwrap.dedent(""" diff --git a/xmodule/capa/tests/test_capa_problem.py b/xmodule/capa/tests/test_capa_problem.py index 74cf4d096f..5f42b91849 100644 --- a/xmodule/capa/tests/test_capa_problem.py +++ b/xmodule/capa/tests/test_capa_problem.py @@ -15,6 +15,7 @@ from markupsafe import Markup from xmodule.capa.correctmap import CorrectMap from xmodule.capa.responsetypes import LoncapaProblemError from xmodule.capa.tests.helpers import new_loncapa_problem +from xmodule.capa.tests.test_util import use_unsafe_codejail from openedx.core.djangolib.markup import HTML @@ -23,6 +24,7 @@ FEATURES_WITH_GRADING_METHOD_IN_PROBLEMS['ENABLE_GRADING_METHOD_IN_PROBLEMS'] = @ddt.ddt +@use_unsafe_codejail() class CAPAProblemTest(unittest.TestCase): """ CAPA problem related tests""" @@ -424,6 +426,7 @@ class CAPAProblemTest(unittest.TestCase): @ddt.ddt +@use_unsafe_codejail() class CAPAMultiInputProblemTest(unittest.TestCase): """ TestCase for CAPA problems with multiple inputtypes """ diff --git a/xmodule/capa/tests/test_customrender.py b/xmodule/capa/tests/test_customrender.py index 9330960844..0a16f764fe 100644 --- a/xmodule/capa/tests/test_customrender.py +++ b/xmodule/capa/tests/test_customrender.py @@ -6,7 +6,7 @@ import xml.sax.saxutils as saxutils from lxml import etree from xmodule.capa import customrender -from xmodule.capa.tests.helpers import test_capa_system +from xmodule.capa.tests.helpers import mock_capa_system # just a handy shortcut lookup_tag = customrender.registry.get_class_for_tag @@ -28,8 +28,9 @@ class HelperTest(unittest.TestCase): ''' Make sure that our helper function works! ''' + def check(self, d): - xml = etree.XML(test_capa_system().render_template('blah', d)) + xml = etree.XML(mock_capa_system().render_template('blah', d)) assert d == extract_context(xml) def test_extract_context(self): @@ -49,7 +50,7 @@ class SolutionRenderTest(unittest.TestCase): xml_str = """{s}""".format(s=solution) element = etree.fromstring(xml_str) - renderer = lookup_tag('solution')(test_capa_system(), element) + renderer = lookup_tag('solution')(mock_capa_system(), element) assert renderer.id == 'solution_12' @@ -68,7 +69,7 @@ class MathRenderTest(unittest.TestCase): xml_str = """{tex}""".format(tex=latex_in) element = etree.fromstring(xml_str) - renderer = lookup_tag('math')(test_capa_system(), element) + renderer = lookup_tag('math')(mock_capa_system(), element) assert renderer.mathstr == mathjax_out diff --git a/xmodule/capa/tests/test_errors.py b/xmodule/capa/tests/test_errors.py new file mode 100644 index 0000000000..222eef988d --- /dev/null +++ b/xmodule/capa/tests/test_errors.py @@ -0,0 +1,57 @@ +""" +Unit tests for custom error handling in the XQueue submission interface. +""" + +import pytest +from xmodule.capa.errors import ( + JSONParsingError, + MissingKeyError, + ValidationError, + TypeErrorSubmission, + RuntimeErrorSubmission, + GetSubmissionParamsError +) + + +def test_json_parsing_error(): + with pytest.raises(JSONParsingError) as excinfo: + raise JSONParsingError("test_name", "test_error") + assert str(excinfo.value) == "Error parsing test_name: test_error" + + +def test_missing_key_error(): + with pytest.raises(MissingKeyError) as excinfo: + raise MissingKeyError("test_key") + assert str(excinfo.value) == "Missing key: test_key" + + +def test_validation_error(): + with pytest.raises(ValidationError) as excinfo: + raise ValidationError("test_error") + assert str(excinfo.value) == "Validation error: test_error" + + +def test_type_error_submission(): + with pytest.raises(TypeErrorSubmission) as excinfo: + raise TypeErrorSubmission("test_error") + assert str(excinfo.value) == "Type error: test_error" + + +def test_runtime_error_submission(): + with pytest.raises(RuntimeErrorSubmission) as excinfo: + raise RuntimeErrorSubmission("test_error") + assert str(excinfo.value) == "Runtime error: test_error" + + +def test_get_submission_params_error_default(): + """Test GetSubmissionParamsError with default message.""" + with pytest.raises(GetSubmissionParamsError) as excinfo: + raise GetSubmissionParamsError() + assert str(excinfo.value) == "Submission parameters error: Block instance is not defined!" + + +def test_get_submission_params_error_custom(): + """Test GetSubmissionParamsError with a custom error message.""" + with pytest.raises(GetSubmissionParamsError) as excinfo: + raise GetSubmissionParamsError("Custom error message") + assert str(excinfo.value) == "Submission parameters error: Custom error message" diff --git a/xmodule/capa/tests/test_html_render.py b/xmodule/capa/tests/test_html_render.py index 2a9a786771..46ad47d79a 100644 --- a/xmodule/capa/tests/test_html_render.py +++ b/xmodule/capa/tests/test_html_render.py @@ -10,13 +10,15 @@ from unittest import mock import ddt from lxml import etree -from xmodule.capa.tests.helpers import new_loncapa_problem, test_capa_system +from xmodule.capa.tests.helpers import new_loncapa_problem, mock_capa_system +from xmodule.capa.tests.test_util import use_unsafe_codejail from openedx.core.djangolib.markup import HTML from .response_xml_factory import CustomResponseXMLFactory, StringResponseXMLFactory @ddt.ddt +@use_unsafe_codejail() class CapaHtmlRenderTest(unittest.TestCase): """ CAPA HTML rendering tests class. @@ -24,7 +26,7 @@ class CapaHtmlRenderTest(unittest.TestCase): def setUp(self): super(CapaHtmlRenderTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments - self.capa_system = test_capa_system() + self.capa_system = mock_capa_system() def test_blank_problem(self): """ @@ -148,7 +150,7 @@ class CapaHtmlRenderTest(unittest.TestCase): xml_str = StringResponseXMLFactory().build_xml(**kwargs) # Mock out the template renderer - the_system = test_capa_system() + the_system = mock_capa_system() the_system.render_template = mock.Mock() the_system.render_template.return_value = "
    Input Template Render
    " diff --git a/xmodule/capa/tests/test_input_templates.py b/xmodule/capa/tests/test_input_templates.py index 4b14bd5ef8..3048f62095 100644 --- a/xmodule/capa/tests/test_input_templates.py +++ b/xmodule/capa/tests/test_input_templates.py @@ -76,8 +76,7 @@ class TemplateTestCase(unittest.TestCase): 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/capa/tests/test_inputtypes.py b/xmodule/capa/tests/test_inputtypes.py index 4e14bc42b7..523f303b45 100644 --- a/xmodule/capa/tests/test_inputtypes.py +++ b/xmodule/capa/tests/test_inputtypes.py @@ -34,7 +34,7 @@ from six.moves import zip from xmodule.capa import inputtypes from xmodule.capa.checker import DemoSystem -from xmodule.capa.tests.helpers import test_capa_system +from xmodule.capa.tests.helpers import mock_capa_system from xmodule.capa.xqueue_interface import XQUEUE_TIMEOUT from openedx.core.djangolib.markup import HTML @@ -72,7 +72,7 @@ class OptionInputTest(unittest.TestCase): 'default_option_text': 'Select an option', 'response_data': RESPONSE_DATA } - option_input = lookup_tag('optioninput')(test_capa_system(), element, state) + option_input = lookup_tag('optioninput')(mock_capa_system(), element, state) context = option_input._get_render_context() # pylint: disable=protected-access prob_id = 'sky_input' @@ -138,7 +138,7 @@ class ChoiceGroupTest(unittest.TestCase): 'response_data': RESPONSE_DATA } - the_input = lookup_tag(tag)(test_capa_system(), element, state) + the_input = lookup_tag(tag)(mock_capa_system(), element, state) context = the_input._get_render_context() # pylint: disable=protected-access @@ -233,7 +233,7 @@ class JSInputTest(unittest.TestCase): 'value': 103, 'response_data': RESPONSE_DATA } - the_input = lookup_tag('jsinput')(test_capa_system(), element, state) + the_input = lookup_tag('jsinput')(mock_capa_system(), element, state) context = the_input._get_render_context() # pylint: disable=protected-access @@ -270,7 +270,7 @@ class TextLineTest(unittest.TestCase): 'value': 'BumbleBee', 'response_data': RESPONSE_DATA } - the_input = lookup_tag('textline')(test_capa_system(), element, state) + the_input = lookup_tag('textline')(mock_capa_system(), element, state) context = the_input._get_render_context() # pylint: disable=protected-access prob_id = 'prob_1_2' @@ -306,7 +306,7 @@ class TextLineTest(unittest.TestCase): 'value': 'BumbleBee', 'response_data': RESPONSE_DATA } - the_input = lookup_tag('textline')(test_capa_system(), element, state) + the_input = lookup_tag('textline')(mock_capa_system(), element, state) context = the_input._get_render_context() # pylint: disable=protected-access prob_id = 'prob_1_2' @@ -354,7 +354,7 @@ class TextLineTest(unittest.TestCase): 'value': 'BumbleBee', 'response_data': RESPONSE_DATA } - the_input = lookup_tag('textline')(test_capa_system(), element, state) + the_input = lookup_tag('textline')(mock_capa_system(), element, state) context = the_input._get_render_context() # pylint: disable=protected-access prob_id = 'prob_1_2' @@ -400,7 +400,7 @@ class FileSubmissionTest(unittest.TestCase): 'response_data': RESPONSE_DATA } input_class = lookup_tag('filesubmission') - the_input = input_class(test_capa_system(), element, state) + the_input = input_class(mock_capa_system(), element, state) context = the_input._get_render_context() # pylint: disable=protected-access prob_id = 'prob_1_2' @@ -450,7 +450,7 @@ class CodeInputTest(unittest.TestCase): } input_class = lookup_tag('codeinput') - the_input = input_class(test_capa_system(), element, state) + the_input = input_class(mock_capa_system(), element, state) context = the_input._get_render_context() # pylint: disable=protected-access prob_id = 'prob_1_2' @@ -510,7 +510,7 @@ class MatlabTest(unittest.TestCase): } self.input_class = lookup_tag('matlabinput') - self.the_input = self.input_class(test_capa_system(), elt, state) + self.the_input = self.input_class(mock_capa_system(), elt, state) def test_rendering(self): context = self.the_input._get_render_context() # pylint: disable=protected-access @@ -547,7 +547,7 @@ class MatlabTest(unittest.TestCase): } elt = etree.fromstring(self.xml) - the_input = self.input_class(test_capa_system(), elt, state) + the_input = self.input_class(mock_capa_system(), elt, state) context = the_input._get_render_context() # pylint: disable=protected-access prob_id = 'prob_1_2' expected = { @@ -582,7 +582,7 @@ class MatlabTest(unittest.TestCase): } elt = etree.fromstring(self.xml) prob_id = 'prob_1_2' - the_input = self.input_class(test_capa_system(), elt, state) + the_input = self.input_class(mock_capa_system(), elt, state) context = the_input._get_render_context() # pylint: disable=protected-access expected = { 'STATIC_URL': '/dummy-static/', @@ -616,7 +616,7 @@ class MatlabTest(unittest.TestCase): } elt = etree.fromstring(self.xml) prob_id = 'prob_1_2' - the_input = self.input_class(test_capa_system(), elt, state) + the_input = self.input_class(mock_capa_system(), elt, state) context = the_input._get_render_context() # pylint: disable=protected-access expected = { 'STATIC_URL': '/dummy-static/', @@ -668,7 +668,7 @@ class MatlabTest(unittest.TestCase): 'feedback': {'message': '3'}, } elt = etree.fromstring(self.xml) - the_input = self.input_class(test_capa_system(), elt, state) + the_input = self.input_class(mock_capa_system(), elt, state) inner_msg = 'hello!' queue_msg = json.dumps({'msg': inner_msg}) @@ -687,7 +687,7 @@ class MatlabTest(unittest.TestCase): 'feedback': {'message': '3'}, } elt = etree.fromstring(self.xml) - the_input = self.input_class(test_capa_system(), elt, state) + the_input = self.input_class(mock_capa_system(), elt, state) inner_msg = 'hello!' queue_msg = json.dumps({'msg': inner_msg}) @@ -702,7 +702,7 @@ class MatlabTest(unittest.TestCase): state = {'input_state': {'queuestate': 'queued', 'queuetime': 5}} elt = etree.fromstring(self.xml) - the_input = self.input_class(test_capa_system(), elt, state) + the_input = self.input_class(mock_capa_system(), elt, state) assert the_input.status == 'queued' @patch('xmodule.capa.inputtypes.time.time', return_value=45) @@ -711,7 +711,7 @@ class MatlabTest(unittest.TestCase): state = {'input_state': {'queuestate': 'queued', 'queuetime': 5}} elt = etree.fromstring(self.xml) - the_input = self.input_class(test_capa_system(), elt, state) + the_input = self.input_class(mock_capa_system(), elt, state) assert the_input.status == 'unsubmitted' assert the_input.msg == 'No response from Xqueue within {} seconds. Aborted.'.format(XQUEUE_TIMEOUT) @@ -723,7 +723,7 @@ class MatlabTest(unittest.TestCase): state = {'input_state': {'queuestate': 'queued'}} elt = etree.fromstring(self.xml) - the_input = self.input_class(test_capa_system(), elt, state) + the_input = self.input_class(mock_capa_system(), elt, state) assert the_input.status == 'unsubmitted' def test_matlab_api_key(self): @@ -731,7 +731,7 @@ class MatlabTest(unittest.TestCase): Test that api_key ends up in the xqueue payload """ elt = etree.fromstring(self.xml) - system = test_capa_system() + system = mock_capa_system() system.matlab_api_key = 'test_api_key' the_input = lookup_tag('matlabinput')(system, elt, {}) @@ -852,7 +852,7 @@ class MatlabTest(unittest.TestCase): } elt = etree.fromstring(self.xml) - the_input = self.input_class(test_capa_system(), elt, state) + the_input = self.input_class(mock_capa_system(), elt, state) context = the_input._get_render_context() # pylint: disable=protected-access self.maxDiff = None expected = fromstring('\n
    \n') # lint-amnesty, pylint: disable=line-too-long @@ -901,7 +901,7 @@ class MatlabTest(unittest.TestCase): 'status': 'queued', } elt = etree.fromstring(self.xml) - the_input = self.input_class(test_capa_system(), elt, state) + the_input = self.input_class(mock_capa_system(), elt, state) assert the_input.queue_msg == queue_msg def test_matlab_queue_message_not_allowed_tag(self): @@ -915,7 +915,7 @@ class MatlabTest(unittest.TestCase): 'status': 'queued', } elt = etree.fromstring(self.xml) - the_input = self.input_class(test_capa_system(), elt, state) + the_input = self.input_class(mock_capa_system(), elt, state) expected = "" assert the_input.queue_msg == expected @@ -974,7 +974,7 @@ class SchematicTest(unittest.TestCase): 'response_data': RESPONSE_DATA } - the_input = lookup_tag('schematic')(test_capa_system(), element, state) + the_input = lookup_tag('schematic')(mock_capa_system(), element, state) context = the_input._get_render_context() # pylint: disable=protected-access prob_id = 'prob_1_2' @@ -1021,7 +1021,7 @@ class ImageInputTest(unittest.TestCase): 'response_data': RESPONSE_DATA } - the_input = lookup_tag('imageinput')(test_capa_system(), element, state) + the_input = lookup_tag('imageinput')(mock_capa_system(), element, state) context = the_input._get_render_context() # pylint: disable=protected-access prob_id = 'prob_1_2' @@ -1079,7 +1079,7 @@ class CrystallographyTest(unittest.TestCase): 'response_data': RESPONSE_DATA } - the_input = lookup_tag('crystallography')(test_capa_system(), element, state) + the_input = lookup_tag('crystallography')(mock_capa_system(), element, state) context = the_input._get_render_context() # pylint: disable=protected-access prob_id = 'prob_1_2' @@ -1124,7 +1124,7 @@ class VseprTest(unittest.TestCase): 'response_data': RESPONSE_DATA } - the_input = lookup_tag('vsepr_input')(test_capa_system(), element, state) + the_input = lookup_tag('vsepr_input')(mock_capa_system(), element, state) context = the_input._get_render_context() # pylint: disable=protected-access prob_id = 'prob_1_2' @@ -1160,7 +1160,7 @@ class ChemicalEquationTest(unittest.TestCase): 'value': 'H2OYeah', 'response_data': RESPONSE_DATA } - self.the_input = lookup_tag('chemicalequationinput')(test_capa_system(), element, state) + self.the_input = lookup_tag('chemicalequationinput')(mock_capa_system(), element, state) def test_rendering(self): """ @@ -1255,7 +1255,7 @@ class FormulaEquationTest(unittest.TestCase): 'value': 'x^2+1/2', 'response_data': RESPONSE_DATA } - self.the_input = lookup_tag('formulaequationinput')(test_capa_system(), element, state) + self.the_input = lookup_tag('formulaequationinput')(mock_capa_system(), element, state) def test_rendering(self): """ @@ -1305,7 +1305,7 @@ class FormulaEquationTest(unittest.TestCase): 'value': 'x^2+1/2', 'response_data': RESPONSE_DATA } - the_input = lookup_tag('formulaequationinput')(test_capa_system(), element, state) + the_input = lookup_tag('formulaequationinput')(mock_capa_system(), element, state) context = the_input._get_render_context() # pylint: disable=protected-access prob_id = 'prob_1_2' @@ -1440,7 +1440,7 @@ class DragAndDropTest(unittest.TestCase): ] } - the_input = lookup_tag('drag_and_drop_input')(test_capa_system(), element, state) + the_input = lookup_tag('drag_and_drop_input')(mock_capa_system(), element, state) prob_id = 'prob_1_2' context = the_input._get_render_context() # pylint: disable=protected-access expected = { @@ -1494,7 +1494,7 @@ class AnnotationInputTest(unittest.TestCase): tag = 'annotationinput' - the_input = lookup_tag(tag)(test_capa_system(), element, state) + the_input = lookup_tag(tag)(mock_capa_system(), element, state) context = the_input._get_render_context() # pylint: disable=protected-access prob_id = 'annotation_input' @@ -1588,7 +1588,7 @@ class TestChoiceText(unittest.TestCase): 'describedby_html': DESCRIBEDBY.format(status_id=prob_id) } expected.update(state) - the_input = lookup_tag(tag)(test_capa_system(), element, state) + the_input = lookup_tag(tag)(mock_capa_system(), element, state) context = the_input._get_render_context() # pylint: disable=protected-access assert context == expected diff --git a/xmodule/capa/tests/test_responsetypes.py b/xmodule/capa/tests/test_responsetypes.py index e8df8894c7..ca9f5eba59 100644 --- a/xmodule/capa/tests/test_responsetypes.py +++ b/xmodule/capa/tests/test_responsetypes.py @@ -20,7 +20,7 @@ from pytz import UTC from xmodule.capa.correctmap import CorrectMap from xmodule.capa.responsetypes import LoncapaProblemError, ResponseError, StudentInputError -from xmodule.capa.tests.helpers import load_fixture, new_loncapa_problem, test_capa_system +from xmodule.capa.tests.helpers import load_fixture, new_loncapa_problem, mock_capa_system from xmodule.capa.tests.response_xml_factory import ( AnnotationResponseXMLFactory, ChoiceResponseXMLFactory, @@ -37,6 +37,7 @@ from xmodule.capa.tests.response_xml_factory import ( SymbolicResponseXMLFactory, TrueFalseResponseXMLFactory ) +from xmodule.capa.tests.test_util import use_unsafe_codejail from xmodule.capa.util import convert_files_to_filenames from xmodule.capa.xqueue_interface import dateformat @@ -108,6 +109,7 @@ class ResponseTest(unittest.TestCase): return str(rand.randint(0, 1e9)) +@use_unsafe_codejail() class MultiChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = MultipleChoiceResponseXMLFactory @@ -375,6 +377,7 @@ class SymbolicResponseTest(ResponseTest): # pylint: disable=missing-class-docst assert correct_map.get_correctness('1_2_1') == expected_correctness +@use_unsafe_codejail() class OptionResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = OptionResponseXMLFactory @@ -422,6 +425,7 @@ class OptionResponseTest(ResponseTest): # pylint: disable=missing-class-docstri assert correct_map.get_property('1_2_1', 'answervariable') == '$a' +@use_unsafe_codejail() class FormulaResponseTest(ResponseTest): """ Test the FormulaResponse class @@ -571,6 +575,7 @@ class FormulaResponseTest(ResponseTest): assert not list(problem.responders.values())[0].validate_answer('3*y+2*x') +@use_unsafe_codejail() class StringResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = StringResponseXMLFactory @@ -1124,6 +1129,7 @@ class CodeResponseTest(ResponseTest): # pylint: disable=missing-class-docstring assert output[answer_id]['msg'] == 'Invalid grader reply. Please contact the course staff.' +@use_unsafe_codejail() class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = ChoiceResponseXMLFactory @@ -1292,6 +1298,7 @@ class ChoiceResponseTest(ResponseTest): # pylint: disable=missing-class-docstri self.assert_grade(problem, ['choice_1', 'choice_3'], 'incorrect') +@use_unsafe_codejail() class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = NumericalResponseXMLFactory @@ -1680,6 +1687,7 @@ class NumericalResponseTest(ResponseTest): # pylint: disable=missing-class-docs assert not responder.validate_answer('fish') +@use_unsafe_codejail() class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstring xml_factory_class = CustomResponseXMLFactory @@ -2326,7 +2334,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri import my_helper num = my_helper.seventeen() """) - capa_system = test_capa_system() + capa_system = mock_capa_system() capa_system.get_python_lib_zip = lambda: zipstring.getvalue() # lint-amnesty, pylint: disable=unnecessary-lambda problem = self.build_problem(script=script, capa_system=capa_system) assert problem.context['num'] == 17 @@ -2399,6 +2407,7 @@ class CustomResponseTest(ResponseTest): # pylint: disable=missing-class-docstri assert correct_map.get_msg('1_2_11') == '11' +@use_unsafe_codejail() class SchematicResponseTest(ResponseTest): """ Class containing setup and tests for Schematic responsetype. @@ -2488,6 +2497,7 @@ class AnnotationResponseTest(ResponseTest): # lint-amnesty, pylint: disable=mis assert expected_points == actual_points, ('%s should have %d points' % (answer_id, expected_points)) +@use_unsafe_codejail() class ChoiceTextResponseTest(ResponseTest): """ Class containing setup and tests for ChoiceText responsetype. diff --git a/xmodule/capa/tests/test_shuffle.py b/xmodule/capa/tests/test_shuffle.py index cc0242826a..d7ce39f008 100644 --- a/xmodule/capa/tests/test_shuffle.py +++ b/xmodule/capa/tests/test_shuffle.py @@ -5,7 +5,7 @@ import textwrap import unittest from xmodule.capa.responsetypes import LoncapaProblemError -from xmodule.capa.tests.helpers import new_loncapa_problem, test_capa_system +from xmodule.capa.tests.helpers import new_loncapa_problem, mock_capa_system class CapaShuffleTest(unittest.TestCase): @@ -13,7 +13,7 @@ class CapaShuffleTest(unittest.TestCase): def setUp(self): super(CapaShuffleTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments - self.system = test_capa_system() + self.system = mock_capa_system() def test_shuffle_4_choices(self): xml_str = textwrap.dedent(""" diff --git a/xmodule/capa/tests/test_targeted_feedback.py b/xmodule/capa/tests/test_targeted_feedback.py index cae4763123..0ec13b4962 100644 --- a/xmodule/capa/tests/test_targeted_feedback.py +++ b/xmodule/capa/tests/test_targeted_feedback.py @@ -6,7 +6,7 @@ i.e. those with the element import textwrap import unittest -from xmodule.capa.tests.helpers import load_fixture, new_loncapa_problem, test_capa_system +from xmodule.capa.tests.helpers import load_fixture, new_loncapa_problem, mock_capa_system class CapaTargetedFeedbackTest(unittest.TestCase): @@ -16,7 +16,7 @@ class CapaTargetedFeedbackTest(unittest.TestCase): def setUp(self): super(CapaTargetedFeedbackTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments - self.system = test_capa_system() + self.system = mock_capa_system() def test_no_targeted_feedback(self): xml_str = textwrap.dedent(""" diff --git a/xmodule/capa/tests/test_util.py b/xmodule/capa/tests/test_util.py index 51ffc623a9..3176ff9b9a 100644 --- a/xmodule/capa/tests/test_util.py +++ b/xmodule/capa/tests/test_util.py @@ -6,10 +6,12 @@ Tests capa util import unittest +import codejail.safe_exec import ddt +from django.test.utils import TestContextDecorator from lxml import etree -from xmodule.capa.tests.helpers import test_capa_system +from xmodule.capa.tests.helpers import mock_capa_system from xmodule.capa.util import ( compare_with_tolerance, contextualize_text, @@ -25,7 +27,7 @@ class UtilTest(unittest.TestCase): def setUp(self): super(UtilTest, self).setUp() # lint-amnesty, pylint: disable=super-with-arguments - self.system = test_capa_system() + self.system = mock_capa_system() def test_compare_with_tolerance(self): # lint-amnesty, pylint: disable=too-many-statements # Test default tolerance '0.001%' (it is relative) @@ -145,7 +147,7 @@ class UtilTest(unittest.TestCase): Test for markup removal with nh3. """ assert remove_markup('The Truth is Out There & you need to find it') ==\ - 'The Truth is Out There & you need to find it' + 'The Truth is Out There & you need to find it' @ddt.data( 'When the root level failš the whole hierarchy won’t work anymore.', @@ -167,3 +169,28 @@ class UtilTest(unittest.TestCase): expected_text = '$あなたあなたあなたあなた あなたhi' contextual_text = contextualize_text(text, context) assert expected_text == contextual_text + + +class use_unsafe_codejail(TestContextDecorator): + """ + Tell codejail to run in unsafe mode for the scope of the decorator. + Use this as a decorator on Django TestCase classes or methods. + + This is needed because codejail has significant OS-level setup requirements + which we don't even attempt to fulfill for unit testing purposes. Running + tests in unsafe mode (that is, running code executions in-process, with no + sandboxing) is only safe because we control the contents of the unit tests. + It's not a perfect replica of how safe mode operates but it's generally good + enough for testing the integration and overall behavior. + """ + + def __init__(self): + self.old_be_unsafe = None + super().__init__() + + def enable(self): + self.old_be_unsafe = codejail.safe_exec.ALWAYS_BE_UNSAFE + codejail.safe_exec.ALWAYS_BE_UNSAFE = True + + def disable(self): + codejail.safe_exec.ALWAYS_BE_UNSAFE = self.old_be_unsafe diff --git a/xmodule/capa/tests/test_xqueue_interface.py b/xmodule/capa/tests/test_xqueue_interface.py index 819fd73c79..db06fbfcb3 100644 --- a/xmodule/capa/tests/test_xqueue_interface.py +++ b/xmodule/capa/tests/test_xqueue_interface.py @@ -1,37 +1,63 @@ """Test the XQueue service and interface.""" from unittest import TestCase -from unittest.mock import Mock +from unittest.mock import Mock, patch from django.conf import settings from django.test.utils import override_settings from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator from xblock.fields import ScopeIds +import pytest +import json from openedx.core.djangolib.testing.utils import skip_unless_lms from xmodule.capa.xqueue_interface import XQueueInterface, XQueueService +@pytest.mark.django_db @skip_unless_lms class XQueueServiceTest(TestCase): """Test the XQueue service methods.""" + def setUp(self): super().setUp() - location = BlockUsageLocator(CourseLocator("test_org", "test_course", "test_run"), "problem", "ExampleProblem") - self.block = Mock(scope_ids=ScopeIds('user1', 'mock_problem', location, location)) + location = BlockUsageLocator( + CourseLocator("test_org", "test_course", "test_run"), + "problem", + "ExampleProblem", + ) + self.block = Mock(scope_ids=ScopeIds("user1", "mock_problem", location, location)) + self.block.max_score = Mock(return_value=10) # Mock max_score method self.service = XQueueService(self.block) def test_interface(self): """Test that the `XQUEUE_INTERFACE` settings are passed from the service to the interface.""" assert isinstance(self.service.interface, XQueueInterface) - assert self.service.interface.url == 'http://sandbox-xqueue.edx.org' - assert self.service.interface.auth['username'] == 'lms' - assert self.service.interface.auth['password'] == '***REMOVED***' - assert self.service.interface.session.auth.username == 'anant' - assert self.service.interface.session.auth.password == 'agarwal' + assert self.service.interface.url == "http://sandbox-xqueue.edx.org" + assert self.service.interface.auth["username"] == "lms" + assert self.service.interface.auth["password"] == "***REMOVED***" + assert self.service.interface.session.auth.username == "anant" + assert self.service.interface.session.auth.password == "agarwal" - def test_construct_callback(self): - """Test that the XQueue callback is initialized correctly, and can be altered through the settings.""" + @patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=True) + def test_construct_callback_with_flag_enabled(self, mock_flag): + """Test construct_callback when the waffle flag is enabled.""" + usage_id = self.block.scope_ids.usage_id + course_id = str(usage_id.course_key) + callback_url = f"courses/{course_id}/xqueue/user1/{usage_id}" + + assert self.service.construct_callback() == f"{settings.LMS_ROOT_URL}/{callback_url}/score_update" + assert self.service.construct_callback("alt_dispatch") == ( + f"{settings.LMS_ROOT_URL}/{callback_url}/alt_dispatch" + ) + + custom_callback_url = "http://alt.url" + with override_settings(XQUEUE_INTERFACE={**settings.XQUEUE_INTERFACE, "callback_url": custom_callback_url}): + assert self.service.construct_callback() == f"{custom_callback_url}/{callback_url}/score_update" + + @patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=False) + def test_construct_callback_with_flag_disabled(self, mock_flag): + """Test construct_callback when the waffle flag is disabled.""" usage_id = self.block.scope_ids.usage_id callback_url = f'courses/{usage_id.context_key}/xqueue/user1/{usage_id}' @@ -44,7 +70,7 @@ class XQueueServiceTest(TestCase): def test_default_queuename(self): """Check the format of the default queue name.""" - assert self.service.default_queuename == 'test_org-test_course' + assert self.service.default_queuename == "test_org-test_course" def test_waittime(self): """Check that the time between requests is retrieved correctly from the settings.""" @@ -52,3 +78,63 @@ class XQueueServiceTest(TestCase): with override_settings(XQUEUE_WAITTIME_BETWEEN_REQUESTS=15): assert self.service.waittime == 15 + + +@pytest.mark.django_db +@patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=True) +@patch("xmodule.capa.xqueue_submission.XQueueInterfaceSubmission.send_to_submission") +def test_send_to_queue_with_flag_enabled(mock_send_to_submission, mock_flag): + """Test send_to_queue when the waffle flag is enabled.""" + url = "http://example.com/xqueue" + django_auth = {"username": "user", "password": "pass"} + block = Mock() # Mock block for the constructor + xqueue_interface = XQueueInterface(url, django_auth, block=block) + + header = json.dumps({ + "lms_callback_url": ( + "http://example.com/courses/course-v1:test_org+test_course+test_run/" + "xqueue/block@item_id/type@problem" + ), + }) + body = json.dumps({ + "student_info": json.dumps({"anonymous_student_id": "student_id"}), + "student_response": "student_answer", + }) + files_to_upload = None + + mock_send_to_submission.return_value = {"submission": "mock_submission"} + error, msg = xqueue_interface.send_to_queue(header, body, files_to_upload) + + mock_send_to_submission.assert_called_once_with(header, body, {}) + + +@pytest.mark.django_db +@patch("xmodule.capa.xqueue_interface.use_edx_submissions_for_xqueue", return_value=False) +@patch("xmodule.capa.xqueue_interface.XQueueInterface._http_post") +def test_send_to_queue_with_flag_disabled(mock_http_post, mock_flag): + """Test send_to_queue when the waffle flag is disabled.""" + url = "http://example.com/xqueue" + django_auth = {"username": "user", "password": "pass"} + block = Mock() # Mock block for the constructor + xqueue_interface = XQueueInterface(url, django_auth, block=block) + + header = json.dumps({ + "lms_callback_url": ( + "http://example.com/courses/course-v1:test_org+test_course+test_run/" + "xqueue/block@item_id/type@problem" + ), + }) + body = json.dumps({ + "student_info": json.dumps({"anonymous_student_id": "student_id"}), + "student_response": "student_answer", + }) + files_to_upload = None + + mock_http_post.return_value = (0, "Submission sent successfully") + error, msg = xqueue_interface.send_to_queue(header, body, files_to_upload) + + mock_http_post.assert_called_once_with( + "http://example.com/xqueue/xqueue/submit/", + {"xqueue_header": header, "xqueue_body": body}, + files={}, + ) diff --git a/xmodule/capa/tests/test_xqueue_submission.py b/xmodule/capa/tests/test_xqueue_submission.py new file mode 100644 index 0000000000..704c6249d4 --- /dev/null +++ b/xmodule/capa/tests/test_xqueue_submission.py @@ -0,0 +1,117 @@ +""" +Unit tests for the XQueueInterfaceSubmission class. +""" +import json +import pytest +from unittest.mock import Mock, patch +from xmodule.capa.xqueue_submission import XQueueInterfaceSubmission +from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator +from xblock.fields import ScopeIds + + +@pytest.fixture +def xqueue_service(): + """ + Fixture that returns an instance of XQueueInterfaceSubmission. + """ + location = BlockUsageLocator( + CourseLocator("test_org", "test_course", "test_run"), + "problem", + "ExampleProblem" + ) + block = Mock(scope_ids=ScopeIds('user1', 'problem', location, location)) + block.max_score = Mock(return_value=10) + return XQueueInterfaceSubmission(block) + + +def test_get_submission_params(xqueue_service): + """ + Test extracting item data from an xqueue submission. + """ + header = json.dumps({ + 'lms_callback_url': 'http://example.com/callback', + 'queue_name': 'default' + }) + payload = json.dumps({ + 'student_info': json.dumps({'anonymous_student_id': 'student_id'}), + 'student_response': 'student_answer', + 'grader_payload': json.dumps({'grader': 'test.py'}) + }) + + student_item, student_answer, queue_name, grader_file_name, points_possible = ( + xqueue_service.get_submission_params(header, payload) + ) + + assert student_item == { + 'item_id': 'block-v1:test_org+test_course+test_run+type@problem+block@ExampleProblem', + 'item_type': 'problem', + 'course_id': 'course-v1:test_org+test_course+test_run', + 'student_id': 'student_id' + } + assert student_answer == 'student_answer' + assert queue_name == 'default' + assert grader_file_name == 'test.py' + assert points_possible == 10 + + +@pytest.mark.django_db +@patch('submissions.api.create_external_grader_detail') +def test_send_to_submission(mock_create_external_grader_detail, xqueue_service): + """ + Test sending a submission to the grading system. + """ + header = json.dumps({ + 'lms_callback_url': ( + 'http://example.com/courses/course-v1:test_org+test_course+test_run/xqueue/5/' + 'block-v1:test_org+test_course+test_run+type@problem+block@ExampleProblem/' + ), + }) + body = json.dumps({ + 'student_info': json.dumps({'anonymous_student_id': 'student_id'}), + 'student_response': 'student_answer', + 'grader_payload': json.dumps({'grader': 'test.py'}) + }) + + mock_response = {"submission": "mock_submission"} + mock_create_external_grader_detail.return_value = mock_response + + result = xqueue_service.send_to_submission(header, body) + + assert result == mock_response + mock_create_external_grader_detail.assert_called_once_with( + { + 'item_id': 'block-v1:test_org+test_course+test_run+type@problem+block@ExampleProblem', + 'item_type': 'problem', + 'course_id': 'course-v1:test_org+test_course+test_run', + 'student_id': 'student_id' + }, + 'student_answer', + queue_name='default', + grader_file_name='test.py', + points_possible=10, + files=None + ) + + +@pytest.mark.django_db +@patch('submissions.api.create_external_grader_detail') +def test_send_to_submission_with_missing_fields(mock_create_external_grader_detail, xqueue_service): + """ + Test send_to_submission with missing required fields. + """ + header = json.dumps({ + 'lms_callback_url': ( + 'http://example.com/courses/course-v1:test_org+test_course+test_run/xqueue/5/' + 'block@item_id/' + ) + }) + body = json.dumps({ + 'student_info': json.dumps({'anonymous_student_id': 'student_id'}), + 'grader_payload': json.dumps({'grader': 'test.py'}) + }) + + result = xqueue_service.send_to_submission(header, body) + + assert "error" in result + assert "Validation error" in result["error"] + mock_create_external_grader_detail.assert_not_called() diff --git a/xmodule/capa/xqueue_interface.py b/xmodule/capa/xqueue_interface.py index aee7232a41..66b2dc7384 100644 --- a/xmodule/capa/xqueue_interface.py +++ b/xmodule/capa/xqueue_interface.py @@ -11,6 +11,9 @@ import requests from django.conf import settings from django.urls import reverse from requests.auth import HTTPBasicAuth +from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag +from opaque_keys.edx.keys import CourseKey +from xmodule.capa.xqueue_submission import XQueueInterfaceSubmission if TYPE_CHECKING: from xmodule.capa_block import ProblemBlock @@ -25,6 +28,34 @@ XQUEUE_TIMEOUT = 35 # seconds CONNECT_TIMEOUT = 3.05 # seconds READ_TIMEOUT = 10 # seconds +# .. toggle_name: send_to_submission_course.enable +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_description: Enables use of the submissions service instead of legacy xqueue for course problem submissions. +# .. toggle_default: False +# .. toggle_use_cases: opt_in +# .. toggle_creation_date: 2024-04-03 +# .. toggle_expiration_date: 2025-08-12 +# .. toggle_will_remain_in_codebase: True +# .. toggle_tickets: none +# .. toggle_status: supported +SEND_TO_SUBMISSION_COURSE_FLAG = CourseWaffleFlag('send_to_submission_course.enable', __name__) + + +def use_edx_submissions_for_xqueue(course_key: CourseKey | None = None) -> bool: + """ + Determines whether edx-submissions should be used instead of legacy XQueue. + + This helper abstracts the toggle logic so that the rest of the codebase is not tied + to specific feature flag mechanics or rollout strategies. + + Args: + course_key (CourseKey | None): Optional course key. If None, fallback to site-level toggle. + + Returns: + bool: True if edx-submissions should be used, False otherwise. + """ + return SEND_TO_SUBMISSION_COURSE_FLAG.is_enabled(course_key) + def make_hashkey(seed): """ @@ -72,15 +103,28 @@ def parse_xreply(xreply): class XQueueInterface: - """ - Interface to the external grading system - """ + """Initializes the XQueue interface.""" - def __init__(self, url: str, django_auth: Dict[str, str], requests_auth: Optional[HTTPBasicAuth] = None): + def __init__(self, url: str, django_auth: Dict[str, str], + requests_auth: Optional[HTTPBasicAuth] = None, + block: 'ProblemBlock' = None): + """ + Initializes the XQueue interface. + + Args: + url (str): The URL of the XQueue service. + django_auth (Dict[str, str]): Authentication credentials for Django. + requests_auth (Optional[HTTPBasicAuth], optional): Authentication for HTTP requests. Defaults to None. + block ('ProblemBlock', optional): Added as a parameter only to extract the course_id + to check the course waffle flag `send_to_submission_course.enable`. + This can be removed after the legacy xqueue is deprecated. Defaults to None. + """ self.url = url self.auth = django_auth self.session = requests.Session() self.session.auth = requests_auth + self.block = block + self.submission = XQueueInterfaceSubmission(self.block) def send_to_queue(self, header, body, files_to_upload=None): """ @@ -135,6 +179,20 @@ class XQueueInterface: for f in files_to_upload: files.update({f.name: f}) + if self.block is None: + # XQueueInterface: if self.block is None, falling back to legacy xqueue submission. + log.error( + "Unexpected None block: falling back to legacy xqueue submission. " + "This may indicate a problem with the xqueue transition." + ) + return self._http_post(self.url + '/xqueue/submit/', payload, files=files) + + course_key = self.block.scope_ids.usage_id.context_key + + if use_edx_submissions_for_xqueue(course_key): + submission = self.submission.send_to_submission(header, body, files) + return None, '' + return self._http_post(self.url + '/xqueue/submit/', payload, files=files) def _http_post(self, url, data, files=None): # lint-amnesty, pylint: disable=missing-function-docstring @@ -168,7 +226,8 @@ class XQueueService: basic_auth = settings.XQUEUE_INTERFACE.get('basic_auth') requests_auth = HTTPBasicAuth(*basic_auth) if basic_auth else None self._interface = XQueueInterface( - settings.XQUEUE_INTERFACE['url'], settings.XQUEUE_INTERFACE['django_auth'], requests_auth + settings.XQUEUE_INTERFACE['url'], settings.XQUEUE_INTERFACE['django_auth'], requests_auth, + block=block ) self._block = block @@ -180,21 +239,28 @@ class XQueueService: """ return self._interface - def construct_callback(self, dispatch: str = 'score_update') -> str: + def construct_callback(self, dispatch: str = "score_update") -> str: """ - Return a fully qualified callback URL for external queueing system. + Return a fully qualified callback URL for the external queueing system. """ + course_key = self._block.scope_ids.usage_id.context_key + userid = str(self._block.scope_ids.user_id) + mod_id = str(self._block.scope_ids.usage_id) + + callback_type = "xqueue_callback" + relative_xqueue_callback_url = reverse( - 'xqueue_callback', - kwargs=dict( - course_id=str(self._block.scope_ids.usage_id.context_key), - userid=str(self._block.scope_ids.user_id), - mod_id=str(self._block.scope_ids.usage_id), - dispatch=dispatch, - ), + callback_type, + kwargs={ + "course_id": str(course_key), + "userid": userid, + "mod_id": mod_id, + "dispatch": dispatch, + }, ) - xqueue_callback_url_prefix = settings.XQUEUE_INTERFACE.get('callback_url', settings.LMS_ROOT_URL) - return xqueue_callback_url_prefix + relative_xqueue_callback_url + + xqueue_callback_url_prefix = settings.XQUEUE_INTERFACE.get("callback_url", settings.LMS_ROOT_URL) + return f"{xqueue_callback_url_prefix}{relative_xqueue_callback_url}" @property def default_queuename(self) -> str: diff --git a/xmodule/capa/xqueue_submission.py b/xmodule/capa/xqueue_submission.py new file mode 100644 index 0000000000..3d7f061ffc --- /dev/null +++ b/xmodule/capa/xqueue_submission.py @@ -0,0 +1,113 @@ +""" +This module provides an interface for submitting student responses +to an external grading system through XQueue. +""" + +import json +import logging +from xmodule.capa.errors import ( + GetSubmissionParamsError, + JSONParsingError, + MissingKeyError, + ValidationError, + TypeErrorSubmission, + RuntimeErrorSubmission +) + +log = logging.getLogger(__name__) + + +class XQueueInterfaceSubmission: + """Interface to the external grading system.""" + + def __init__(self, block): + self.block = block + + def _parse_json(self, data, name): + """ + Helper function to safely parse data that may or may not be a JSON string. + This is necessary because some callers may already provide parsed Python dicts + (e.g., during internal calls or test cases), while other sources may send raw JSON strings. + This helper ensures consistent behavior regardless of input format. + Args: + data: The input to parse, either a JSON string or a Python dict. + name: Name of the field (used for error reporting). + Returns: + Parsed Python object or original data if already parsed. + Raises: + JSONParsingError: If `data` is a string and cannot be parsed as JSON. + """ + try: + return json.loads(data) if isinstance(data, str) else data + except json.JSONDecodeError as e: + raise JSONParsingError(name, str(e)) from e + + def get_submission_params(self, header, payload): + """ + Extracts student submission data from the given header and payload. + """ + header = self._parse_json(header, "header") + payload = self._parse_json(payload, "payload") + + queue_name = header.get('queue_name', 'default') + + if not self.block: + raise GetSubmissionParamsError() + + course_id = str(self.block.scope_ids.usage_id.context_key) + item_type = self.block.scope_ids.block_type + points_possible = self.block.max_score() + + item_id = str(self.block.scope_ids.usage_id) + + try: + grader_payload = self._parse_json(payload["grader_payload"], "grader_payload") + grader_file_name = grader_payload.get("grader", '') + except KeyError as e: + raise MissingKeyError("grader_payload") from e + + student_info = self._parse_json(payload["student_info"], "student_info") + student_id = student_info.get("anonymous_student_id") + + if not student_id: + raise ValidationError("The field 'anonymous_student_id' is missing from student_info.") + + student_answer = payload.get("student_response") + if student_answer is None: + raise ValidationError("The field 'student_response' does not exist.") + + student_dict = { + 'item_id': item_id, + 'item_type': item_type, + 'course_id': course_id, + 'student_id': student_id + } + + return student_dict, student_answer, queue_name, grader_file_name, points_possible + + def send_to_submission(self, header, body, files_to_upload=None): + """ + Submits the extracted student data to the edx-submissions system. + """ + try: + from submissions.api import create_external_grader_detail + student_item, answer, queue_name, grader_file_name, points_possible = ( + self.get_submission_params(header, body) + ) + return create_external_grader_detail( + student_item, + answer, + queue_name=queue_name, + grader_file_name=grader_file_name, + points_possible=points_possible, + files=files_to_upload + ) + except (JSONParsingError, MissingKeyError, ValidationError) as e: + log.error("%s", e) + return {"error": str(e)} + except TypeError as e: + log.error("%s", e) + raise TypeErrorSubmission(str(e)) from e + except RuntimeError as e: + log.error("%s", e) + raise RuntimeErrorSubmission(str(e)) from e diff --git a/xmodule/capa_block.py b/xmodule/capa_block.py index 24737b6898..1a096e76b2 100644 --- a/xmodule/capa_block.py +++ b/xmodule/capa_block.py @@ -25,7 +25,14 @@ from web_fragments.fragment import Fragment from xblock.core import XBlock from xblock.fields import Boolean, Dict, Float, Integer, Scope, String, XMLString, List from xblock.scorable import ScorableXBlockMixin, Score +from xblocks_contrib.problem import ProblemBlock as _ExtractedProblemBlock +from common.djangoapps.xblock_django.constants import ( + ATTR_KEY_DEPRECATED_ANONYMOUS_USER_ID, + ATTR_KEY_USER_IS_STAFF, + ATTR_KEY_USER_ID, +) +from openedx.core.djangolib.markup import HTML, Text from xmodule.capa import responsetypes from xmodule.capa.capa_problem import LoncapaProblem, LoncapaSystem from xmodule.capa.inputtypes import Status @@ -36,8 +43,8 @@ from xmodule.editing_block import EditingMixin from xmodule.exceptions import NotFoundError, ProcessingError from xmodule.graders import ShowCorrectness from xmodule.raw_block import RawMixin -from xmodule.util.sandboxing import SandboxService from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_css_to_fragment +from xmodule.util.sandboxing import SandboxService from xmodule.x_module import ( ResourceTemplates, XModuleMixin, @@ -45,20 +52,12 @@ from xmodule.x_module import ( shim_xmodule_js ) from xmodule.xml_block import XmlMixin -from common.djangoapps.xblock_django.constants import ( - ATTR_KEY_DEPRECATED_ANONYMOUS_USER_ID, - ATTR_KEY_USER_IS_STAFF, - ATTR_KEY_USER_ID, -) -from openedx.core.djangolib.markup import HTML, Text from .capa.xqueue_interface import XQueueService - from .fields import Date, ListScoreField, ScoreField, Timedelta from .progress import Progress log = logging.getLogger("edx.courseware") - # Make '_' a no-op so we can scrape strings. Using lambda instead of # `django.utils.translation.gettext_noop` because Django cannot be imported in this file _ = lambda text: text @@ -134,7 +133,7 @@ class Randomization(String): @XBlock.needs('sandbox') @XBlock.needs('replace_urls') @XBlock.wants('call_to_action') -class ProblemBlock( +class _BuiltInProblemBlock( ScorableXBlockMixin, RawMixin, XmlMixin, @@ -161,6 +160,8 @@ class ProblemBlock( """ INDEX_CONTENT_TYPE = 'CAPA' + is_extracted = False + resources_dir = None has_score = True @@ -339,6 +340,11 @@ class ProblemBlock( "or to report an issue, please contact moocsupport@mathworks.com"), scope=Scope.settings ) + markdown_edited = Boolean( + help=_("Indicates if the problem was edited using the Markdown editor in the Authoring MFE."), + scope=Scope.settings, + default=False + ) def bind_for_student(self, *args, **kwargs): # lint-amnesty, pylint: disable=signature-differs super().bind_for_student(*args, **kwargs) @@ -720,7 +726,7 @@ class ProblemBlock( # For the purposes of this report, we don't need to support those use cases. anonymous_student_id=None, cache=None, - can_execute_unsafe_code=lambda: None, + can_execute_unsafe_code=lambda: False, get_python_lib_zip=( lambda: SandboxService(contentstore, self.scope_ids.usage_id.context_key).get_python_lib_zip() ), @@ -2509,3 +2515,10 @@ def randomization_bin(seed, problem_id): r_hash.update(str(problem_id).encode()) # get the first few digits of the hash, convert to an int, then mod. return int(r_hash.hexdigest()[:7], 16) % NUM_RANDOMIZATION_BINS + + +ProblemBlock = ( + _ExtractedProblemBlock if settings.USE_EXTRACTED_PROBLEM_BLOCK + else _BuiltInProblemBlock +) +ProblemBlock.__name__ = "ProblemBlock" diff --git a/xmodule/contentstore/mongo.py b/xmodule/contentstore/mongo.py index e44f03cede..3e36c55091 100644 --- a/xmodule/contentstore/mongo.py +++ b/xmodule/contentstore/mongo.py @@ -12,7 +12,6 @@ import pymongo from bson.son import SON from fs.osfs import OSFS from gridfs.errors import NoFile, FileExists -from mongodb_proxy import autoretry_read from opaque_keys.edx.keys import AssetKey from xmodule.contentstore.content import XASSET_LOCATION_TAG @@ -29,6 +28,7 @@ class MongoContentStore(ContentStore): MongoDB-backed ContentStore. """ # lint-amnesty, pylint: disable=unused-argument + def __init__( self, host, db, port=27017, tz_aware=True, user=None, password=None, bucket='fs', collection=None, **kwargs @@ -39,8 +39,6 @@ class MongoContentStore(ContentStore): :param collection: ignores but provided for consistency w/ other doc_store_config patterns """ # GridFS will throw an exception if the Database is wrapped in a MongoProxy. So don't wrap it. - # The appropriate methods below are marked as autoretry_read - those methods will handle - # the AutoReconnect errors. self.connection_params = { 'db': db, 'host': host, @@ -48,7 +46,6 @@ class MongoContentStore(ContentStore): 'tz_aware': tz_aware, 'user': user, 'password': password, - 'proxy': False, **kwargs } self.bucket = bucket @@ -164,7 +161,6 @@ class MongoContentStore(ContentStore): # Deletes of non-existent files are considered successful self.fs.delete(location_or_id) - @autoretry_read() def find(self, location, throw_on_not_found=True, as_stream=False): # lint-amnesty, pylint: disable=arguments-differ content_id, __ = self.asset_db_key(location) @@ -292,7 +288,6 @@ class MongoContentStore(ContentStore): self.fs_files.remove(query) return assets_to_delete - @autoretry_read() def _get_all_content_for_course(self, course_key, get_thumbnails=False, @@ -310,71 +305,51 @@ class MongoContentStore(ContentStore): contentType: The mimetype string of the asset md5: An md5 hash of the asset content ''' - # TODO: Using an aggregate() instead of a find() here is a hack to get around the fact that Mongo 3.2 does not - # support sorting case-insensitively. - # If a sort on displayname is requested, the aggregation pipeline creates a new field: - # `insensitive_displayname`, a lowercase version of `displayname` that is sorted on instead. - # Mongo 3.4 does not require this hack. When upgraded, change this aggregation back to a find and specifiy - # a collation based on user's language locale instead. - # See: https://openedx.atlassian.net/browse/EDUCATOR-2221 - pipeline_stages = [] query = query_for_course(course_key, 'asset' if not get_thumbnails else 'thumbnail') + user_language = 'en' if filter_params: + user_language = filter_params.pop('user_language', 'en') query.update(filter_params) - pipeline_stages.append({'$match': query}) + # Count total matching documents + count = self.fs_files.count_documents(query) + + sort_list = [] if sort: sort = dict(sort) if 'displayname' in sort: - pipeline_stages.append({ - '$project': { - 'contentType': 1, - 'locked': 1, - 'chunkSize': 1, - 'content_son': 1, - 'displayname': 1, - 'filename': 1, - 'length': 1, - 'import_path': 1, - 'uploadDate': 1, - 'thumbnail_location': 1, - 'md5': 1, - 'insensitive_displayname': { - '$toLower': '$displayname' - } - } + # Apply case-insensitive sorting + cursor = self.fs_files.find(query, { + 'contentType': 1, + 'locked': 1, + 'chunkSize': 1, + 'content_son': 1, + 'displayname': 1, + 'filename': 1, + 'length': 1, + 'import_path': 1, + 'uploadDate': 1, + 'thumbnail_location': 1, + 'md5': 1 }) - sort = {'insensitive_displayname': sort['displayname']} - pipeline_stages.append({'$sort': sort}) + cursor = cursor.sort('displayname', sort['displayname']).collation( + {'locale': user_language, 'strength': 2} + ) + else: + # Apply simple sorting + sort_list = list(sort.items()) + cursor = self.fs_files.find(query).sort(sort_list) + else: + cursor = self.fs_files.find(query) - # This is another hack to get the total query result count, but only the Nth page of actual documents - # See: https://stackoverflow.com/a/39784851/6620612 - pipeline_stages.append({'$group': {'_id': None, 'count': {'$sum': 1}, 'results': {'$push': '$$ROOT'}}}) + # Apply pagination + if start > 0: + cursor = cursor.skip(start) if maxresults > 0: - pipeline_stages.append({ - '$project': { - 'count': 1, - 'results': { - '$slice': ['$results', start, maxresults] - } - } - }) + cursor = cursor.limit(maxresults) - cursor = self.fs_files.aggregate(pipeline_stages) - # Set values if result of query is empty - count = 0 - assets = [] - try: - result = cursor.next() - if result: - count = result['count'] - assets = list(result['results']) - except StopIteration: - # Skip if no assets were returned - pass - - # We're constructing the asset key immediately after retrieval from the database so that - # callers are insulated from knowing how our identifiers are stored. + assets = list(cursor) + # Construct asset keys for asset in assets: asset_id = asset.get('content_son', asset['_id']) asset['asset_key'] = course_key.make_asset_key(asset_id['category'], asset_id['name']) @@ -424,7 +399,6 @@ class MongoContentStore(ContentStore): if result.matched_count == 0: raise NotFoundError(asset_db_key) - @autoretry_read() def get_attrs(self, location): """ Gets all of the attributes associated with the given asset. Note, returns even built in attrs diff --git a/xmodule/course_block.py b/xmodule/course_block.py index 5b1f92d777..c3f42ecb66 100644 --- a/xmodule/course_block.py +++ b/xmodule/course_block.py @@ -841,8 +841,8 @@ class CourseFields: # lint-amnesty, pylint: disable=missing-class-docstring # Translators: please don't translate "id". help=_( 'Configure team sets, limit team sizes, and set visibility settings using JSON. See ' - 'teams ' + '', + "view_link": f'', "blocks": [ {"display_name": display_name_with_default(child)} for child in self.get_children() diff --git a/xmodule/js/fixtures/video_all.html b/xmodule/js/fixtures/video_all.html index 280fc4db35..81b472d754 100644 --- a/xmodule/js/fixtures/video_all.html +++ b/xmodule/js/fixtures/video_all.html @@ -42,8 +42,13 @@ Share on: " + "href='https://docs.openedx.org/en/latest/educators/navigation/components_activities.html#lti-component'>" ) BREAK_TAG = '
    ' @@ -274,7 +274,7 @@ class LTIFields: @XBlock.needs("mako") @XBlock.needs("user") @XBlock.needs("rebind_user") -class LTIBlock( +class _BuiltInLTIBlock( LTIFields, LTI20BlockMixin, EmptyDataRawMixin, @@ -366,6 +366,7 @@ class LTIBlock( Otherwise error message from LTI provider is generated. """ + is_extracted = False resources_dir = None uses_xmodule_styles_setup = True @@ -609,8 +610,12 @@ class LTIBlock( def get_course(self): """ Return course by course id. + + Returns None if the current block is not part of a course (i.e part of a library). """ - return self.runtime.modulestore.get_course(self.course_id) + if isinstance(self.course_id, CourseKey): + return self.runtime.modulestore.get_course(self.course_id) + return None @property def context_id(self): @@ -960,7 +965,8 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} Obtains client_key and client_secret credentials from current course. """ course = self.get_course() - for lti_passport in course.lti_passports: + lti_passports = course.lti_passports if course else [] + for lti_passport in lti_passports: try: lti_id, key, secret = [i.strip() for i in lti_passport.split(':')] except ValueError: @@ -984,3 +990,10 @@ oauth_consumer_key="", oauth_signature="frVp4JuvT1mVXlxktiAUjQ7%2F1cw%3D"'} else: close_date = due_date return close_date is not None and datetime.datetime.now(UTC) > close_date + + +LTIBlock = ( + _ExtractedLTIBlock if settings.USE_EXTRACTED_LTI_BLOCK + else _BuiltInLTIBlock +) +LTIBlock.__name__ = "LTIBlock" diff --git a/xmodule/modulestore/django.py b/xmodule/modulestore/django.py index f36c0c35a9..c15f03a4e3 100644 --- a/xmodule/modulestore/django.py +++ b/xmodule/modulestore/django.py @@ -6,10 +6,10 @@ Passes settings.MODULESTORE as kwargs to MongoModuleStore from contextlib import contextmanager from importlib import import_module +import importlib.resources as resources import gettext import logging -from pkg_resources import resource_filename import re # lint-amnesty, pylint: disable=wrong-import-order from django.conf import settings @@ -422,9 +422,9 @@ class XBlockI18nService: return 'django', xblock_locale_path # Pre-OEP-58 translations within the XBlock pip packages are deprecated but supported. - deprecated_xblock_locale_path = resource_filename(xblock_module_name, 'translations') - # The `text` domain was used for XBlocks pre-OEP-58. - return 'text', deprecated_xblock_locale_path + with resources.as_file(resources.files(xblock_module_name) / 'translations') as deprecated_xblock_locale_path: + # The `text` domain was used for XBlocks pre-OEP-58. + return 'text', str(deprecated_xblock_locale_path) def get_javascript_i18n_catalog_url(self, block): """ diff --git a/xmodule/modulestore/mixed.py b/xmodule/modulestore/mixed.py index fb5be17041..1d9fd1f84e 100644 --- a/xmodule/modulestore/mixed.py +++ b/xmodule/modulestore/mixed.py @@ -678,6 +678,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): self.mappings[course_key] = store # .. event_implemented_name: COURSE_CREATED + # .. event_type: org.openedx.content_authoring.course.created.v1 COURSE_CREATED.send_event( time=datetime.now(timezone.utc), course=CourseData( @@ -761,6 +762,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): def send_created_event(): # .. event_implemented_name: XBLOCK_CREATED + # .. event_type: org.openedx.content_authoring.xblock.created.v1 XBLOCK_CREATED.send_event( time=datetime.now(timezone.utc), xblock_info=XBlockData( @@ -799,6 +801,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): def send_created_event(): # .. event_implemented_name: XBLOCK_CREATED + # .. event_type: org.openedx.content_authoring.xblock.created.v1 XBLOCK_CREATED.send_event( time=datetime.now(timezone.utc), xblock_info=XBlockData( @@ -843,6 +846,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): def send_updated_event(): # .. event_implemented_name: XBLOCK_UPDATED + # .. event_type: org.openedx.content_authoring.xblock.updated.v1 XBLOCK_UPDATED.send_event( time=datetime.now(timezone.utc), xblock_info=XBlockData( @@ -866,6 +870,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): def send_deleted_event(): # .. event_implemented_name: XBLOCK_DELETED + # .. event_type: org.openedx.content_authoring.xblock.deleted.v1 XBLOCK_DELETED.send_event( time=datetime.now(timezone.utc), xblock_info=XBlockData( @@ -991,6 +996,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase): store = self._verify_modulestore_support(location.course_key, 'publish') item = store.publish(location, user_id, **kwargs) # .. event_implemented_name: XBLOCK_PUBLISHED + # .. event_type: org.openedx.content_authoring.xblock.published.v1 XBLOCK_PUBLISHED.send_event( time=datetime.now(timezone.utc), xblock_info=XBlockData( diff --git a/xmodule/modulestore/mongo/base.py b/xmodule/modulestore/mongo/base.py index 16a8c134c1..2b9b3031fa 100644 --- a/xmodule/modulestore/mongo/base.py +++ b/xmodule/modulestore/mongo/base.py @@ -23,7 +23,6 @@ from uuid import uuid4 import pymongo from bson.son import SON from fs.osfs import OSFS -from mongodb_proxy import autoretry_read from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator, LibraryLocator from path import Path as path @@ -85,6 +84,7 @@ class MongoKeyValueStore(InheritanceKeyValueStore): A KeyValueStore that maps keyed data access to one of the 3 data areas known to the MongoModuleStore (data, children, and metadata) """ + def __init__(self, data, metadata): super().__init__() if not isinstance(data, dict): @@ -465,7 +465,6 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo fs_service=None, user_service=None, signal_handler=None, - retry_wait_time=0.1, **kwargs): """ :param doc_store_config: must have a host, db, and collection entries. Other common entries: port, tz_aware. @@ -474,7 +473,6 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo super().__init__(contentstore=contentstore, **kwargs) self.doc_store_config = doc_store_config - self.retry_wait_time = retry_wait_time self.do_connection(**self.doc_store_config) if default_class is not None: @@ -534,7 +532,7 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo self.database = connect_to_mongodb( db, host, port=port, tz_aware=tz_aware, user=user, password=password, - retry_wait_time=self.retry_wait_time, **kwargs + **kwargs ) self.collection = self.database[collection] @@ -569,7 +567,7 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo connection = self.collection.database.client if database: - connection.drop_database(self.collection.database.proxied_object) + connection.drop_database(self.collection.database) elif collections: self.collection.drop() else: @@ -578,7 +576,6 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo if connections: connection.close() - @autoretry_read() def fill_in_run(self, course_key): """ In mongo some course_keys are used without runs. This helper function returns @@ -737,7 +734,6 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo for item in items ] - @autoretry_read() def get_course_summaries(self, **kwargs): """ Returns a list of `CourseSummary`. This accepts an optional parameter of 'org' which @@ -786,7 +782,6 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo return courses_summaries - @autoretry_read() def get_courses(self, **kwargs): ''' Returns a list of course descriptors. This accepts an optional parameter of 'org' which @@ -821,7 +816,6 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo ) return [course for course in base_list if not isinstance(course, ErrorBlock)] - @autoretry_read() def _find_one(self, location): '''Look for a given location in the collection. If the item is not present, raise ItemNotFoundError. @@ -867,7 +861,6 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo except ItemNotFoundError: return None - @autoretry_read() def has_course(self, course_key, ignore_case=False, **kwargs): # lint-amnesty, pylint: disable=arguments-differ """ Returns the course_id of the course if it was found, else None @@ -962,7 +955,6 @@ class MongoModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase, Mongo for key in ('tag', 'org', 'course', 'category', 'name', 'revision') ]) - @autoretry_read() def get_items( # lint-amnesty, pylint: disable=arguments-differ self, course_id, diff --git a/xmodule/modulestore/split_mongo/mongo_connection.py b/xmodule/modulestore/split_mongo/mongo_connection.py index 9c00b0c22f..cb6cca612e 100644 --- a/xmodule/modulestore/split_mongo/mongo_connection.py +++ b/xmodule/modulestore/split_mongo/mongo_connection.py @@ -17,7 +17,6 @@ from django.core.cache import caches, InvalidCacheBackendError from django.db.transaction import TransactionManagementError import pymongo import pytz -from mongodb_proxy import autoretry_read # Import this just to export it from pymongo.errors import DuplicateKeyError # pylint: disable=unused-import from edx_django_utils import monitoring @@ -57,6 +56,7 @@ class Tagger: An object used by :class:`QueryTimer` to allow timed code blocks to add measurements and tags to the timer. """ + def __init__(self, default_sample_rate): self.added_tags = [] self.measures = [] @@ -103,6 +103,7 @@ class QueryTimer: An object that allows timing a block of code while also recording measurements about that code. """ + def __init__(self, metric_base, sample_rate=1): """ Arguments: @@ -198,6 +199,7 @@ class CourseStructureCache: If the 'course_structure_cache' doesn't exist, then don't do anything for for set and get. """ + def __init__(self): self.cache = None try: @@ -248,26 +250,26 @@ class CourseStructureCache: # We rely on the course structure cache default timeout, which should be # high by default (~ a few days). - try: + total_bytes_in_one_mb = 1024 * 1024 + if data_size < total_bytes_in_one_mb * 2: # Only data with a size smaller than 2MB will be cached self.cache.set(key, compressed_pickled_data) - except Exception: # pylint: disable=broad-except - total_bytes_in_one_mb = 1024 * 1024 + else: chunk_size_in_mbs = round(data_size / total_bytes_in_one_mb, 2) - # .. custom_attribute_name: split_mongo_compressed_size + # .. custom_attribute_name: split_mongo_compressed_size_in_mbs # .. custom_attribute_description: contains the data chunk size in MBs. The size on which # the memcached client failed to store value in course structure cache. - monitoring.set_custom_attribute('split_mongo_compressed_size', chunk_size_in_mbs) - log.info('Data caching (course structure) failed on chunk size: {} MB'.format(chunk_size_in_mbs)) + monitoring.set_custom_attribute('split_mongo_compressed_size_in_mbs', chunk_size_in_mbs) class MongoPersistenceBackend: """ Segregation of pymongo functions from the data modeling mechanisms for split modulestore. """ + def __init__( self, db, collection, host, port=27017, tz_aware=True, user=None, password=None, - asset_collection=None, retry_wait_time=0.1, with_mysql_subclass=False, **kwargs # lint-amnesty, pylint: disable=unused-argument + asset_collection=None, with_mysql_subclass=False, **kwargs # lint-amnesty, pylint: disable=unused-argument ): """ Create & open the connection, authenticate, and provide pointers to the collections @@ -287,7 +289,6 @@ class MongoPersistenceBackend: 'tz_aware': tz_aware, 'user': user, 'password': password, - 'retry_wait_time': retry_wait_time, **kwargs } @@ -363,7 +364,6 @@ class MongoPersistenceBackend: return structure - @autoretry_read() def find_structures_by_id(self, ids, course_context=None): """ Return all structures that specified in ``ids``. @@ -380,7 +380,6 @@ class MongoPersistenceBackend: tagger.measure("structures", len(docs)) return docs - @autoretry_read() def find_courselike_blocks_by_id(self, ids, block_type, course_context=None): """ Find all structures that specified in `ids`. Among the blocks only return block whose type is `block_type`. diff --git a/xmodule/modulestore/split_mongo/split.py b/xmodule/modulestore/split_mongo/split.py index e69a2ca0e5..b2124c4025 100644 --- a/xmodule/modulestore/split_mongo/split.py +++ b/xmodule/modulestore/split_mongo/split.py @@ -63,7 +63,6 @@ from importlib import import_module from bson.objectid import ObjectId from ccx_keys.locator import CCXBlockUsageLocator, CCXLocator -from mongodb_proxy import autoretry_read from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import ( BlockUsageLocator, @@ -953,7 +952,6 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): branch=branch, ) - @autoretry_read() def get_courses(self, branch, **kwargs): # lint-amnesty, pylint: disable=arguments-differ """ Returns a list of course blocks matching any given qualifiers. @@ -969,7 +967,6 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): # get the blocks for each course index (s/b the root) return self._get_structures_for_branch_and_locator(branch, self._create_course_locator, **kwargs) - @autoretry_read() def get_course_summaries(self, branch, **kwargs): """ Returns a list of `CourseSummary` which matching any given qualifiers. @@ -1026,7 +1023,6 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): in self.find_matching_course_indexes(branch="library") }) - @autoretry_read() def get_library_summaries(self, **kwargs): """ Returns a list of `LegacyLibrarySummary` objects. @@ -3255,7 +3251,6 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase): """ structure['blocks'][block_key] = content - @autoretry_read() def find_courses_by_search_target(self, field_name, field_value): """ Find all the courses which cached that they have the given field with the given value. @@ -3325,6 +3320,7 @@ class SparseList(list): Enable inserting items into a list in arbitrary order and then retrieving them. """ # taken from http://stackoverflow.com/questions/1857780/sparse-assignment-list-in-python + def __setitem__(self, index, value): """ Add value to the list ensuring the list is long enough to accommodate it at the given index diff --git a/xmodule/modulestore/tests/test_api.py b/xmodule/modulestore/tests/test_api.py index 4e665c9eec..03dd79d4ff 100644 --- a/xmodule/modulestore/tests/test_api.py +++ b/xmodule/modulestore/tests/test_api.py @@ -57,7 +57,7 @@ def test_file_paths_api(): Test the `get_python_locale_root` returned path. """ root = get_python_locale_root() - assert root.endswith('edx-platform/conf/plugins-locale/xblock.v1'), 'Needs to match Makefile and other code' + assert root.endswith('/conf/plugins-locale/xblock.v1'), 'Needs to match Makefile and other code' def test_get_javascript_i18n_file_name(): diff --git a/xmodule/modulestore/tests/test_django_utils.py b/xmodule/modulestore/tests/test_django_utils.py index 86c3a08df5..f8fba427b4 100644 --- a/xmodule/modulestore/tests/test_django_utils.py +++ b/xmodule/modulestore/tests/test_django_utils.py @@ -2,6 +2,7 @@ Tests for the modulestore.django module """ +from pathlib import Path from unittest.mock import patch import django.utils.translation @@ -23,19 +24,19 @@ def test_get_python_locale_with_atlas_oep58_translations(mock_modern_xblock): assert domain == 'django', 'Uses django domain when atlas locale is found.' -@patch('xmodule.modulestore.django.resource_filename', return_value='/lib/my_legacy_xblock/translations') +@patch('importlib.resources.files', return_value=Path('/lib/my_legacy_xblock')) def test_get_python_locale_with_bundled_translations(mock_modern_xblock): """ Ensure that get_python_locale() falls back to XBlock internal translations if atlas translations weren't pulled. Pre-OEP-58 translations were stored in the `translations` directory of the XBlock which is - accessible via the `pkg_resources.resource_filename` function. + accessible via the `importlib.resources.files` function. """ i18n_service = XBlockI18nService() block = mock_modern_xblock['legacy_xblock'] domain, path = i18n_service.get_python_locale(block) - assert path == '/lib/my_legacy_xblock/translations', 'Backward compatible with pe-OEP-58.' + assert path == '/lib/my_legacy_xblock/translations', 'Backward compatible with pre-OEP-58.' assert domain == 'text', 'Use the legacy `text` domain for backward compatibility with old XBlocks.' diff --git a/xmodule/modulestore/tests/test_mixed_modulestore.py b/xmodule/modulestore/tests/test_mixed_modulestore.py index 0928ab253b..c7770beb0f 100644 --- a/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -164,6 +164,24 @@ class CommonMixedModuleStoreSetup(CourseComparisonTest, OpenEdxEventsTestMixin): self.course_locations = {} self.user_id = ModuleStoreEnum.UserID.test + # mock and ignore publishable link entity related tasks to avoid unnecessary + # errors as it is tested separately + if settings.ROOT_URLCONF == 'cms.urls': + create_or_update_xblock_upstream_link_patch = patch( + 'cms.djangoapps.contentstore.signals.handlers.handle_create_or_update_xblock_upstream_link' + ) + create_or_update_xblock_upstream_link_patch.start() + self.addCleanup(create_or_update_xblock_upstream_link_patch.stop) + component_link_patch = patch( + 'cms.djangoapps.contentstore.signals.handlers.ComponentLink' + ) + component_link_patch.start() + self.addCleanup(component_link_patch.stop) + container_link_patch = patch( + 'cms.djangoapps.contentstore.signals.handlers.ContainerLink' + ) + container_link_patch.start() + self.addCleanup(container_link_patch.stop) def _check_connection(self): """ diff --git a/xmodule/modulestore/tests/test_split_modulestore.py b/xmodule/modulestore/tests/test_split_modulestore.py index bee57733ac..c574d345d9 100644 --- a/xmodule/modulestore/tests/test_split_modulestore.py +++ b/xmodule/modulestore/tests/test_split_modulestore.py @@ -16,7 +16,6 @@ import ddt from ccx_keys.locator import CCXBlockUsageLocator from django.core.cache import InvalidCacheBackendError, caches from opaque_keys.edx.locator import BlockUsageLocator, CourseKey, CourseLocator, LocalId -from testfixtures import LogCapture from xblock.fields import Reference, ReferenceList, ReferenceValueDict from openedx.core.djangolib.testing.utils import CacheIsolationMixin @@ -842,42 +841,38 @@ class TestCourseStructureCache(CacheIsolationMixin, SplitModuleTest): # now make sure that you get the same structure assert cached_structure == not_cached_structure + @patch('xmodule.modulestore.split_mongo.mongo_connection.monitoring.set_custom_attribute') @patch('django.core.cache.cache.set') @patch('xmodule.modulestore.split_mongo.mongo_connection.get_cache') - def test_course_structure_cache_with_data_chunk_greater_than_one_mb(self, mock_get_cache, mock_set_cache): + def test_course_structure_cache_with_data_chunk_greater_than_two_mb(self, mock_get_cache, mock_set_cache, + mock_set_custom_attribute): enabled_cache = caches['default'] mock_get_cache.return_value = enabled_cache - mock_set_cache.side_effect = Exception course_cache = CourseStructureCache() - size = 300000000 + size = 3000000000 # this data_chunk will be compressed before being cached data_chunk = b'\x00' * size - logger_name = 'xmodule.modulestore.split_mongo.mongo_connection' - expected_message = 'Data caching (course structure) failed on chunk size: 1.25 MB' - with LogCapture(logger_name) as capture: - course_cache.set('my_data_chunk', data_chunk) - - self.assertEqual(capture.records[0].name, logger_name) - self.assertEqual(capture.records[0].msg, expected_message) - self.assertEqual(capture.records[0].levelname, 'INFO') + course_cache.set('my_data_chunk', data_chunk) + mock_set_cache.assert_not_called() + mock_set_custom_attribute.assert_called() + @patch('xmodule.modulestore.split_mongo.mongo_connection.monitoring.set_custom_attribute') + @patch('django.core.cache.cache.set') @patch('xmodule.modulestore.split_mongo.mongo_connection.get_cache') - def test_course_structure_cache_with_data_chunk_lesser_than_one_mb(self, mock_get_cache): + def test_course_structure_cache_with_data_chunk_lesser_than_two_mb(self, mock_get_cache, mock_set_cache, + mock_set_custom_attribute): enabled_cache = caches['default'] mock_get_cache.return_value = enabled_cache course_cache = CourseStructureCache() size = 30000 data_chunk = b'\x00' * size - logger_name = 'xmodule.modulestore.split_mongo.mongo_connection' - with LogCapture(logger_name) as capture: - course_cache.set('my_data_chunk', data_chunk) - - # data chunk was less than 1MB so no logs were added. - self.assertEqual(len(capture.records), 0) + course_cache.set('my_data_chunk', data_chunk) + mock_set_cache.assert_called() + mock_set_custom_attribute.assert_not_called() def _get_structure(self, course): """ diff --git a/xmodule/modulestore/tests/test_split_mongo_mongo_connection.py b/xmodule/modulestore/tests/test_split_mongo_mongo_connection.py index 008c1cc2a6..d4f30facd0 100644 --- a/xmodule/modulestore/tests/test_split_mongo_mongo_connection.py +++ b/xmodule/modulestore/tests/test_split_mongo_mongo_connection.py @@ -14,13 +14,12 @@ from xmodule.modulestore.split_mongo.mongo_connection import MongoPersistenceBac class TestHeartbeatFailureException(unittest.TestCase): """ Test that a heartbeat failure is thrown at the appropriate times """ - @patch('pymongo.MongoClient') - @patch('pymongo.database.Database') - def test_heartbeat_raises_exception_when_connection_alive_is_false(self, *calls): + @patch('pymongo.mongo_client.MongoClient') + def test_heartbeat_raises_exception_when_connection_alive_is_false(self, MockClient): # pylint: disable=W0613 - with patch('mongodb_proxy.MongoProxy') as mock_proxy: - mock_proxy.return_value.admin.command.side_effect = ConnectionFailure('Test') - useless_conn = MongoPersistenceBackend('useless', 'useless', 'useless') - with pytest.raises(HeartbeatFailure): - useless_conn.heartbeat() + MockClient.return_value.admin.command.side_effect = ConnectionFailure('Test') + useless_conn = MongoPersistenceBackend('useless', 'useless', 'useless') + + with pytest.raises(HeartbeatFailure): + useless_conn.heartbeat() diff --git a/xmodule/modulestore/xml_importer.py b/xmodule/modulestore/xml_importer.py index 5b880b4ade..a7d0909b28 100644 --- a/xmodule/modulestore/xml_importer.py +++ b/xmodule/modulestore/xml_importer.py @@ -27,6 +27,7 @@ import mimetypes import os import re from abc import abstractmethod +from datetime import datetime, timezone import xblock from django.core.exceptions import ObjectDoesNotExist @@ -34,12 +35,15 @@ from django.utils.translation import gettext as _ from lxml import etree from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.locator import LibraryLocator +from openedx_events.content_authoring.data import CourseData +from openedx_events.content_authoring.signals import COURSE_IMPORT_COMPLETED from path import Path as path from xblock.core import XBlockMixin from xblock.fields import Reference, ReferenceList, ReferenceValueDict, Scope from xblock.runtime import DictKeyValueStore, KvsFieldData from common.djangoapps.util.monitoring import monitor_import_failure +from openedx.core.djangoapps.content_tagging.api import import_course_tags_from_csv from xmodule.assetstore import AssetMetadata from xmodule.contentstore.content import StaticContent from xmodule.errortracker import make_error_tracker @@ -52,7 +56,6 @@ from xmodule.modulestore.xml import ImportSystem, LibraryXMLModuleStore, XMLModu from xmodule.tabs import CourseTabList from xmodule.util.misc import escape_invalid_characters from xmodule.x_module import XModuleMixin -from openedx.core.djangoapps.content_tagging.api import import_course_tags_from_csv from .inheritance import own_metadata from .store_utilities import rewrite_nonportable_content_links @@ -548,6 +551,11 @@ class ImportManager: # pylint: disable=raise-missing-from raise BlockFailedToImport(leftover.display_name, leftover.location) + def post_course_import(self, dest_id): + """ + Tasks that need to triggered after a course is imported. + """ + def run_imports(self): """ Iterate over the given directories and yield courses. @@ -589,6 +597,7 @@ class ImportManager: logging.info(f'Course import {dest_id}: No tags.csv file present.') except ValueError as e: logging.info(f'Course import {dest_id}: {str(e)}') + self.post_course_import(dest_id) yield courselike @@ -717,6 +726,19 @@ class CourseImportManager(ImportManager): csv_path = path(data_path) / 'tags.csv' import_course_tags_from_csv(csv_path, dest_id) + def post_course_import(self, dest_id): + """ + Trigger celery task to create upstream links for newly imported blocks. + """ + # .. event_implemented_name: COURSE_IMPORT_COMPLETED + # .. event_type: org.openedx.content_authoring.course.import.completed.v1 + COURSE_IMPORT_COMPLETED.send_event( + time=datetime.now(timezone.utc), + course=CourseData( + course_key=dest_id + ) + ) + class LibraryImportManager(ImportManager): """ diff --git a/xmodule/mongo_utils.py b/xmodule/mongo_utils.py index 5aecbfc405..78538d530a 100644 --- a/xmodule/mongo_utils.py +++ b/xmodule/mongo_utils.py @@ -6,7 +6,6 @@ Common MongoDB connection functions. import logging import pymongo -from mongodb_proxy import MongoProxy from pymongo.read_preferences import ( # lint-amnesty, pylint: disable=unused-import ReadPreference, _MONGOS_MODES, @@ -23,7 +22,7 @@ MONGO_READ_PREFERENCE_MAP = dict(zip(_MONGOS_MODES, _MODES)) def connect_to_mongodb( db, host, port=27017, tz_aware=True, user=None, password=None, - retry_wait_time=0.1, proxy=True, **kwargs + retry_reads=True, **kwargs ): """ Returns a MongoDB Database connection, optionally wrapped in a proxy. The proxy @@ -62,6 +61,7 @@ def connect_to_mongodb( 'port': port, 'tz_aware': tz_aware, 'document_class': dict, + 'retryReads': retry_reads, **kwargs, } @@ -69,14 +69,6 @@ def connect_to_mongodb( connection_params.update({'username': user, 'password': password, 'authSource': auth_source}) mongo_conn = pymongo.MongoClient(**connection_params) - - if proxy: - mongo_conn = MongoProxy( - mongo_conn[db], - wait_time=retry_wait_time - ) - return mongo_conn - return mongo_conn[db] diff --git a/xmodule/poll_block.py b/xmodule/poll_block.py index a1c9686f42..b8c65f1cdb 100644 --- a/xmodule/poll_block.py +++ b/xmodule/poll_block.py @@ -6,18 +6,19 @@ If student does not yet anwered - Question with set of choices. If student have answered - Question with statistics for each answers. """ - import html import json import logging -from collections import OrderedDict from copy import deepcopy -from web_fragments.fragment import Fragment - +from collections import OrderedDict +from django.conf import settings from lxml import etree +from web_fragments.fragment import Fragment from xblock.core import XBlock from xblock.fields import Boolean, Dict, List, Scope, String # lint-amnesty, pylint: disable=wrong-import-order +from xblocks_contrib.poll import PollBlock as _ExtractedPollBlock + from openedx.core.djangolib.markup import Text, HTML from xmodule.mako_block import MakoTemplateBlockBase from xmodule.stringify import stringify_children @@ -30,13 +31,12 @@ from xmodule.x_module import ( ) from xmodule.xml_block import XmlMixin - log = logging.getLogger(__name__) _ = lambda text: text @XBlock.needs('mako') -class PollBlock( +class _BuiltInPollBlock( MakoTemplateBlockBase, XmlMixin, XModuleToXBlockMixin, @@ -44,6 +44,9 @@ class PollBlock( XModuleMixin, ): # pylint: disable=abstract-method """Poll Block""" + + is_extracted = False + # Name of poll to use in links to this poll display_name = String( help=_("The display name for this component."), @@ -244,3 +247,10 @@ class PollBlock( add_child(xml_object, answer) return xml_object + + +PollBlock = ( + _ExtractedPollBlock if settings.USE_EXTRACTED_POLL_QUESTION_BLOCK + else _BuiltInPollBlock +) +PollBlock.__name__ = "PollBlock" diff --git a/xmodule/seq_block.py b/xmodule/seq_block.py index 1b94f47d89..f06d3030f5 100644 --- a/xmodule/seq_block.py +++ b/xmodule/seq_block.py @@ -557,7 +557,19 @@ class SequenceBlock( 'This section is a prerequisite. You must complete this section in order to unlock additional content.' ) - blocks = self._render_student_view_for_blocks(context, children, fragment, view) if prereq_met else [] + if prereq_met: + blocks = self._render_student_view_for_blocks(context, children, fragment, view) + else: + blocks = [] + for child in children: + usage_id = child.scope_ids.usage_id + blocks.append({ + 'id': str(usage_id), + 'type': child.scope_ids.block_type, + 'display_name': child.display_name_with_default, + 'is_gated': True, # Mark as blocked + 'content': '', # Real content not included + }) params = { 'items': blocks, diff --git a/xmodule/split_test_block.py b/xmodule/split_test_block.py index 05ca3a5db4..52f4054787 100644 --- a/xmodule/split_test_block.py +++ b/xmodule/split_test_block.py @@ -419,9 +419,8 @@ class SplitTestBlock( # lint-amnesty, pylint: disable=abstract-method ) ) 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' diff --git a/xmodule/static/css-builtin-blocks/AnnotatableBlockDisplay.css b/xmodule/static/css-builtin-blocks/AnnotatableBlockDisplay.css index 45b395ec66..0a17543285 100644 --- a/xmodule/static/css-builtin-blocks/AnnotatableBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/AnnotatableBlockDisplay.css @@ -1,16 +1,5 @@ @import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); -.xmodule_display.xmodule_AnnotatableBlock { - /* TODO: move top-level variables to a common _variables.scss. - * NOTE: These variables were only added here because when this was integrated with the CMS, - * SASS compilation errors were triggered because the CMS didn't have the same variables defined - * that the LMS did, so the quick fix was to localize the LMS variables not shared by the CMS. - * -Abarrett and Vshnayder - */ - /* stylelint-disable-line */ - /* stylelint-disable-line */ -} - .xmodule_display.xmodule_AnnotatableBlock .annotatable-wrapper { position: relative; } @@ -22,7 +11,7 @@ .xmodule_display.xmodule_AnnotatableBlock .annotatable-section { position: relative; padding: 0.5em 1em; - border: 1px solid var(--gray-l3); + border: 1px solid var(--gray-l3, #c8c8c8); border-radius: 0.5em; margin-bottom: 0.5em; } @@ -40,7 +29,7 @@ } .xmodule_display.xmodule_AnnotatableBlock .annotatable-section .annotatable-section-body { - border-top: 1px solid var(--gray-l3); + border-top: 1px solid var(--gray-l3, #c8c8c8); margin-top: 0.5em; padding-top: 0.5em; } @@ -162,7 +151,7 @@ border: 1px solid #333; border-radius: 1em; background-color: rgba(0, 0, 0, 0.85); - color: var(--white); + color: var(--white, #fff); -webkit-font-smoothing: antialiased; } @@ -170,12 +159,12 @@ font-size: 1em; color: inherit; background-color: transparent; - padding: calc((var(--baseline) / 4)) calc((var(--baseline) / 2)); + padding: calc((var(--baseline, 20px) / 4)) calc((var(--baseline, 20px) / 2)); border: none; } .xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip .ui-tooltip-titlebar .ui-tooltip-title { - padding: calc((var(--baseline) / 4)) 0; + padding: calc((var(--baseline, 20px) / 4)) 0; border-bottom: 2px solid #333; font-weight: bold; } @@ -187,7 +176,7 @@ .xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip .ui-tooltip-titlebar .ui-state-hover { color: inherit; - border: 1px solid var(--gray-l3); + border: 1px solid var(--gray-l3, #c8c8c8); } .xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip .ui-tooltip-content { @@ -195,7 +184,7 @@ font-size: 0.875em; text-align: left; font-weight: 400; - padding: 0 calc((var(--baseline) / 2)) calc((var(--baseline) / 2)) calc((var(--baseline) / 2)); + padding: 0 calc((var(--baseline, 20px) / 2)) calc((var(--baseline, 20px) / 2)) calc((var(--baseline, 20px) / 2)); background-color: transparent; border-color: transparent; } @@ -210,12 +199,12 @@ } .xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip-annotatable .ui-tooltip-content { - padding: 0 calc((var(--baseline) / 2)); + padding: 0 calc((var(--baseline, 20px) / 2)); } .xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip-annotatable .ui-tooltip-content .annotatable-comment { display: block; - margin: 0 0 calc((var(--baseline) / 2)) 0; + margin: 0 0 calc((var(--baseline, 20px) / 2)) 0; max-height: 225px; overflow: auto; line-height: normal; @@ -224,7 +213,7 @@ .xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip-annotatable .ui-tooltip-content .annotatable-reply { display: block; border-top: 2px solid #333; - padding: calc((var(--baseline) / 4)) 0; + padding: calc((var(--baseline, 20px) / 4)) 0; margin: 0; text-align: center; } @@ -237,7 +226,7 @@ left: 50%; height: 0; width: 0; - margin-left: calc(-1 * (var(--baseline) / 4)); + margin-left: calc(-1 * (var(--baseline, 20px) / 4)); border: 10px solid transparent; border-top-color: rgba(0, 0, 0, 0.85); } diff --git a/xmodule/static/css-builtin-blocks/HtmlBlockDisplay.css b/xmodule/static/css-builtin-blocks/HtmlBlockDisplay.css index 0896a814a2..ff8b7c7fae 100644 --- a/xmodule/static/css-builtin-blocks/HtmlBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/HtmlBlockDisplay.css @@ -1,13 +1,5 @@ @import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); -.xmodule_display.xmodule_AboutBlock, -.xmodule_display.xmodule_CourseInfoBlock, -.xmodule_display.xmodule_HtmlBlock, -.xmodule_display.xmodule_StaticTabBlock { - /* stylelint-disable-line */ - /* stylelint-disable-line */ -} - .xmodule_display.xmodule_AboutBlock *, .xmodule_display.xmodule_CourseInfoBlock *, .xmodule_display.xmodule_HtmlBlock *, @@ -19,7 +11,7 @@ .xmodule_display.xmodule_CourseInfoBlock h1, .xmodule_display.xmodule_HtmlBlock h1, .xmodule_display.xmodule_StaticTabBlock h1 { - color: var(--body-color); + color: var(--body-color, #313131); font: normal 2em/1.4em var(--font-family-sans-serif); letter-spacing: 1px; margin: 0 0 1.416em; @@ -32,7 +24,7 @@ color: #646464; font: normal 1.2em/1.2em var(--font-family-sans-serif); letter-spacing: 1px; - margin-bottom: calc((var(--baseline) * 0.75)); + margin-bottom: calc((var(--baseline, 20px) * 0.75)); -webkit-font-smoothing: antialiased; } @@ -52,7 +44,7 @@ .xmodule_display.xmodule_StaticTabBlock h4, .xmodule_display.xmodule_StaticTabBlock h5, .xmodule_display.xmodule_StaticTabBlock h6 { - margin: 0 0 calc((var(--baseline) / 2)); + margin: 0 0 calc((var(--baseline, 20px) / 2)); font-weight: 600; } @@ -91,7 +83,7 @@ margin-bottom: 1.416em; font-size: 1em; line-height: 1.6em !important; - color: var(--body-color); + color: var(--body-color, #313131); } .xmodule_display.xmodule_AboutBlock em, @@ -138,26 +130,26 @@ font-weight: bold; } -.xmodule_display.xmodule_AboutBlock p + p, -.xmodule_display.xmodule_AboutBlock ul + p, -.xmodule_display.xmodule_AboutBlock ol + p, -.xmodule_display.xmodule_CourseInfoBlock p + p, -.xmodule_display.xmodule_CourseInfoBlock ul + p, -.xmodule_display.xmodule_CourseInfoBlock ol + p, -.xmodule_display.xmodule_HtmlBlock p + p, -.xmodule_display.xmodule_HtmlBlock ul + p, -.xmodule_display.xmodule_HtmlBlock ol + p, -.xmodule_display.xmodule_StaticTabBlock p + p, -.xmodule_display.xmodule_StaticTabBlock ul + p, -.xmodule_display.xmodule_StaticTabBlock ol + p { - margin-top: var(--baseline); +.xmodule_display.xmodule_AboutBlock p+p, +.xmodule_display.xmodule_AboutBlock ul+p, +.xmodule_display.xmodule_AboutBlock ol+p, +.xmodule_display.xmodule_CourseInfoBlock p+p, +.xmodule_display.xmodule_CourseInfoBlock ul+p, +.xmodule_display.xmodule_CourseInfoBlock ol+p, +.xmodule_display.xmodule_HtmlBlock p+p, +.xmodule_display.xmodule_HtmlBlock ul+p, +.xmodule_display.xmodule_HtmlBlock ol+p, +.xmodule_display.xmodule_StaticTabBlock p+p, +.xmodule_display.xmodule_StaticTabBlock ul+p, +.xmodule_display.xmodule_StaticTabBlock ol+p { + margin-top: var(--baseline, 20px); } .xmodule_display.xmodule_AboutBlock blockquote, .xmodule_display.xmodule_CourseInfoBlock blockquote, .xmodule_display.xmodule_HtmlBlock blockquote, .xmodule_display.xmodule_StaticTabBlock blockquote { - margin: 1em calc((var(--baseline) * 2)); + margin: 1em calc((var(--baseline, 20px) * 2)); } .xmodule_display.xmodule_AboutBlock ol, @@ -170,7 +162,7 @@ .xmodule_display.xmodule_StaticTabBlock ul { padding: 0 0 0 1em; margin: 1em 0; - color: var(--body-color); + color: var(--body-color, #313131); } .xmodule_display.xmodule_AboutBlock ol li, @@ -198,7 +190,11 @@ list-style: disc outside none; } -.xmodule_display.xmodule_AboutBlock a:link, .xmodule_display.xmodule_AboutBlock a:visited, .xmodule_display.xmodule_AboutBlock a:hover, .xmodule_display.xmodule_AboutBlock a:active, .xmodule_display.xmodule_AboutBlock a:focus, +.xmodule_display.xmodule_AboutBlock a:link, +.xmodule_display.xmodule_AboutBlock a:visited, +.xmodule_display.xmodule_AboutBlock a:hover, +.xmodule_display.xmodule_AboutBlock a:active, +.xmodule_display.xmodule_AboutBlock a:focus, .xmodule_display.xmodule_CourseInfoBlock a:link, .xmodule_display.xmodule_CourseInfoBlock a:visited, .xmodule_display.xmodule_CourseInfoBlock a:hover, @@ -214,7 +210,7 @@ .xmodule_display.xmodule_StaticTabBlock a:hover, .xmodule_display.xmodule_StaticTabBlock a:active, .xmodule_display.xmodule_StaticTabBlock a:focus { - color: var(--blue); + color: var(--blue, #0075b4); } .xmodule_display.xmodule_AboutBlock img, @@ -230,7 +226,7 @@ .xmodule_display.xmodule_HtmlBlock pre, .xmodule_display.xmodule_StaticTabBlock pre { margin: 1em 0; - color: var(--body-color); + color: var(--body-color, #313131); font-family: monospace, serif; font-size: 1em; white-space: pre-wrap; @@ -241,7 +237,7 @@ .xmodule_display.xmodule_CourseInfoBlock code, .xmodule_display.xmodule_HtmlBlock code, .xmodule_display.xmodule_StaticTabBlock code { - color: var(--body-color); + color: var(--body-color, #313131); font-family: monospace, serif; background: none; padding: 0; @@ -252,7 +248,7 @@ .xmodule_display.xmodule_HtmlBlock table, .xmodule_display.xmodule_StaticTabBlock table { width: 100%; - margin: var(--baseline) 0; + margin: var(--baseline, 20px) 0; border-collapse: collapse; font-size: 16px; } @@ -265,9 +261,9 @@ .xmodule_display.xmodule_HtmlBlock table th, .xmodule_display.xmodule_StaticTabBlock table td, .xmodule_display.xmodule_StaticTabBlock table th { - margin: var(--baseline) 0; - padding: calc((var(--baseline) / 2)); - border: 1px solid var(--gray-l3); + margin: var(--baseline, 20px) 0; + padding: calc((var(--baseline, 20px) / 2)); + border: 1px solid var(--gray-l3, #c8c8c8); font-size: 14px; } @@ -318,12 +314,12 @@ .xmodule_display.xmodule_StaticTabBlock .wrapper-modal-image .modal-ui-icon { position: absolute; display: block; - padding: calc((var(--baseline) / 4)) 7px; + padding: calc((var(--baseline, 20px) / 4)) 7px; border-radius: 5px; opacity: 0.9; - background: var(--white); - color: var(--black); - border: 2px solid var(--black); + background: var(--white, #fff); + color: var(--black, #000); + border: 2px solid var(--black, #000); } .xmodule_display.xmodule_AboutBlock .wrapper-modal-image .modal-ui-icon .label, @@ -450,14 +446,14 @@ .xmodule_display.xmodule_CourseInfoBlock .wrapper-modal-image .image-modal .image-content .image-controls .image-control .modal-ui-icon.action-zoom-in, .xmodule_display.xmodule_HtmlBlock .wrapper-modal-image .image-modal .image-content .image-controls .image-control .modal-ui-icon.action-zoom-in, .xmodule_display.xmodule_StaticTabBlock .wrapper-modal-image .image-modal .image-content .image-controls .image-control .modal-ui-icon.action-zoom-in { - margin-right: calc((var(--baseline) / 4)); + margin-right: calc((var(--baseline, 20px) / 4)); } .xmodule_display.xmodule_AboutBlock .wrapper-modal-image .image-modal .image-content .image-controls .image-control .modal-ui-icon.action-zoom-out, .xmodule_display.xmodule_CourseInfoBlock .wrapper-modal-image .image-modal .image-content .image-controls .image-control .modal-ui-icon.action-zoom-out, .xmodule_display.xmodule_HtmlBlock .wrapper-modal-image .image-modal .image-content .image-controls .image-control .modal-ui-icon.action-zoom-out, .xmodule_display.xmodule_StaticTabBlock .wrapper-modal-image .image-modal .image-content .image-controls .image-control .modal-ui-icon.action-zoom-out { - margin-left: calc((var(--baseline) / 4)); + margin-left: calc((var(--baseline, 20px) / 4)); } .xmodule_display.xmodule_AboutBlock .wrapper-modal-image .image-modal .image-content .image-controls .image-control .modal-ui-icon.is-disabled, diff --git a/xmodule/static/css-builtin-blocks/HtmlBlockEditor.css b/xmodule/static/css-builtin-blocks/HtmlBlockEditor.css index feae8a0034..acb9079c81 100644 --- a/xmodule/static/css-builtin-blocks/HtmlBlockEditor.css +++ b/xmodule/static/css-builtin-blocks/HtmlBlockEditor.css @@ -1,10 +1,5 @@ @import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); -.xmodule_edit.xmodule_AboutBlock, -.xmodule_edit.xmodule_CourseInfoBlock, -.xmodule_edit.xmodule_HtmlBlock, -.xmodule_edit.xmodule_StaticTabBlock { -} .xmodule_edit.xmodule_AboutBlock .ui-col-wide, .xmodule_edit.xmodule_CourseInfoBlock .ui-col-wide, @@ -85,7 +80,7 @@ background-image: -webkit-linear-gradient(top, #d4dee8, #c9d5e2); background-image: linear-gradient(to bottom, #d4dee8, #c9d5e2); position: relative; - padding: calc(var(--baseline) / 4); + padding: calc(var(--baseline, 20px) / 4); border-bottom-color: #a5aaaf; } @@ -104,7 +99,7 @@ .xmodule_edit.xmodule_StaticTabBlock .editor .editor-bar button { display: inline-block; float: left; - padding: 3px calc(var(--baseline) / 2) 5px; + padding: 3px calc(var(--baseline, 20px) / 2) 5px; margin-left: 7px; border: 0; border-radius: 2px; @@ -118,7 +113,8 @@ height: 21px; } -.xmodule_edit.xmodule_AboutBlock .editor .editor-bar button:hover, .xmodule_edit.xmodule_AboutBlock .editor .editor-bar button:focus, +.xmodule_edit.xmodule_AboutBlock .editor .editor-bar button:hover, +.xmodule_edit.xmodule_AboutBlock .editor .editor-bar button:focus, .xmodule_edit.xmodule_CourseInfoBlock .editor .editor-bar button:hover, .xmodule_edit.xmodule_CourseInfoBlock .editor .editor-bar button:focus, .xmodule_edit.xmodule_HtmlBlock .editor .editor-bar button:hover, @@ -144,7 +140,7 @@ .xmodule_edit.xmodule_HtmlBlock .editor .editor-tabs li, .xmodule_edit.xmodule_StaticTabBlock .editor .editor-tabs li { float: left; - margin-right: calc(var(--baseline) / 4); + margin-right: calc(var(--baseline, 20px) / 4); } .xmodule_edit.xmodule_AboutBlock .editor .editor-tabs li:last-child, @@ -163,9 +159,9 @@ padding: 7px 20px 3px; border: 1px solid #a5aaaf; border-radius: 3px 3px 0 0; - background-color: var(--transparent); - background-image: -webkit-linear-gradient(top, var(--transparent) 87%, rgba(0, 0, 0, 0.06)); - background-image: linear-gradient(to bottom, var(--transparent) 87%, rgba(0, 0, 0, 0.06)); + background-color: var(--transparent, transparent); + background-image: -webkit-linear-gradient(top, var(--transparent, transparent) 87%, rgba(0, 0, 0, 0.06)); + background-image: linear-gradient(to bottom, var(--transparent, transparent) 87%, rgba(0, 0, 0, 0.06)); background-color: #e5ecf3; font-size: 13px; color: #3c3c3c; @@ -176,8 +172,8 @@ .xmodule_edit.xmodule_CourseInfoBlock .editor .editor-tabs .tab.current, .xmodule_edit.xmodule_HtmlBlock .editor .editor-tabs .tab.current, .xmodule_edit.xmodule_StaticTabBlock .editor .editor-tabs .tab.current { - background: var(--white); - border-bottom-color: var(--white); + background: var(--white, #fff); + border-bottom-color: var(--white, #fff); } .xmodule_edit.xmodule_AboutBlock .html-editor:after, diff --git a/xmodule/static/css-builtin-blocks/LTIBlockDisplay.css b/xmodule/static/css-builtin-blocks/LTIBlockDisplay.css index 294dfc9a83..ab39520a5f 100644 --- a/xmodule/static/css-builtin-blocks/LTIBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/LTIBlockDisplay.css @@ -1,11 +1,5 @@ @import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); -.xmodule_display.xmodule_LTIBlock { - /* stylelint-disable-line */ - /* stylelint-disable-line */ - /* stylelint-disable-line */ - /* stylelint-disable-line */ -} .xmodule_display.xmodule_LTIBlock h2.problem-header { display: inline-block; @@ -13,7 +7,7 @@ .xmodule_display.xmodule_LTIBlock div.problem-progress { display: inline-block; - padding-left: calc((var(--baseline)/4)); + padding-left: calc((var(--baseline, 20px) / 4)); color: #666; font-weight: 100; font-size: 1em; @@ -25,8 +19,8 @@ .xmodule_display.xmodule_LTIBlock div.lti .wrapper-lti-link { font-size: 14px; - background-color: var(--sidebar-color); - padding: var(--baseline); + background-color: var(--sidebar-color, #f6f6f6); + padding: var(--baseline, 20px); } .xmodule_display.xmodule_LTIBlock div.lti .wrapper-lti-link .lti-link { @@ -57,6 +51,6 @@ } .xmodule_display.xmodule_LTIBlock div.lti div.problem-feedback { - margin-top: calc((var(--baseline)/4)); - margin-bottom: calc((var(--baseline)/4)); + margin-top: calc((var(--baseline, 20px) / 4)); + margin-bottom: calc((var(--baseline, 20px) / 4)); } diff --git a/xmodule/static/css-builtin-blocks/PollBlockDisplay.css b/xmodule/static/css-builtin-blocks/PollBlockDisplay.css index f617dc960d..0615ecf123 100644 --- a/xmodule/static/css-builtin-blocks/PollBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/PollBlockDisplay.css @@ -1,10 +1,5 @@ @import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); -.xmodule_display.xmodule_PollBlock { - /* stylelint-disable-line */ - /* stylelint-disable-line */ -} - @media print { .xmodule_display.xmodule_PollBlock div.poll_question { display: block; @@ -12,7 +7,8 @@ padding: 0; } - .xmodule_display.xmodule_PollBlock div.poll_question canvas, .xmodule_display.xmodule_PollBlock div.poll_question img { + .xmodule_display.xmodule_PollBlock div.poll_question canvas, + .xmodule_display.xmodule_PollBlock div.poll_question img { page-break-inside: avoid; } } @@ -23,13 +19,13 @@ .xmodule_display.xmodule_PollBlock div.poll_question h3 { margin-top: 0; - margin-bottom: calc((var(--baseline) * 0.75)); + margin-bottom: calc((var(--baseline, 20px) * 0.75)); color: #fe57a1; font-size: 1.9em; } .xmodule_display.xmodule_PollBlock div.poll_question h3.problem-header div.staff { - margin-top: calc((var(--baseline) * 1.5)); + margin-top: calc((var(--baseline, 20px) * 1.5)); font-size: 80%; } @@ -47,7 +43,7 @@ } .xmodule_display.xmodule_PollBlock div.poll_question .poll_answer { - margin-bottom: var(--baseline); + margin-bottom: var(--baseline, 20px); } .xmodule_display.xmodule_PollBlock div.poll_question .poll_answer.short { @@ -109,7 +105,7 @@ font-weight: bold; letter-spacing: normal; line-height: 25.59375px; - margin-bottom: calc((var(--baseline) * 0.75)); + margin-bottom: calc((var(--baseline, 20px) * 0.75)); margin: 0; padding: 0px; text-align: center; @@ -145,9 +141,9 @@ width: 80%; text-align: left; min-height: 30px; - margin-left: var(--baseline); + margin-left: var(--baseline, 20px); height: auto; - margin-bottom: var(--baseline); + margin-bottom: var(--baseline, 20px); } .xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .question .text.short { @@ -156,7 +152,7 @@ .xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats { min-height: 40px; - margin-top: var(--baseline); + margin-top: var(--baseline, 20px); clear: both; } @@ -174,7 +170,7 @@ border: 1px solid black; display: inline; float: left; - margin-right: calc((var(--baseline) / 2)); + margin-right: calc((var(--baseline, 20px) / 2)); } .xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats .bar.short { diff --git a/xmodule/static/css-builtin-blocks/ProblemBlockDisplay.css b/xmodule/static/css-builtin-blocks/ProblemBlockDisplay.css index 31ce58efe3..aa731796f4 100644 --- a/xmodule/static/css-builtin-blocks/ProblemBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/ProblemBlockDisplay.css @@ -1,11 +1,38 @@ @import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); -.xmodule_display.xmodule_ProblemBlock { - /* stylelint-disable-line */ - /* stylelint-disable-line */ -} - -.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input + label.choicegroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicegroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + label.choicetextgroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicetextgroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + section.choicetextgroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + section.choicetextgroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input + label.choicegroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicegroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + label.choicetextgroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicetextgroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + section.choicetextgroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + section.choicetextgroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input + label.choicegroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicegroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + label.choicetextgroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicetextgroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + section.choicetextgroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + section.choicetextgroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline > .incorrect .status .status-icon::after, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput > .incorrect .status .status-icon::after, .xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline > .partially-correct .status .status-icon::after, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput > .partially-correct .status .status-icon::after, .xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline > .correct .status .status-icon::after, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput > .correct .status .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .partially-correct .status-icon::after { +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input+label.choicegroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicegroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+label.choicetextgroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicetextgroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+section.choicetextgroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+section.choicetextgroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input+label.choicegroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicegroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+label.choicetextgroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicetextgroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+section.choicetextgroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+section.choicetextgroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input+label.choicegroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicegroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+label.choicetextgroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicetextgroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+section.choicetextgroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+section.choicetextgroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.incorrect .status .status-icon::after, +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.incorrect .status .status-icon::after, +.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.partially-correct .status .status-icon::after, +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.partially-correct .status .status-icon::after, +.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.correct .status .status-icon::after, +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.correct .status .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .partially-correct .status-icon::after { font-family: FontAwesome; -webkit-font-smoothing: antialiased; display: inline-block; @@ -14,7 +41,7 @@ .xmodule_display.xmodule_ProblemBlock h2 { margin-top: 0; - margin-bottom: calc((var(--baseline) * 0.75)); + margin-bottom: calc((var(--baseline, 20px) * 0.75)); } .xmodule_display.xmodule_ProblemBlock h2.problem-header { @@ -22,7 +49,7 @@ } .xmodule_display.xmodule_ProblemBlock h2.problem-header section.staff { - margin-top: calc((var(--baseline) * 1.5)); + margin-top: calc((var(--baseline, 20px) * 1.5)); font-size: 80%; } @@ -38,23 +65,25 @@ font-weight: bold; } -.xmodule_display.xmodule_ProblemBlock .feedback-hint-incorrect, .xmodule_display.xmodule_ProblemBlock .feedback-hint-partially-correct, +.xmodule_display.xmodule_ProblemBlock .feedback-hint-incorrect, +.xmodule_display.xmodule_ProblemBlock .feedback-hint-partially-correct, .xmodule_display.xmodule_ProblemBlock .feedback-hint-correct { - margin-top: calc((var(--baseline) / 4)); + margin-top: calc((var(--baseline, 20px) / 4)); } -.xmodule_display.xmodule_ProblemBlock .feedback-hint-incorrect .icon, .xmodule_display.xmodule_ProblemBlock .feedback-hint-partially-correct .icon, +.xmodule_display.xmodule_ProblemBlock .feedback-hint-incorrect .icon, +.xmodule_display.xmodule_ProblemBlock .feedback-hint-partially-correct .icon, .xmodule_display.xmodule_ProblemBlock .feedback-hint-correct .icon { - margin-right: calc((var(--baseline) / 4)); + margin-right: calc((var(--baseline, 20px) / 4)); } .xmodule_display.xmodule_ProblemBlock .feedback-hint-incorrect .icon { - color: var(--incorrect); + color: var(--incorrect, #b20610); } .xmodule_display.xmodule_ProblemBlock .feedback-hint-partially-correct .icon, .xmodule_display.xmodule_ProblemBlock .feedback-hint-correct .icon { - color: var(--correct); + color: var(--correct, #008100); } .xmodule_display.xmodule_ProblemBlock .feedback-hint-text { @@ -87,17 +116,17 @@ } .xmodule_display.xmodule_ProblemBlock .inline-error { - color: var(--error-color-dark); + color: var(--error-color-dark, #95050d); } .xmodule_display.xmodule_ProblemBlock div.problem-progress { display: inline-block; - color: var(--gray-d1); + color: var(--gray-d1, #5e5e5e); font-size: 0.875em; } .xmodule_display.xmodule_ProblemBlock div.problem { - padding-top: var(--baseline); + padding-top: var(--baseline, 20px); } @media print { @@ -121,110 +150,242 @@ display: inline; } -.xmodule_display.xmodule_ProblemBlock div.problem .inline + p { - margin-top: var(--baseline); +.xmodule_display.xmodule_ProblemBlock div.problem .inline+p { + margin-top: var(--baseline, 20px); } .xmodule_display.xmodule_ProblemBlock div.problem .question-description { - color: var(--gray-d1); - font-size: var(--small-font-size); + color: var(--gray-d1, #5e5e5e); + font-size: var(--small-font-size, 80%); } -.xmodule_display.xmodule_ProblemBlock div.problem form > label, .xmodule_display.xmodule_ProblemBlock div.problem .problem-group-label { +.xmodule_display.xmodule_ProblemBlock div.problem form>label, +.xmodule_display.xmodule_ProblemBlock div.problem .problem-group-label { display: block; - margin-bottom: var(--baseline); + margin-bottom: var(--baseline, 20px); font: inherit; color: inherit; -webkit-font-smoothing: initial; } -.xmodule_display.xmodule_ProblemBlock div.problem .problem-group-label + .question-description { - margin-top: calc(-1 * var(--baseline)); +.xmodule_display.xmodule_ProblemBlock div.problem .problem-group-label+.question-description { + margin-top: calc(-1 * var(--baseline, 20px)); } -.xmodule_display.xmodule_ProblemBlock .wrapper-problem-response + .wrapper-problem-response, -.xmodule_display.xmodule_ProblemBlock .wrapper-problem-response + p { - margin-top: calc((var(--baseline) * 1.5)); +.xmodule_display.xmodule_ProblemBlock .wrapper-problem-response+.wrapper-problem-response, +.xmodule_display.xmodule_ProblemBlock .wrapper-problem-response+p { + margin-top: calc((var(--baseline, 20px) * 1.5)); } -.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup { +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup { min-width: 100px; width: auto !important; width: 100px; } -.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup:after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup:after { +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup:after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup:after { content: ""; display: table; clear: both; } -.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup label, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup label { +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup label, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup label { box-sizing: border-box; display: inline-block; clear: both; - margin-bottom: calc((var(--baseline) / 2)); - border: 2px solid var(--gray-l4); + margin-bottom: calc((var(--baseline, 20px) / 2)); + border: 2px solid var(--gray-l4, #e4e4e4); border-radius: 3px; - padding: calc((var(--baseline) / 2)); + padding: calc((var(--baseline, 20px) / 2)); width: 100%; } -.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup label::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup label::after { - margin-left: calc((var(--baseline) * 0.75)); +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup label::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup label::after { + margin-left: calc((var(--baseline, 20px) * 0.75)); } -.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup .indicator-container, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .indicator-container { +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup .indicator-container, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .indicator-container { min-height: 1px; width: 25px; display: inline-block; } -.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup fieldset, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup fieldset { +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup fieldset, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup fieldset { box-sizing: border-box; } -.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input[type="radio"], .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input[type="radio"], .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input[type="checkbox"], .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input[type="checkbox"] { - margin: calc((var(--baseline) / 4)); - margin-right: calc((var(--baseline) / 2)); +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input[type="radio"], +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input[type="radio"], +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input[type="checkbox"], +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input[type="checkbox"] { + margin: calc((var(--baseline, 20px) / 4)); + margin-right: calc((var(--baseline, 20px) / 2)); } -.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:focus + label, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + label, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:hover + label, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + label { - border: 2px solid var(--blue); +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:focus+label, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+label, +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:hover+label, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label { + border: 2px solid var(--blue, #0075b4); } -.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input + label.choicegroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicegroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + label.choicetextgroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicetextgroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + section.choicetextgroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + section.choicetextgroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:focus + label.choicegroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + label.choicegroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus + label.choicetextgroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + label.choicetextgroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus + section.choicetextgroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + section.choicetextgroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:hover + label.choicegroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + label.choicegroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover + label.choicetextgroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + label.choicetextgroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover + section.choicetextgroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + section.choicetextgroup_correct { - border: 2px solid var(--correct); +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input+label.choicegroup_correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicegroup_correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+label.choicetextgroup_correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicetextgroup_correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+section.choicetextgroup_correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+section.choicetextgroup_correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:focus+label.choicegroup_correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+label.choicegroup_correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus+label.choicetextgroup_correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+label.choicetextgroup_correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus+section.choicetextgroup_correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+section.choicetextgroup_correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:hover+label.choicegroup_correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicegroup_correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+label.choicetextgroup_correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicetextgroup_correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+section.choicetextgroup_correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+section.choicetextgroup_correct { + border: 2px solid var(--correct, #008100); } -.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input + label.choicegroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicegroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + label.choicetextgroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicetextgroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + section.choicetextgroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + section.choicetextgroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:focus + label.choicegroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + label.choicegroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus + label.choicetextgroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + label.choicetextgroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus + section.choicetextgroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + section.choicetextgroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:hover + label.choicegroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + label.choicegroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover + label.choicetextgroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + label.choicetextgroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover + section.choicetextgroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + section.choicetextgroup_correct .status-icon::after { - color: var(--correct); +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input+label.choicegroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicegroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+label.choicetextgroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicetextgroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+section.choicetextgroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+section.choicetextgroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:focus+label.choicegroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+label.choicegroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus+label.choicetextgroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+label.choicetextgroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus+section.choicetextgroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+section.choicetextgroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:hover+label.choicegroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicegroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+label.choicetextgroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicetextgroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+section.choicetextgroup_correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+section.choicetextgroup_correct .status-icon::after { + color: var(--correct, #008100); font-size: 1.2em; content: ""; } -.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input + label.choicegroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicegroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + label.choicetextgroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicetextgroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + section.choicetextgroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + section.choicetextgroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:focus + label.choicegroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + label.choicegroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus + label.choicetextgroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + label.choicetextgroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus + section.choicetextgroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + section.choicetextgroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:hover + label.choicegroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + label.choicegroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover + label.choicetextgroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + label.choicetextgroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover + section.choicetextgroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + section.choicetextgroup_partially-correct { - border: 2px solid var(--partially-correct); +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input+label.choicegroup_partially-correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicegroup_partially-correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+label.choicetextgroup_partially-correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicetextgroup_partially-correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+section.choicetextgroup_partially-correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+section.choicetextgroup_partially-correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:focus+label.choicegroup_partially-correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+label.choicegroup_partially-correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus+label.choicetextgroup_partially-correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+label.choicetextgroup_partially-correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus+section.choicetextgroup_partially-correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+section.choicetextgroup_partially-correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:hover+label.choicegroup_partially-correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicegroup_partially-correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+label.choicetextgroup_partially-correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicetextgroup_partially-correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+section.choicetextgroup_partially-correct, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+section.choicetextgroup_partially-correct { + border: 2px solid var(--partially-correct, #008100); } -.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input + label.choicegroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicegroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + label.choicetextgroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicetextgroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + section.choicetextgroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + section.choicetextgroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:focus + label.choicegroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + label.choicegroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus + label.choicetextgroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + label.choicetextgroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus + section.choicetextgroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + section.choicetextgroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:hover + label.choicegroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + label.choicegroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover + label.choicetextgroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + label.choicetextgroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover + section.choicetextgroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + section.choicetextgroup_partially-correct .status-icon::after { - color: var(--partially-correct); +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input+label.choicegroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicegroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+label.choicetextgroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicetextgroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+section.choicetextgroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+section.choicetextgroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:focus+label.choicegroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+label.choicegroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus+label.choicetextgroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+label.choicetextgroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus+section.choicetextgroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+section.choicetextgroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:hover+label.choicegroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicegroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+label.choicetextgroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicetextgroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+section.choicetextgroup_partially-correct .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+section.choicetextgroup_partially-correct .status-icon::after { + color: var(--partially-correct, #008100); font-size: 1.2em; content: ""; } -.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input + label.choicegroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicegroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + label.choicetextgroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicetextgroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + section.choicetextgroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + section.choicetextgroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:focus + label.choicegroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + label.choicegroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus + label.choicetextgroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + label.choicetextgroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus + section.choicetextgroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + section.choicetextgroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:hover + label.choicegroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + label.choicegroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover + label.choicetextgroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + label.choicetextgroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover + section.choicetextgroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + section.choicetextgroup_incorrect { - border: 2px solid var(--incorrect); +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input+label.choicegroup_incorrect, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicegroup_incorrect, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+label.choicetextgroup_incorrect, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicetextgroup_incorrect, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+section.choicetextgroup_incorrect, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+section.choicetextgroup_incorrect, +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:focus+label.choicegroup_incorrect, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+label.choicegroup_incorrect, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus+label.choicetextgroup_incorrect, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+label.choicetextgroup_incorrect, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus+section.choicetextgroup_incorrect, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+section.choicetextgroup_incorrect, +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:hover+label.choicegroup_incorrect, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicegroup_incorrect, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+label.choicetextgroup_incorrect, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicetextgroup_incorrect, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+section.choicetextgroup_incorrect, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+section.choicetextgroup_incorrect { + border: 2px solid var(--incorrect, #b20610); } -.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input + label.choicegroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicegroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + label.choicetextgroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicetextgroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + section.choicetextgroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + section.choicetextgroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:focus + label.choicegroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + label.choicegroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus + label.choicetextgroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + label.choicetextgroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus + section.choicetextgroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + section.choicetextgroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:hover + label.choicegroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + label.choicegroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover + label.choicetextgroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + label.choicetextgroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover + section.choicetextgroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + section.choicetextgroup_incorrect .status-icon::after { - color: var(--incorrect); +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input+label.choicegroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicegroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+label.choicetextgroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicetextgroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+section.choicetextgroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+section.choicetextgroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:focus+label.choicegroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+label.choicegroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus+label.choicetextgroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+label.choicetextgroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus+section.choicetextgroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+section.choicetextgroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:hover+label.choicegroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicegroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+label.choicetextgroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicetextgroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+section.choicetextgroup_incorrect .status-icon::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+section.choicetextgroup_incorrect .status-icon::after { + color: var(--incorrect, #b20610); font-size: 1.2em; content: ""; } -.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input + label.choicegroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicegroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + label.choicetextgroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + label.choicetextgroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input + section.choicetextgroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input + section.choicetextgroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:focus + label.choicegroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + label.choicegroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus + label.choicetextgroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + label.choicetextgroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus + section.choicetextgroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus + section.choicetextgroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:hover + label.choicegroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + label.choicegroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover + label.choicetextgroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + label.choicetextgroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover + section.choicetextgroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover + section.choicetextgroup_submitted { - border: 2px solid var(--submitted); +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input+label.choicegroup_submitted, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicegroup_submitted, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+label.choicetextgroup_submitted, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+label.choicetextgroup_submitted, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input+section.choicetextgroup_submitted, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input+section.choicetextgroup_submitted, +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:focus+label.choicegroup_submitted, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+label.choicegroup_submitted, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus+label.choicetextgroup_submitted, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+label.choicetextgroup_submitted, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:focus+section.choicetextgroup_submitted, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+section.choicetextgroup_submitted, +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:hover+label.choicegroup_submitted, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicegroup_submitted, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+label.choicetextgroup_submitted, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicetextgroup_submitted, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+section.choicetextgroup_submitted, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+section.choicetextgroup_submitted { + border: 2px solid var(--submitted, #0075b4); } .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup .field { @@ -232,10 +393,10 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup label { - padding: calc((var(--baseline) / 2)); - padding-left: calc((var(--baseline) * 2.3)); + padding: calc((var(--baseline, 20px) / 2)); + padding-left: calc((var(--baseline, 20px) * 2.3)); position: relative; - font-size: var(--base-font-size); + font-size: var(--base-font-size, 18px); line-height: normal; cursor: pointer; } @@ -245,50 +406,52 @@ left: 0.5625em; position: absolute; top: 0.35em; - width: calc(var(--baseline) * 1.1); - height: calc(var(--baseline) * 1.1); + width: calc(var(--baseline, 20px) * 1.1); + height: calc(var(--baseline, 20px) * 1.1); z-index: 1; } .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup legend { - margin-bottom: var(--baseline); + margin-bottom: var(--baseline, 20px); max-width: 100%; white-space: normal; } -.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup legend + .question-description { - margin-top: calc(-1 * var(--baseline)); +.xmodule_display.xmodule_ProblemBlock div.problem .choicegroup legend+.question-description { + margin-top: calc(-1 * var(--baseline, 20px)); max-width: 100%; white-space: normal; } .xmodule_display.xmodule_ProblemBlock div.problem .indicator-container { - margin-left: calc((var(--baseline) * 0.75)); + margin-left: calc((var(--baseline, 20px) * 0.75)); } .xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status { - width: var(--baseline); + width: var(--baseline, 20px); } .xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.correct .status-icon::after { - color: var(--correct); + color: var(--correct, #008100); font-size: 1.2em; content: ""; } .xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.partially-correct .status-icon::after { - color: var(--partially-correct); + color: var(--partially-correct, #008100); font-size: 1.2em; content: ""; } .xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.incorrect .status-icon::after { - color: var(--incorrect); + color: var(--incorrect, #b20610); font-size: 1.2em; content: ""; } -.xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.submitted .status-icon, .xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.unsubmitted .status-icon, .xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.unanswered .status-icon { +.xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.submitted .status-icon, +.xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.unsubmitted .status-icon, +.xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.unanswered .status-icon { content: ''; } @@ -299,22 +462,22 @@ content: " "; } -.xmodule_display.xmodule_ProblemBlock div.problem .solution-span > span { - margin: var(--baseline) 0; +.xmodule_display.xmodule_ProblemBlock div.problem .solution-span>span { + margin: var(--baseline, 20px) 0; display: block; position: relative; } -.xmodule_display.xmodule_ProblemBlock div.problem .solution-span > span:empty { +.xmodule_display.xmodule_ProblemBlock div.problem .solution-span>span:empty { display: none; } -.xmodule_display.xmodule_ProblemBlock div.problem .targeted-feedback-span > span { +.xmodule_display.xmodule_ProblemBlock div.problem .targeted-feedback-span>span { display: block; position: relative; } -.xmodule_display.xmodule_ProblemBlock div.problem .targeted-feedback-span > span:empty { +.xmodule_display.xmodule_ProblemBlock div.problem .targeted-feedback-span>span:empty { display: none; } @@ -327,15 +490,17 @@ } .xmodule_display.xmodule_ProblemBlock div.problem div p span.clarification i:hover { - color: var(--blue); + color: var(--blue, #0075b4); } -.xmodule_display.xmodule_ProblemBlock div.problem div.correct input, .xmodule_display.xmodule_ProblemBlock div.problem div.ui-icon-check input { - border-color: var(--correct); +.xmodule_display.xmodule_ProblemBlock div.problem div.correct input, +.xmodule_display.xmodule_ProblemBlock div.problem div.ui-icon-check input { + border-color: var(--correct, #008100); } -.xmodule_display.xmodule_ProblemBlock div.problem div.partially-correct input, .xmodule_display.xmodule_ProblemBlock div.problem div.ui-icon-check input { - border-color: var(--partially-correct); +.xmodule_display.xmodule_ProblemBlock div.problem div.partially-correct input, +.xmodule_display.xmodule_ProblemBlock div.problem div.ui-icon-check input { + border-color: var(--partially-correct, #008100); } .xmodule_display.xmodule_ProblemBlock div.problem div.processing input { @@ -343,20 +508,22 @@ } .xmodule_display.xmodule_ProblemBlock div.problem div.ui-icon-close input { - border-color: var(--incorrect); + border-color: var(--incorrect, #b20610); } -.xmodule_display.xmodule_ProblemBlock div.problem div.incorrect input, .xmodule_display.xmodule_ProblemBlock div.problem div.incomplete input { - border-color: var(--incorrect); +.xmodule_display.xmodule_ProblemBlock div.problem div.incorrect input, +.xmodule_display.xmodule_ProblemBlock div.problem div.incomplete input { + border-color: var(--incorrect, #b20610); } -.xmodule_display.xmodule_ProblemBlock div.problem div.submitted input, .xmodule_display.xmodule_ProblemBlock div.problem div.ui-icon-check input { - border-color: var(--submitted); +.xmodule_display.xmodule_ProblemBlock div.problem div.submitted input, +.xmodule_display.xmodule_ProblemBlock div.problem div.ui-icon-check input { + border-color: var(--submitted, #0075b4); } .xmodule_display.xmodule_ProblemBlock div.problem div p.answer { display: inline-block; - margin-top: calc((var(--baseline) / 2)); + margin-top: calc((var(--baseline, 20px) / 2)); margin-bottom: 0; } @@ -379,7 +546,7 @@ } .xmodule_display.xmodule_ProblemBlock div.problem div div.equation img.loading { - padding-left: calc((var(--baseline) / 2)); + padding-left: calc((var(--baseline, 20px) / 2)); display: inline-block; } @@ -388,7 +555,9 @@ display: inline-block; } -.xmodule_display.xmodule_ProblemBlock div.problem div div.equation span.MathJax_CHTML, .xmodule_display.xmodule_ProblemBlock div.problem div div.equation span.MathJax, .xmodule_display.xmodule_ProblemBlock div.problem div div.equation span.MathJax_SVG { +.xmodule_display.xmodule_ProblemBlock div.problem div div.equation span.MathJax_CHTML, +.xmodule_display.xmodule_ProblemBlock div.problem div div.equation span.MathJax, +.xmodule_display.xmodule_ProblemBlock div.problem div div.equation span.MathJax_SVG { padding: 6px; min-width: 30px; border: 1px solid #e3e3e3; @@ -408,16 +577,17 @@ top: 4px; width: 14px; height: 14px; - background: url("var(--static-path)/images/unanswered-icon.png") center center no-repeat; + background: var(--icon-unanswered) center center no-repeat; } -.xmodule_display.xmodule_ProblemBlock div.problem div span.processing, .xmodule_display.xmodule_ProblemBlock div.problem div span.ui-icon-processing { +.xmodule_display.xmodule_ProblemBlock div.problem div span.processing, +.xmodule_display.xmodule_ProblemBlock div.problem div span.ui-icon-processing { display: inline-block; position: relative; top: 6px; width: 25px; height: 20px; - background: url("var(--static-path)/images/spinner.gif") center center no-repeat; + background: var(--icon-spinner) center center no-repeat; } .xmodule_display.xmodule_ProblemBlock div.problem div span.ui-icon-check { @@ -426,28 +596,29 @@ top: 3px; width: 25px; height: 20px; - background: url("var(--static-path)/images/correct-icon.png") center center no-repeat; + background: var(--icon-correct) center center no-repeat; } -.xmodule_display.xmodule_ProblemBlock div.problem div span.incomplete, .xmodule_display.xmodule_ProblemBlock div.problem div span.ui-icon-close { +.xmodule_display.xmodule_ProblemBlock div.problem div span.incomplete, +.xmodule_display.xmodule_ProblemBlock div.problem div span.ui-icon-close { display: inline-block; position: relative; top: 3px; width: 20px; height: 20px; - background: url("var(--static-path)/images/incorrect-icon.png") center center no-repeat; + background: var(--icon-incorrect) center center no-repeat; } .xmodule_display.xmodule_ProblemBlock div.problem div .reload { float: right; - margin: calc((var(--baseline) / 2)); + margin: calc((var(--baseline, 20px) / 2)); } .xmodule_display.xmodule_ProblemBlock div.problem div .grader-status { - margin: calc(var(--baseline) / 2) 0; - padding: calc(var(--baseline) / 2); + margin: calc(var(--baseline, 20px) / 2) 0; + padding: calc(var(--baseline, 20px) / 2); border-radius: 5px; - background: var(--gray-l6); + background: var(--gray-l6, #f8f8f8); } .xmodule_display.xmodule_ProblemBlock div.problem div .grader-status:after { @@ -467,7 +638,7 @@ .xmodule_display.xmodule_ProblemBlock div.problem div .grader-status .grading { margin: 0px 7px 0 0; padding-left: 25px; - background: url("var(--static-path)/images/info-icon.png") left center no-repeat; + background: var(--icon-info) left center no-repeat; text-indent: 0px; } @@ -479,11 +650,11 @@ } .xmodule_display.xmodule_ProblemBlock div.problem div .grader-status.file { - margin-top: var(--baseline); - padding: var(--baseline) 0 0 0; + margin-top: var(--baseline, 20px); + padding: var(--baseline, 20px) 0 0 0; border: 0; border-top: 1px solid #eee; - background: var(--white); + background: var(--white, #fff); } .xmodule_display.xmodule_ProblemBlock div.problem div .grader-status.file p.debug { @@ -495,11 +666,11 @@ } .xmodule_display.xmodule_ProblemBlock div.problem div .evaluation p { - margin-bottom: calc((var(--baseline) / 5)); + margin-bottom: calc((var(--baseline, 20px) / 5)); } .xmodule_display.xmodule_ProblemBlock div.problem div .feedback-on-feedback { - margin-right: var(--baseline); + margin-right: var(--baseline, 20px); height: 100px; } @@ -530,10 +701,10 @@ } .xmodule_display.xmodule_ProblemBlock div.problem div .submit-message-container { - margin: var(--baseline) 0px; + margin: var(--baseline, 20px) 0px; } -.xmodule_display.xmodule_ProblemBlock div.problem div.inline > span { +.xmodule_display.xmodule_ProblemBlock div.problem div.inline>span { display: inline; } @@ -583,15 +754,18 @@ table-layout: auto; } -.xmodule_display.xmodule_ProblemBlock div.problem table td.cont-justified-left, .xmodule_display.xmodule_ProblemBlock div.problem table th.cont-justified-left { +.xmodule_display.xmodule_ProblemBlock div.problem table td.cont-justified-left, +.xmodule_display.xmodule_ProblemBlock div.problem table th.cont-justified-left { text-align: left !important; } -.xmodule_display.xmodule_ProblemBlock div.problem table td.cont-justified-right, .xmodule_display.xmodule_ProblemBlock div.problem table th.cont-justified-right { +.xmodule_display.xmodule_ProblemBlock div.problem table td.cont-justified-right, +.xmodule_display.xmodule_ProblemBlock div.problem table th.cont-justified-right { text-align: right !important; } -.xmodule_display.xmodule_ProblemBlock div.problem table td.cont-justified-center, .xmodule_display.xmodule_ProblemBlock div.problem table th.cont-justified-center { +.xmodule_display.xmodule_ProblemBlock div.problem table td.cont-justified-center, +.xmodule_display.xmodule_ProblemBlock div.problem table th.cont-justified-center { text-align: center !important; } @@ -603,7 +777,9 @@ text-align: left; } -.xmodule_display.xmodule_ProblemBlock div.problem table caption, .xmodule_display.xmodule_ProblemBlock div.problem table th, .xmodule_display.xmodule_ProblemBlock div.problem table td { +.xmodule_display.xmodule_ProblemBlock div.problem table caption, +.xmodule_display.xmodule_ProblemBlock div.problem table th, +.xmodule_display.xmodule_ProblemBlock div.problem table td { padding: .25em .75em .25em 0; padding: .25rem .75rem .25rem 0; } @@ -616,7 +792,9 @@ background: #f1f1f1; } -.xmodule_display.xmodule_ProblemBlock div.problem table tr, .xmodule_display.xmodule_ProblemBlock div.problem table td, .xmodule_display.xmodule_ProblemBlock div.problem table th { +.xmodule_display.xmodule_ProblemBlock div.problem table tr, +.xmodule_display.xmodule_ProblemBlock div.problem table td, +.xmodule_display.xmodule_ProblemBlock div.problem table th { vertical-align: middle; } @@ -625,22 +803,22 @@ padding: 0px 5px; border: 1px solid #eaeaea; border-radius: 3px; - background-color: var(--gray-l6); + background-color: var(--gray-l6, #f8f8f8); white-space: nowrap; font-size: .9em; } .xmodule_display.xmodule_ProblemBlock div.problem pre { overflow: auto; - padding: 6px calc(var(--baseline) / 2); - border: 1px solid var(--gray-l3); + padding: 6px calc(var(--baseline, 20px) / 2); + border: 1px solid var(--gray-l3, #c8c8c8); border-radius: 3px; - background-color: var(--gray-l6); + background-color: var(--gray-l6, #f8f8f8); font-size: .9em; line-height: 1.4; } -.xmodule_display.xmodule_ProblemBlock div.problem pre > code { +.xmodule_display.xmodule_ProblemBlock div.problem pre>code { margin: 0; padding: 0; border: none; @@ -648,74 +826,90 @@ white-space: pre; } -.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline input, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput input { +.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline input, +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput input { box-sizing: border-box; - border: 2px solid var(--gray-l4); + border: 2px solid var(--gray-l4, #e4e4e4); border-radius: 3px; min-width: 160px; height: 46px; } -.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline .status, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput .status { +.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline .status, +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput .status { display: inline-block; - margin-top: calc((var(--baseline) / 2)); + margin-top: calc((var(--baseline, 20px) / 2)); background: none; } -.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline > .incorrect input, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput > .incorrect input { - border: 2px solid var(--incorrect); +.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.incorrect input, +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.incorrect input { + border: 2px solid var(--incorrect, #b20610); } -.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline > .incorrect .status .status-icon::after, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput > .incorrect .status .status-icon::after { - color: var(--incorrect); +.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.incorrect .status .status-icon::after, +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.incorrect .status .status-icon::after { + color: var(--incorrect, #b20610); font-size: 1.2em; content: ""; } -.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline > .partially-correct input, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput > .partially-correct input { - border: 2px solid var(--partially-correct); +.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.partially-correct input, +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.partially-correct input { + border: 2px solid var(--partially-correct, #008100); } -.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline > .partially-correct .status .status-icon::after, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput > .partially-correct .status .status-icon::after { - color: var(--partially-correct); +.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.partially-correct .status .status-icon::after, +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.partially-correct .status .status-icon::after { + color: var(--partially-correct, #008100); font-size: 1.2em; content: ""; } -.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline > .correct input, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput > .correct input { - border: 2px solid var(--correct); +.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.correct input, +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.correct input { + border: 2px solid var(--correct, #008100); } -.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline > .correct .status .status-icon::after, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput > .correct .status .status-icon::after { - color: var(--correct); +.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.correct .status .status-icon::after, +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.correct .status .status-icon::after { + color: var(--correct, #008100); font-size: 1.2em; content: ""; } -.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline > .submitted input, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput > .submitted input { - border: 2px solid var(--submitted); +.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.submitted input, +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.submitted input { + border: 2px solid var(--submitted, #0075b4); } -.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline > .submitted .status, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput > .submitted .status { +.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.submitted .status, +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.submitted .status { content: ''; } -.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline > .unanswered input, .xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline > .unsubmitted input, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput > .unanswered input, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput > .unsubmitted input { - border: 2px solid var(--gray-l4); +.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.unanswered input, +.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.unsubmitted input, +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.unanswered input, +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.unsubmitted input { + border: 2px solid var(--gray-l4, #e4e4e4); } -.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline > .unanswered .status .status-icon::after, .xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline > .unsubmitted .status .status-icon::after, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput > .unanswered .status .status-icon::after, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput > .unsubmitted .status .status-icon::after { +.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.unanswered .status .status-icon::after, +.xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.unsubmitted .status .status-icon::after, +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.unanswered .status .status-icon::after, +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.unsubmitted .status .status-icon::after { content: ''; display: inline-block; } -.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput > div input { +.xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>div input { direction: ltr; text-align: left; } .xmodule_display.xmodule_ProblemBlock .problem .trailing_text { - margin-right: calc((var(--baseline) / 2)); + margin-right: calc((var(--baseline, 20px) / 2)); display: inline-block; } @@ -767,7 +961,7 @@ visibility: hidden; width: 0; border-right: none; - border-left: 1px solid var(--black); + border-left: 1px solid var(--black, #000); } .xmodule_display.xmodule_ProblemBlock div.problem .CodeMirror-focused pre.CodeMirror-cursor { @@ -786,12 +980,12 @@ .xmodule_display.xmodule_ProblemBlock .capa-message { display: inline-block; - color: var(--gray-d1); + color: var(--gray-d1, #5e5e5e); -webkit-font-smoothing: antialiased; } .xmodule_display.xmodule_ProblemBlock div.problem .action { - min-height: var(--baseline); + min-height: var(--baseline, 20px); width: 100%; display: flex; display: -ms-flexbox; @@ -805,11 +999,11 @@ display: inline-flex; justify-content: flex-end; width: 100%; - padding-bottom: var(--baseline); + padding-bottom: var(--baseline, 20px); } .xmodule_display.xmodule_ProblemBlock div.problem .action .problem-action-button-wrapper { - border-right: 1px solid var(--gray-300); + border-right: 1px solid var(--gray-300, #d9d9d9); padding: 0 13px; display: inline-block; } @@ -824,12 +1018,14 @@ max-width: 110px; } -.xmodule_display.xmodule_ProblemBlock div.problem .action .problem-action-btn:hover, .xmodule_display.xmodule_ProblemBlock div.problem .action .problem-action-btn:focus, .xmodule_display.xmodule_ProblemBlock div.problem .action .problem-action-btn:active { - color: var(--primary) !important; +.xmodule_display.xmodule_ProblemBlock div.problem .action .problem-action-btn:hover, +.xmodule_display.xmodule_ProblemBlock div.problem .action .problem-action-btn:focus, +.xmodule_display.xmodule_ProblemBlock div.problem .action .problem-action-btn:active { + color: var(--primary, #0075b4) !important; } .xmodule_display.xmodule_ProblemBlock div.problem .action .problem-action-btn .icon { - margin-bottom: calc(var(--baseline) / 10); + margin-bottom: calc(var(--baseline, 20px) / 10); display: block; } @@ -840,42 +1036,42 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .action .submit-attempt-container { - padding-bottom: var(--baseline); + padding-bottom: var(--baseline, 20px); flex-grow: 1; display: flex; align-items: center; } -@media (max-width: var(--bp-screen-lg)) { +@media (max-width: var(--bp-screen-lg, 1024px)) { .xmodule_display.xmodule_ProblemBlock div.problem .action .submit-attempt-container { max-width: 100%; - padding-bottom: var(--baseline); + padding-bottom: var(--baseline, 20px); } } .xmodule_display.xmodule_ProblemBlock div.problem .action .submit-attempt-container .submit { - margin-right: calc((var(--baseline) / 2)); + margin-right: calc((var(--baseline, 20px) / 2)); float: left; white-space: nowrap; } .xmodule_display.xmodule_ProblemBlock div.problem .action .submit-attempt-container .submit-cta-description { - color: var(--primary); + color: var(--primary, #0075b4); font-size: small; - padding-right: calc(var(--baseline) / 2); + padding-right: calc(var(--baseline, 20px) / 2); } .xmodule_display.xmodule_ProblemBlock div.problem .action .submit-attempt-container .submit-cta-link-button { - color: var(--primary); - padding-right: calc(var(--baseline) / 4); + color: var(--primary, #0075b4); + padding-right: calc(var(--baseline, 20px) / 4); } .xmodule_display.xmodule_ProblemBlock div.problem .action .submission-feedback { - margin-right: calc((var(--baseline) / 2)); - margin-top: calc(var(--baseline) / 2); + margin-right: calc((var(--baseline, 20px) / 2)); + margin-top: calc(var(--baseline, 20px) / 2); display: inline-block; - color: var(--gray-d1); - font-size: var(--medium-font-size); + color: var(--gray-d1, #5e5e5e); + font-size: var(--medium-font-size, 0.9em); -webkit-font-smoothing: antialiased; vertical-align: middle; } @@ -900,119 +1096,111 @@ visibility: hidden; } -.xmodule_display.xmodule_ProblemBlock div.problem - -var -( ---all-text-inputs - -) -{ - display: inline -; - width: auto -; +.xmodule_display.xmodule_ProblemBlock div.problem var (--all-text-inputs) { + display: inline; + width: auto; } + .xmodule_display.xmodule_ProblemBlock div.problem center { display: block; margin: lh() 0; padding: lh(); - border: 1px solid var(--gray-l3); + border: 1px solid var(--gray-l3, #c8c8c8); } .xmodule_display.xmodule_ProblemBlock div.problem .message { font-size: inherit; } -.xmodule_display.xmodule_ProblemBlock div.problem .detailed-solution > p { +.xmodule_display.xmodule_ProblemBlock div.problem .detailed-solution>p { margin: 0; } -.xmodule_display.xmodule_ProblemBlock div.problem .detailed-solution > p:first-child { +.xmodule_display.xmodule_ProblemBlock div.problem .detailed-solution>p:first-child { margin-bottom: 0; } -.xmodule_display.xmodule_ProblemBlock div.problem .detailed-targeted-feedback > p, -.xmodule_display.xmodule_ProblemBlock div.problem .detailed-targeted-feedback-partially-correct > p, -.xmodule_display.xmodule_ProblemBlock div.problem .detailed-targeted-feedback-correct > p { +.xmodule_display.xmodule_ProblemBlock div.problem .detailed-targeted-feedback>p, +.xmodule_display.xmodule_ProblemBlock div.problem .detailed-targeted-feedback-partially-correct>p, +.xmodule_display.xmodule_ProblemBlock div.problem .detailed-targeted-feedback-correct>p { margin: 0; font-weight: normal; } .xmodule_display.xmodule_ProblemBlock div.problem div.capa_alert { - margin-top: var(--baseline); + margin-top: var(--baseline, 20px); padding: 8px 12px; - border: 1px solid var(--warning-color); + border: 1px solid var(--warning-color, #ffc01f); border-radius: 3px; - background: var(--warning-color-accent); + background: var(--warning-color-accent, #fffcdd); font-size: 0.9em; } .xmodule_display.xmodule_ProblemBlock div.problem .notification { float: left; - margin-top: calc(var(--baseline) / 2); - padding: calc((var(--baseline) / 2.5)) calc((var(--baseline) / 2)) calc((var(--baseline) / 5)) calc((var(--baseline) / 2)); - line-height: var(--base-line-height); + margin-top: calc(var(--baseline, 20px) / 2); + padding: calc((var(--baseline, 20px) / 2.5)) calc((var(--baseline, 20px) / 2)) calc((var(--baseline, 20px) / 5)) calc((var(--baseline, 20px) / 2)); + line-height: var(--base-line-height, 1.5em); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.success { - border-top: 3px solid var(--success); + border-top: 3px solid var(--success, #008100); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.success .icon { margin-right: 15px; - color: var(--success); + color: var(--success, #008100); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.error { - border-top: 3px solid var(--danger); + border-top: 3px solid var(--danger, #b20610); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.error .icon { margin-right: 15px; - color: var(--danger); + color: var(--danger, #b20610); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.warning { - border-top: 3px solid var(--warning); + border-top: 3px solid var(--warning, #e2c01f); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.warning .icon { margin-right: 15px; - color: var(--warning); + color: var(--warning, #e2c01f); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.general { - border-top: 3px solid var(--general-color-accent); + border-top: 3px solid var(--general-color-accent, #0075b4); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.general .icon { margin-right: 15px; - color: var(--general-color-accent); + color: var(--general-color-accent, #0075b4); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.problem-hint { - border: 1px solid var(--uxpl-gray-background); + border: 1px solid var(--uxpl-gray-background, #d9d9d9); border-radius: 6px; } .xmodule_display.xmodule_ProblemBlock div.problem .notification.problem-hint .icon { - margin-right: calc(3 * var(--baseline) / 4); - color: var(--uxpl-gray-dark); + margin-right: calc(3 * var(--baseline, 20px) / 4); + color: var(--uxpl-gray-dark, #111111); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.problem-hint li { - color: var(--uxpl-gray-base); + color: var(--uxpl-gray-base, #414141); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.problem-hint li strong { - color: var(--uxpl-gray-dark); + color: var(--uxpl-gray-dark, #111111); } .xmodule_display.xmodule_ProblemBlock div.problem .notification .icon { float: left; position: relative; - top: calc(var(--baseline) / 5); + top: calc(var(--baseline, 20px) / 5); } .xmodule_display.xmodule_ProblemBlock div.problem .notification .notification-message { @@ -1028,7 +1216,7 @@ var } .xmodule_display.xmodule_ProblemBlock div.problem .notification .notification-message ol li:not(:last-child) { - margin-bottom: calc(var(--baseline) / 4); + margin-bottom: calc(var(--baseline, 20px) / 4); } .xmodule_display.xmodule_ProblemBlock div.problem .notification .notification-btn-wrapper { @@ -1037,14 +1225,14 @@ var .xmodule_display.xmodule_ProblemBlock div.problem .notification-btn { float: right; - padding: calc((var(--baseline) / 10)) calc((var(--baseline) / 4)); - min-width: calc((var(--baseline) * 3)); + padding: calc((var(--baseline, 20px) / 10)) calc((var(--baseline, 20px) / 4)); + min-width: calc((var(--baseline, 20px) * 3)); display: block; clear: both; } .xmodule_display.xmodule_ProblemBlock div.problem .notification-btn:first-child { - margin-bottom: calc(var(--baseline) / 4); + margin-bottom: calc(var(--baseline, 20px) / 4); } .xmodule_display.xmodule_ProblemBlock div.problem button:hover { @@ -1061,28 +1249,28 @@ var } .xmodule_display.xmodule_ProblemBlock div.problem button.btn-brand:hover { - background-color: var(--btn-brand-focus-background); + background-color: var(--btn-brand-focus-background, #065683); } .xmodule_display.xmodule_ProblemBlock div.problem .review-btn { - color: var(--blue); + color: var(--blue, #0075b4); } .xmodule_display.xmodule_ProblemBlock div.problem .review-btn.sr { - color: var(--blue); + color: var(--blue, #0075b4); } .xmodule_display.xmodule_ProblemBlock div.problem div.capa_reset { padding: 25px; - background-color: var(--error-color-light); - border: 1px solid var(--error-color); + background-color: var(--error-color-light, #f95861); + border: 1px solid var(--error-color, #cb0712); border-radius: 3px; font-size: 1em; - margin-top: calc(var(--baseline) / 2); - margin-bottom: calc(var(--baseline) / 2); + margin-top: calc(var(--baseline, 20px) / 2); + margin-bottom: calc(var(--baseline, 20px) / 2); } -.xmodule_display.xmodule_ProblemBlock div.problem .capa_reset > h2 { +.xmodule_display.xmodule_ProblemBlock div.problem .capa_reset>h2 { color: #a00; } @@ -1091,14 +1279,14 @@ var } .xmodule_display.xmodule_ProblemBlock div.problem .hints { - border: 1px solid var(--gray-l3); + border: 1px solid var(--gray-l3, #c8c8c8); } .xmodule_display.xmodule_ProblemBlock div.problem .hints h3 { padding: 9px; border-bottom: 1px solid #e3e3e3; background: #eee; - text-shadow: 0 1px 0 var(--white); + text-shadow: 0 1px 0 var(--white, #fff); font-size: 1em; } @@ -1117,11 +1305,11 @@ var .xmodule_display.xmodule_ProblemBlock div.problem .hints div header a { display: block; padding: 9px; - background: var(--gray-l6); - box-shadow: inset 0 0 0 1px var(--white); + background: var(--gray-l6, #f8f8f8); + box-shadow: inset 0 0 0 1px var(--white, #fff); } -.xmodule_display.xmodule_ProblemBlock div.problem .hints div > section { +.xmodule_display.xmodule_ProblemBlock div.problem .hints div>section { padding: 9px; } @@ -1139,25 +1327,25 @@ var font-size: 0.9em; } -.xmodule_display.xmodule_ProblemBlock div.problem .test > section { +.xmodule_display.xmodule_ProblemBlock div.problem .test>section { position: relative; - margin-bottom: calc((var(--baseline) / 2)); - padding: 9px 9px var(--baseline); + margin-bottom: calc((var(--baseline, 20px) / 2)); + padding: 9px 9px var(--baseline, 20px); border: 1px solid #ddd; border-radius: 3px; - background: var(--white); + background: var(--white, #fff); box-shadow: inset 0 0 0 1px #eee; } -.xmodule_display.xmodule_ProblemBlock div.problem .test > section p:last-of-type { +.xmodule_display.xmodule_ProblemBlock div.problem .test>section p:last-of-type { margin-bottom: 0; } -.xmodule_display.xmodule_ProblemBlock div.problem .test > section .shortform { +.xmodule_display.xmodule_ProblemBlock div.problem .test>section .shortform { margin-bottom: .6em; } -.xmodule_display.xmodule_ProblemBlock div.problem .test > section a.full { +.xmodule_display.xmodule_ProblemBlock div.problem .test>section a.full { position: absolute; top: 0; right: 0; @@ -1165,13 +1353,13 @@ var left: 0; box-sizing: border-box; display: block; - padding: calc((var(--baseline) / 5)); - background: var(--gray-l4); + padding: calc((var(--baseline, 20px) / 5)); + background: var(--gray-l4, #e4e4e4); text-align: right; font-size: 1em; } -.xmodule_display.xmodule_ProblemBlock div.problem .test > section a.full.full-top { +.xmodule_display.xmodule_ProblemBlock div.problem .test>section a.full.full-top { position: absolute; top: 1px; right: 0; @@ -1179,7 +1367,7 @@ var left: 0; } -.xmodule_display.xmodule_ProblemBlock div.problem .test > section a.full.full-bottom { +.xmodule_display.xmodule_ProblemBlock div.problem .test>section a.full.full-bottom { position: absolute; top: auto; right: 0; @@ -1188,8 +1376,8 @@ var } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section { - padding-top: calc((var(--baseline) * 1.5)); - padding-left: var(--baseline); + padding-top: calc((var(--baseline, 20px) * 1.5)); + padding-left: var(--baseline, 20px); background-color: #fafafa; color: #2c2c2c; font-size: 1em; @@ -1206,9 +1394,9 @@ var } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-errors { - margin: calc((var(--baseline) / 4)); - padding: calc((var(--baseline) / 2)) calc((var(--baseline) / 2)) calc((var(--baseline) / 2)) calc((var(--baseline) * 2)); - background: url("var(--static-path)/images/incorrect-icon.png") center left no-repeat; + margin: calc((var(--baseline, 20px) / 4)); + padding: calc((var(--baseline, 20px) / 2)) calc((var(--baseline, 20px) / 2)) calc((var(--baseline, 20px) / 2)) calc((var(--baseline, 20px) * 2)); + background: var(--icon-incorrect) center left no-repeat; } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-errors li { @@ -1216,10 +1404,10 @@ var } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-output { - margin: calc(var(--baseline) / 4); - padding: var(--baseline) 0 calc((var(--baseline) * 0.75)) 50px; + margin: calc(var(--baseline, 20px) / 4); + padding: var(--baseline, 20px) 0 calc((var(--baseline, 20px) * 0.75)) 50px; border-top: 1px solid #ddd; - border-left: var(--baseline) solid #fafafa; + border-left: var(--baseline, 20px) solid #fafafa; } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-output h4 { @@ -1232,7 +1420,7 @@ var } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-output dt { - margin-top: var(--baseline); + margin-top: var(--baseline, 20px); } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-output dd { @@ -1240,7 +1428,7 @@ var } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-correct { - background: url("var(--static-path)/images/correct-icon.png") left 20px no-repeat; + background: var(--icon-correct) left 20px no-repeat; } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-correct .result-actual-output { @@ -1248,7 +1436,7 @@ var } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-partially-correct { - background: url("var(--static-path)/images/partially-correct-icon.png") left 20px no-repeat; + background: var(--icon-partially-correct) left 20px no-repeat; } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-partially-correct .result-actual-output { @@ -1256,7 +1444,7 @@ var } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-incorrect { - background: url("var(--static-path)/images/incorrect-icon.png") left 20px no-repeat; + background: var(--icon-incorrect) left 20px no-repeat; } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-incorrect .result-actual-output { @@ -1264,8 +1452,8 @@ var } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .markup-text { - margin: calc((var(--baseline) / 4)); - padding: var(--baseline) 0 15px 50px; + margin: calc((var(--baseline, 20px) / 4)); + padding: var(--baseline, 20px) 0 15px 50px; border-top: 1px solid #ddd; border-left: 20px solid #fafafa; } @@ -1279,19 +1467,19 @@ var } .xmodule_display.xmodule_ProblemBlock div.problem .rubric tr { - margin: calc((var(--baseline) / 2)) 0; + margin: calc((var(--baseline, 20px) / 2)) 0; height: 100%; } .xmodule_display.xmodule_ProblemBlock div.problem .rubric td { - margin: calc((var(--baseline) / 2)) 0; - padding: var(--baseline) 0; + margin: calc((var(--baseline, 20px) / 2)) 0; + padding: var(--baseline, 20px) 0; height: 100%; } .xmodule_display.xmodule_ProblemBlock div.problem .rubric th { - margin: calc((var(--baseline) / 4)); - padding: calc((var(--baseline) / 4)); + margin: calc((var(--baseline, 20px) / 4)); + padding: calc((var(--baseline, 20px) / 4)); } .xmodule_display.xmodule_ProblemBlock div.problem .rubric label, @@ -1299,12 +1487,12 @@ var position: relative; display: inline-block; margin: 3px; - padding: calc((var(--baseline) * 0.75)); + padding: calc((var(--baseline, 20px) * 0.75)); min-width: 50px; min-height: 50px; width: 150px; height: 100%; - background-color: var(--gray-l3); + background-color: var(--gray-l3, #c8c8c8); font-size: .9em; } @@ -1312,7 +1500,7 @@ var position: absolute; right: 0; bottom: 0; - margin: calc((var(--baseline) / 2)); + margin: calc((var(--baseline, 20px) / 2)); } .xmodule_display.xmodule_ProblemBlock div.problem .rubric .selected-grade { @@ -1320,7 +1508,7 @@ var color: white; } -.xmodule_display.xmodule_ProblemBlock div.problem .rubric input[type=radio]:checked + label { +.xmodule_display.xmodule_ProblemBlock div.problem .rubric input[type=radio]:checked+label { background: #666; color: white; } @@ -1331,14 +1519,14 @@ var .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input { margin: 0 0 1em 0; - border: 1px solid var(--gray-l3); + border: 1px solid var(--gray-l3, #c8c8c8); border-radius: 1em; /* for debugging the input value field. enable the debug flag on the inputtype */ } .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .annotation-header { padding: .5em 1em; - border-bottom: 1px solid var(--gray-l3); + border-bottom: 1px solid var(--gray-l3, #c8c8c8); } .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .annotation-body { @@ -1355,7 +1543,8 @@ var content: " \2191"; } -.xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .block, .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input ul.tags { +.xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .block, +.xmodule_display.xmodule_ProblemBlock div.problem .annotation-input ul.tags { margin: .5em 0; padding: 0; } @@ -1386,7 +1575,7 @@ var .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input ul.tags li .tag { display: inline-block; - margin-left: calc((var(--baseline) * 2)); + margin-left: calc((var(--baseline, 20px) * 2)); border: 1px solid #666666; } @@ -1399,7 +1588,8 @@ var left: 0; } -.xmodule_display.xmodule_ProblemBlock div.problem .annotation-input ul.tags li .tag-status, .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input ul.tags li .tag { +.xmodule_display.xmodule_ProblemBlock div.problem .annotation-input ul.tags li .tag-status, +.xmodule_display.xmodule_ProblemBlock div.problem .annotation-input ul.tags li .tag { padding: .25em .5em; } @@ -1418,9 +1608,9 @@ var .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .debug-value { margin: 1em 0; padding: 1em; - border: 1px solid var(--black); + border: 1px solid var(--black, #000); background-color: #999; - color: var(--white); + color: var(--white, #fff); } .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .debug-value input[type="text"] { @@ -1428,8 +1618,8 @@ var } .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .debug-value pre { - background-color: var(--gray-l3); - color: var(--black); + background-color: var(--gray-l3, #c8c8c8); + color: var(--black, #000); } .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .debug-value::before { @@ -1442,17 +1632,20 @@ var margin-bottom: 0.5em; } -.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup label.choicetextgroup_correct input[type="text"], .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup section.choicetextgroup_correct input[type="text"] { - border-color: var(--correct); +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup label.choicetextgroup_correct input[type="text"], +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup section.choicetextgroup_correct input[type="text"] { + border-color: var(--correct, #008100); } -.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup label.choicetextgroup_partially-correct input[type="text"], .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup section.choicetextgroup_partially-correct input[type="text"] { - border-color: var(--partially-correct); +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup label.choicetextgroup_partially-correct input[type="text"], +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup section.choicetextgroup_partially-correct input[type="text"] { + border-color: var(--partially-correct, #008100); } -.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup label.choicetextgroup_show_correct::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup section.choicetextgroup_show_correct::after { - margin-left: calc((var(--baseline) * 0.75)); - content: url("var(--static-path)/images/correct-icon.png"); +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup label.choicetextgroup_show_correct::after, +.xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup section.choicetextgroup_show_correct::after { + margin-left: calc((var(--baseline, 20px) * 0.75)); + content: var(--icon-correct); } .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup span.mock_label { @@ -1467,28 +1660,30 @@ var height: 20px; } -.xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .status.unsubmitted .status-icon, .xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .status.unanswered .status-icon { +.xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .status.unsubmitted .status-icon, +.xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .status.unanswered .status-icon { content: ''; } -.xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .status.unsubmitted .status-message, .xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .status.unanswered .status-message { +.xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .status.unsubmitted .status-message, +.xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .status.unanswered .status-message { display: none; } .xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .correct .status-icon::after { - color: var(--correct); + color: var(--correct, #008100); font-size: 1.2em; content: ""; } .xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .incorrect .status-icon::after { - color: var(--incorrect); + color: var(--incorrect, #b20610); font-size: 1.2em; content: ""; } .xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .partially-correct .status-icon::after { - color: var(--partially-correct); + color: var(--partially-correct, #008100); font-size: 1.2em; content: ""; } @@ -1505,28 +1700,30 @@ var height: 20px; } -.xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .tag-status.unsubmitted .status-icon, .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .tag-status.unanswered .status-icon { +.xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .tag-status.unsubmitted .status-icon, +.xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .tag-status.unanswered .status-icon { content: ''; } -.xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .tag-status.unsubmitted .status-message, .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .tag-status.unanswered .status-message { +.xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .tag-status.unsubmitted .status-message, +.xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .tag-status.unanswered .status-message { display: none; } .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .correct .status-icon::after { - color: var(--correct); + color: var(--correct, #008100); font-size: 1.2em; content: ""; } .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .incorrect .status-icon::after { - color: var(--incorrect); + color: var(--incorrect, #b20610); font-size: 1.2em; content: ""; } .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .partially-correct .status-icon::after { - color: var(--partially-correct); + color: var(--partially-correct, #008100); font-size: 1.2em; content: ""; } @@ -1537,5 +1734,5 @@ var .xmodule_display.xmodule_ProblemBlock .problems-wrapper .loading-spinner { text-align: center; - color: var(--gray-d1); + color: var(--gray-d1, #5e5e5e); } diff --git a/xmodule/static/css-builtin-blocks/ProblemBlockEditor.css b/xmodule/static/css-builtin-blocks/ProblemBlockEditor.css index 086a3ebd22..e766c8a3e7 100644 --- a/xmodule/static/css-builtin-blocks/ProblemBlockEditor.css +++ b/xmodule/static/css-builtin-blocks/ProblemBlockEditor.css @@ -1,8 +1,5 @@ @import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); -.xmodule_edit.xmodule_ProblemBlock { -} - .xmodule_edit.xmodule_ProblemBlock .ui-col-wide { width: 74.46809%; margin-right: 2.12766%; @@ -52,7 +49,7 @@ background-image: -webkit-linear-gradient(top, #d4dee8, #c9d5e2); background-image: linear-gradient(to bottom, #d4dee8, #c9d5e2); position: relative; - padding: calc(var(--baseline) / 4); + padding: calc(var(--baseline, 20px) / 4); border-bottom-color: #a5aaaf; } @@ -65,7 +62,7 @@ .xmodule_edit.xmodule_ProblemBlock .editor .editor-bar button { display: inline-block; float: left; - padding: 3px calc(var(--baseline) / 2) 5px; + padding: 3px calc(var(--baseline, 20px) / 2) 5px; margin-left: 7px; border: 0; border-radius: 2px; @@ -76,7 +73,8 @@ height: 21px; } -.xmodule_edit.xmodule_ProblemBlock .editor .editor-bar button:hover, .xmodule_edit.xmodule_ProblemBlock .editor .editor-bar button:focus { +.xmodule_edit.xmodule_ProblemBlock .editor .editor-bar button:hover, +.xmodule_edit.xmodule_ProblemBlock .editor .editor-bar button:focus { background: rgba(255, 255, 255, 0.5); } @@ -90,7 +88,7 @@ .xmodule_edit.xmodule_ProblemBlock .editor .editor-tabs li { float: left; - margin-right: calc(var(--baseline) / 4); + margin-right: calc(var(--baseline, 20px) / 4); } .xmodule_edit.xmodule_ProblemBlock .editor .editor-tabs li:last-child { @@ -103,9 +101,9 @@ padding: 7px 20px 3px; border: 1px solid #a5aaaf; border-radius: 3px 3px 0 0; - background-color: var(--transparent); - background-image: -webkit-linear-gradient(top, var(--transparent) 87%, rgba(0, 0, 0, 0.06)); - background-image: linear-gradient(to bottom, var(--transparent) 87%, rgba(0, 0, 0, 0.06)); + background-color: var(--transparent, transparent); + background-image: -webkit-linear-gradient(top, var(--transparent, transparent) 87%, rgba(0, 0, 0, 0.06)); + background-image: linear-gradient(to bottom, var(--transparent, transparent) 87%, rgba(0, 0, 0, 0.06)); background-color: #e5ecf3; font-size: 13px; color: #3c3c3c; @@ -113,8 +111,8 @@ } .xmodule_edit.xmodule_ProblemBlock .editor .editor-tabs .tab.current { - background: var(--white); - border-bottom-color: var(--white); + background: var(--white, #fff); + border-bottom-color: var(--white, #fff); } .xmodule_edit.xmodule_ProblemBlock .editor-bar .editor-tabs .advanced-toggle { @@ -122,21 +120,22 @@ margin-top: -4px; padding: 3px 9px; font-size: 12px; - color: var(--link-color); + color: var(--link-color, #1b6d99); } .xmodule_edit.xmodule_ProblemBlock .editor-bar .editor-tabs .advanced-toggle.current { - border: 1px solid var(--lightGrey) !important; + border: 1px solid var(--lightGrey, #edf1f5) !important; border-radius: 3px !important; - background: var(--lightGrey) !important; - color: var(--darkGrey) !important; + background: var(--lightGrey, #edf1f5) !important; + color: var(--darkGrey, #8891a1) !important; pointer-events: none; cursor: none; } -.xmodule_edit.xmodule_ProblemBlock .editor-bar .editor-tabs .advanced-toggle.current:hover, .xmodule_edit.xmodule_ProblemBlock .editor-bar .editor-tabs .advanced-toggle.current:focus { +.xmodule_edit.xmodule_ProblemBlock .editor-bar .editor-tabs .advanced-toggle.current:hover, +.xmodule_edit.xmodule_ProblemBlock .editor-bar .editor-tabs .advanced-toggle.current:focus { box-shadow: 0 0 0 0 !important; - background-color: var(--white); + background-color: var(--white, #fff); } .xmodule_edit.xmodule_ProblemBlock .simple-editor-cheatsheet { @@ -144,8 +143,8 @@ top: 41px; left: 70%; width: 0; - border-left: 1px solid var(--gray-l2); - background-color: var(--lightGrey); + border-left: 1px solid var(--gray-l2, #adadad); + background-color: var(--lightGrey, #edf1f5); overflow: hidden; } @@ -195,7 +194,7 @@ } .xmodule_edit.xmodule_ProblemBlock .simple-editor-cheatsheet .col.sample .icon { - height: calc(var(--baseline) * 1.5); + height: calc(var(--baseline, 20px) * 1.5); } .xmodule_edit.xmodule_ProblemBlock .simple-editor-cheatsheet pre { @@ -208,7 +207,7 @@ background: none; } -.xmodule_edit.xmodule_ProblemBlock .problem-editor .markdown-box + .CodeMirror { +.xmodule_edit.xmodule_ProblemBlock .problem-editor .markdown-box+.CodeMirror { padding: 10px; width: 69%; } @@ -218,5 +217,5 @@ width: 26px; height: 21px; vertical-align: middle; - color: var(--body-color); + color: var(--body-color, #313131); } diff --git a/xmodule/static/css-builtin-blocks/SequenceBlockDisplay.css b/xmodule/static/css-builtin-blocks/SequenceBlockDisplay.css index 1a40ca9f7c..53671879f6 100644 --- a/xmodule/static/css-builtin-blocks/SequenceBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/SequenceBlockDisplay.css @@ -1,12 +1,7 @@ @import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); -.xmodule_display.xmodule_SequenceBlock { - /* stylelint-disable-line */ - /* stylelint-disable-line */ -} - .xmodule_display.xmodule_SequenceBlock .block-link { - border-left: 1px solid var(--border-color); + border-left: 1px solid var(--border-color, #e7e7e7); display: block; } @@ -17,7 +12,7 @@ .xmodule_display.xmodule_SequenceBlock .topbar, .xmodule_display.xmodule_SequenceBlock .sequence-nav { - border-bottom: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color, #e7e7e7); } .xmodule_display.xmodule_SequenceBlock .topbar:after, @@ -37,7 +32,7 @@ .xmodule_display.xmodule_SequenceBlock .topbar a.block-link, .xmodule_display.xmodule_SequenceBlock .sequence-nav a.block-link { - border-left: 1px solid var(--border-color); + border-left: 1px solid var(--border-color, #e7e7e7); display: block; } @@ -64,7 +59,7 @@ } .xmodule_display.xmodule_SequenceBlock .sequence-nav { - margin: 0 auto var(--baseline); + margin: 0 auto var(--baseline, 20px); position: relative; border-bottom: none; z-index: 0; @@ -100,7 +95,7 @@ box-sizing: border-box; min-width: 40px; flex-grow: 1; - border-color: var(--border-color); + border-color: var(--border-color, #e7e7e7); border-width: 1px; border-top-style: solid; } @@ -117,7 +112,7 @@ padding: 0; display: block; text-align: center; - border-color: var(--border-color); + border-color: var(--border-color, #e7e7e7); border-width: 1px; border-bottom-style: solid; box-sizing: border-box; @@ -132,7 +127,7 @@ } .xmodule_display.xmodule_SequenceBlock .sequence-nav ol li button .fa-bookmark { - color: var(--link-color); + color: var(--link-color, #1b6d99); } .xmodule_display.xmodule_SequenceBlock .sequence-nav ol li button.seq_video .icon::before { @@ -155,14 +150,14 @@ text-align: left; margin-top: 12px; background: #333333; - color: var(--white); + color: var(--white, #fff); font-family: var(--font-family-sans-serif); line-height: lh(); right: 0; padding: 6px; position: absolute; top: 48px; - text-shadow: 0 -1px 0 var(--black); + text-shadow: 0 -1px 0 var(--black, #000); white-space: pre; pointer-events: none; } @@ -201,7 +196,7 @@ body.touch-based-device .xmodule_display.xmodule_SequenceBlock .sequence-nav ol text-shadow: none; background: none; background-color: #fff; - border-color: var(--border-color); + border-color: var(--border-color, #e7e7e7); box-shadow: none; font-size: inherit; font-weight: normal; @@ -218,7 +213,7 @@ body.touch-based-device .xmodule_display.xmodule_SequenceBlock .sequence-nav ol } .xmodule_display.xmodule_SequenceBlock .sequence-nav-button span:not(:last-child) { - padding-right: calc((var(--baseline) / 2)); + padding-right: calc((var(--baseline, 20px) / 2)); } } @@ -347,7 +342,7 @@ body.touch-based-device .xmodule_display.xmodule_SequenceBlock .sequence-nav ol .xmodule_display.xmodule_SequenceBlock .sequence-nav button:hover, .xmodule_display.xmodule_SequenceBlock .sequence-nav button:active, .xmodule_display.xmodule_SequenceBlock .sequence-nav button.active { - border-bottom: 3px solid var(--link-color); + border-bottom: 3px solid var(--link-color, #1b6d99); background-color: #fff; } diff --git a/xmodule/static/css-builtin-blocks/VideoBlockDisplay.css b/xmodule/static/css-builtin-blocks/VideoBlockDisplay.css index 93c4b6adce..66c0a90cad 100644 --- a/xmodule/static/css-builtin-blocks/VideoBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/VideoBlockDisplay.css @@ -1,19 +1,11 @@ @import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); .xmodule_display.xmodule_VideoBlock { - /* stylelint-disable-line */ - /* stylelint-disable-line */ - /* stylelint-disable-line */ - /* stylelint-disable-line */ - /* stylelint-disable-line */ - /* stylelint-disable-line */ + margin-bottom: calc((var(--baseline, 20px) * 1.5)); } -.xmodule_display.xmodule_VideoBlock { - margin-bottom: calc((var(--baseline) * 1.5)); -} - -.xmodule_display.xmodule_VideoBlock .is-hidden, .xmodule_display.xmodule_VideoBlock .video.closed .subtitles { +.xmodule_display.xmodule_VideoBlock .is-hidden, +.xmodule_display.xmodule_VideoBlock .video.closed .subtitles { display: none; } @@ -32,7 +24,9 @@ clear: both; } -.xmodule_display.xmodule_VideoBlock .video:focus, .xmodule_display.xmodule_VideoBlock .video:active, .xmodule_display.xmodule_VideoBlock .video:hover { +.xmodule_display.xmodule_VideoBlock .video:focus, +.xmodule_display.xmodule_VideoBlock .video:active, +.xmodule_display.xmodule_VideoBlock .video:hover { border: 0; } @@ -86,9 +80,8 @@ .xmodule_display.xmodule_VideoBlock .video .wrapper-video-bottom-section .wrapper-handouts, .xmodule_display.xmodule_VideoBlock .video .wrapper-video-bottom-section .branding, .xmodule_display.xmodule_VideoBlock .video .wrapper-video-bottom-section .wrapper-transcript-feedback { - flex: 1; - margin-top: var(--baseline); - padding-right: var(--baseline); + margin-top: var(--baseline, 20px); + padding-right: var(--baseline, 20px); vertical-align: top; } @@ -113,12 +106,16 @@ } .xmodule_display.xmodule_VideoBlock .video .wrapper-downloads .wrapper-download-transcripts .list-download-transcripts .transcript-option { + display: flex; + align-items: center; margin: 0; } -.xmodule_display.xmodule_VideoBlock .video .wrapper-downloads .wrapper-download-transcripts .list-download-transcripts .transcript-option a.btn, .xmodule_display.xmodule_VideoBlock .video .wrapper-downloads .wrapper-download-transcripts .list-download-transcripts .transcript-option a.btn-link { +.xmodule_display.xmodule_VideoBlock .video .wrapper-downloads .wrapper-download-transcripts .list-download-transcripts .transcript-option a.btn, +.xmodule_display.xmodule_VideoBlock .video .wrapper-downloads .wrapper-download-transcripts .list-download-transcripts .transcript-option a.btn-link { font-size: 16px !important; font-weight: unset; + padding-left: 4px; } .xmodule_display.xmodule_VideoBlock .video .wrapper-downloads .branding { @@ -130,14 +127,14 @@ left: -9999em; display: inline-block; vertical-align: middle; - color: var(--body-color); + color: var(--body-color, #313131); } .xmodule_display.xmodule_VideoBlock .video .wrapper-downloads .branding .brand-logo { display: inline-block; max-width: 100%; - max-height: calc((var(--baseline) * 2)); - padding: calc((var(--baseline) / 4)) 0; + max-height: calc((var(--baseline, 20px) * 2)); + padding: calc((var(--baseline, 20px) / 4)) 0; vertical-align: middle; } @@ -162,8 +159,8 @@ .xmodule_display.xmodule_VideoBlock .video .google-disclaimer { display: none; - margin-top: var(--baseline); - padding-right: var(--baseline); + margin-top: var(--baseline, 20px); + padding-right: var(--baseline, 20px); vertical-align: top; } @@ -224,7 +221,7 @@ } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .btn-play::after { - background: var(--white); + background: var(--white, #fff); position: absolute; width: 50%; height: 50%; @@ -247,31 +244,33 @@ } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible { - max-height: calc((var(--baseline) * 3)); - border-radius: calc((var(--baseline) / 5)); - padding: 8px calc((var(--baseline) / 2)) 8px calc((var(--baseline) * 1.5)); + max-height: calc((var(--baseline, 20px) * 3)); + border-radius: calc((var(--baseline, 20px) / 5)); + padding: 8px calc((var(--baseline, 20px) / 2)) 8px calc((var(--baseline, 20px) * 1.5)); background: rgba(0, 0, 0, 0.75); - color: var(--yellow); + color: var(--yellow, #e2c01f); } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible::before { position: absolute; display: inline-block; top: 50%; - left: var(--baseline); + left: var(--baseline, 20px); margin-top: -0.6em; font-family: 'FontAwesome'; content: "\f142"; - color: var(--white); + color: var(--white, #fff); opacity: 0.5; } -.xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible:hover, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible.is-dragging { +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible:hover, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible.is-dragging { background: black; cursor: move; } -.xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible:hover::before, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible.is-dragging::before { +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible:hover::before, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible.is-dragging::before { opacity: 1; } @@ -280,17 +279,17 @@ min-height: 158px; } -.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player > div { +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player>div { height: 100%; } -.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player > div.hidden { +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player>div.hidden { display: none; } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player .video-error, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player .video-hls-error { - padding: calc((var(--baseline) / 5)); + padding: calc((var(--baseline, 20px) / 5)); background: black; color: white !important; } @@ -327,7 +326,8 @@ } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls:hover ul, -.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls:hover div, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls:focus ul, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls:hover div, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls:focus ul, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls:focus div { opacity: 1; } @@ -338,18 +338,21 @@ margin: 0; border: 0; border-radius: 0; - padding: calc((var(--baseline) / 2)) calc((var(--baseline) / 1.5)); + padding: calc((var(--baseline, 20px) / 2)) calc((var(--baseline, 20px) / 1.5)); background: #282c2e; box-shadow: none; text-shadow: none; color: #cfd8dc; } -.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .control:hover, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .control:focus { +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .control:hover, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .control:focus { background: #171a1b; } -.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .control:active, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .is-active.control, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .active.control { +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .control:active, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .is-active.control, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .active.control { color: #0ea6ec; } @@ -370,7 +373,7 @@ left: 0; right: 0; z-index: 1; - height: calc((var(--baseline) / 4)); + height: calc((var(--baseline, 20px) / 4)); margin-left: 0; border: 1px solid #4f595d; border-radius: 0; @@ -401,17 +404,18 @@ transition: all 0.7s ease-in-out 0s; box-sizing: border-box; top: -1px; - height: calc((var(--baseline) / 4)); - width: calc((var(--baseline) / 4)); - margin-left: calc(-1 * (var(--baseline) / 8)); + height: calc((var(--baseline, 20px) / 4)); + width: calc((var(--baseline, 20px) / 4)); + margin-left: calc(-1 * (var(--baseline, 20px) / 8)); border: 1px solid #cb598d; - border-radius: calc((var(--baseline) / 5)); + border-radius: calc((var(--baseline, 20px) / 5)); padding: 0; background: #cb598d; box-shadow: none; } -.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .slider .ui-slider-handle:focus, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .slider .ui-slider-handle:hover { +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .slider .ui-slider-handle:focus, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .slider .ui-slider-handle:hover { background-color: #db8baf; border-color: #db8baf; } @@ -465,7 +469,7 @@ } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .speed-button:focus, -.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume > .control:focus, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume>.control:focus, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .add-fullscreen:focus, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .auto-advance:focus, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .quality-control:focus, @@ -481,7 +485,7 @@ transition: none; position: absolute; display: none; - bottom: calc((var(--baseline) * 2)); + bottom: calc((var(--baseline, 20px) * 2)); right: 0; width: 120px; margin: 0; @@ -513,7 +517,8 @@ white-space: nowrap; } -.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .speed-option:hover, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .speed-option:focus, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .speed-option:hover, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .speed-option:focus, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .control-lang:hover, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li .control-lang:focus { background-color: #4f595d; @@ -522,9 +527,9 @@ .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li.is-active .speed-option, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li.is-active .control-lang { - border-left: calc(var(--baseline) / 10) solid #0ea6ec; - font-weight: var(--font-bold); - color: #0ea6ec; + border-left: calc(var(--baseline, 20px) / 10) solid #90d7f9; + font-weight: var(--font-bold, 700); + color: #90d7f9; } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container.is-opened .menu { @@ -542,7 +547,7 @@ } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .speeds .speed-button .label { - padding: 0 calc((var(--baseline) / 3)) 0 0; + padding: 0 calc((var(--baseline, 20px) / 3)) 0 0; font-family: var(--font-family-sans-serif); color: #e7ecee; } @@ -567,8 +572,8 @@ } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .lang .language-menu { - width: var(--baseline); - padding: calc((var(--baseline) / 2)) 0; + width: var(--baseline, 20px); + padding: calc((var(--baseline, 20px) / 2)) 0; } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .lang.is-opened .control .icon { @@ -585,7 +590,7 @@ opacity: 1; } -.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume:not(:first-child) > a { +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume:not(:first-child)>a { border-left: none; } @@ -593,7 +598,7 @@ transition: none; display: none; position: absolute; - bottom: calc((var(--baseline) * 2)); + bottom: calc((var(--baseline, 20px) * 2)); right: 0; width: 41px; height: 120px; @@ -602,7 +607,7 @@ .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider { height: 100px; - width: calc((var(--baseline) / 4)); + width: calc((var(--baseline, 20px) / 4)); margin: 14px auto; box-sizing: border-box; border: 1px solid #4f595d; @@ -610,19 +615,20 @@ } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-handle { - transition: height var(--tmg-s2) ease-in-out 0s, width var(--tmg-s2) ease-in-out 0s; + transition: height var(--tmg-s2, 2s) ease-in-out 0s, width var(--tmg-s2, 2s) ease-in-out 0s; left: -5px; box-sizing: border-box; height: 13px; width: 13px; border: 1px solid #cb598d; - border-radius: calc((var(--baseline) / 5)); + border-radius: calc((var(--baseline, 20px) / 5)); padding: 0; background: #cb598d; box-shadow: none; } -.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-handle:hover, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-handle:focus { +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-handle:hover, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-handle:focus { background: #db8baf; border-color: #db8baf; } @@ -643,7 +649,8 @@ color: #0ea6ec; } -.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .quality-control.is-hidden, .xmodule_display.xmodule_VideoBlock .video.closed .video-wrapper .video-controls .secondary-controls .quality-control.subtitles { +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .quality-control.is-hidden, +.xmodule_display.xmodule_VideoBlock .video.closed .video-wrapper .video-controls .secondary-controls .quality-control.subtitles { display: none !important; } @@ -651,17 +658,17 @@ color: #0ea6ec; } -.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .lang > .hide-subtitles { +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .lang>.hide-subtitles { transition: none; } .xmodule_display.xmodule_VideoBlock .video .video-wrapper:hover .video-controls .slider { - height: calc((var(--baseline) / 1.5)); + height: calc((var(--baseline, 20px) / 1.5)); } .xmodule_display.xmodule_VideoBlock .video .video-wrapper:hover .video-controls .slider .ui-slider-handle { - height: calc((var(--baseline) / 1.5)); - width: calc((var(--baseline) / 1.5)); + height: calc((var(--baseline, 20px) / 1.5)); + width: calc((var(--baseline, 20px) / 1.5)); } .xmodule_display.xmodule_VideoBlock .video.video-fullscreen .closed-captions { @@ -715,7 +722,8 @@ outline-offset: -1px; } -.xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li:hover, .xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li:focus { +.xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li:hover, +.xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li:focus { text-decoration: underline; } @@ -762,7 +770,7 @@ bottom: 0; top: 0; width: 275px; - padding: 0 var(--baseline); + padding: 0 var(--baseline, 20px); display: none; } @@ -838,7 +846,7 @@ padding: lh(); box-sizing: border-box; transition: none; - background: var(--black); + background: var(--black, #000); visibility: visible; } @@ -847,7 +855,7 @@ } .xmodule_display.xmodule_VideoBlock .video.video-fullscreen .subtitles li.current { - color: var(--white); + color: var(--white, #fff); } .xmodule_display.xmodule_VideoBlock .video.is-touch .tc-wrapper .video-wrapper object, @@ -866,7 +874,7 @@ background-position: 50% 50%; background-repeat: no-repeat; background-size: 100%; - background-color: var(--black); + background-color: var(--black, #000); } .xmodule_display.xmodule_VideoBlock .video .video-pre-roll.is-html5 { @@ -874,10 +882,10 @@ } .xmodule_display.xmodule_VideoBlock .video .video-pre-roll .btn-play.btn-pre-roll { - padding: var(--baseline); + padding: var(--baseline, 20px); border: none; - border-radius: var(--baseline); - background: var(--black-t2); + border-radius: var(--baseline, 20px); + background: var(--black-t2, rgba(0, 0, 0, 0.5)); box-shadow: none; } @@ -886,15 +894,20 @@ } .xmodule_display.xmodule_VideoBlock .video .video-pre-roll .btn-play.btn-pre-roll img { - height: calc((var(--baseline) * 4)); - width: calc((var(--baseline) * 4)); + height: calc((var(--baseline, 20px) * 4)); + width: calc((var(--baseline, 20px) * 4)); } -.xmodule_display.xmodule_VideoBlock .video .video-pre-roll .btn-play.btn-pre-roll:hover, .xmodule_display.xmodule_VideoBlock .video .video-pre-roll .btn-play.btn-pre-roll:focus { - background: var(--blue); +.xmodule_display.xmodule_VideoBlock .video .video-pre-roll .btn-play.btn-pre-roll:hover, +.xmodule_display.xmodule_VideoBlock .video .video-pre-roll .btn-play.btn-pre-roll:focus { + background: var(--blue, #0075b4); } -.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .slider .ui-slider-handle, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-handle, .xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li, .xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li { +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .slider .ui-slider-handle, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-handle, +.xmodule_display.xmodule_VideoBlock .video .subtitles .subtitles-menu li, +.xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li { cursor: pointer; } @@ -902,15 +915,19 @@ z-index: 0; } -.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container { +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu, +.xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container { z-index: 10; } -.xmodule_display.xmodule_VideoBlock .video .video-pre-roll, .xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list { +.xmodule_display.xmodule_VideoBlock .video .video-pre-roll, +.xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list { z-index: 1000; } -.xmodule_display.xmodule_VideoBlock .video.video-fullscreen, .xmodule_display.xmodule_VideoBlock .video.video-fullscreen .tc-wrapper .video-controls, .xmodule_display.xmodule_VideoBlock .overlay { +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen, +.xmodule_display.xmodule_VideoBlock .video.video-fullscreen .tc-wrapper .video-controls, +.xmodule_display.xmodule_VideoBlock .overlay { z-index: 10000; } @@ -919,7 +936,7 @@ z-index: 100000; } -.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container > a::after { +.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container>a::after { font-family: FontAwesome; -webkit-font-smoothing: antialiased; display: inline-block; @@ -941,7 +958,7 @@ display: none; position: absolute; list-style: none; - background-color: var(--white); + background-color: var(--white, #fff); border: 1px solid #eee; } @@ -949,7 +966,7 @@ margin: 0; padding: 0; border-bottom: 1px solid #eee; - color: var(--white); + color: var(--white, #fff); } .xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li a { @@ -957,13 +974,14 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: var(--gray-l2); + color: var(--gray-l2, #adadad); font-size: 14px; line-height: 23px; } -.xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li a:hover, .xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li a:focus { - color: var(--gray-d1); +.xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li a:hover, +.xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li a:focus { + color: var(--gray-d1, #5e5e5e); } .xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li.active a { @@ -982,23 +1000,23 @@ border-left: 1px solid #eee; } -.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container.open > a { - background-color: var(--action-primary-active-bg); - color: var(--very-light-text); +.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container.open>a { + background-color: var(--action-primary-active-bg, #0075b4); + color: var(--very-light-text, white); } -.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container.open > a::after { - color: var(--very-light-text); +.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container.open>a::after { + color: var(--very-light-text, white); } -.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container > a { - transition: all var(--tmg-f2) ease-in-out 0s; +.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container>a { + transition: all var(--tmg-f2, 0.25s) ease-in-out 0s; font-size: 12px; display: block; border-radius: 0 3px 3px 0; - background-color: var(--very-light-text); - padding: calc((var(--baseline) * 0.75)) calc((var(--baseline) * 1.25)) calc((var(--baseline) * 0.75)) calc((var(--baseline) * 0.75)); - color: var(--gray-l2); + background-color: var(--very-light-text, white); + padding: calc((var(--baseline, 20px) * 0.75)) calc((var(--baseline, 20px) * 1.25)) calc((var(--baseline, 20px) * 0.75)) calc((var(--baseline, 20px) * 0.75)); + color: var(--gray-l2, #adadad); min-width: 1.5em; line-height: 14px; text-align: center; @@ -1006,12 +1024,12 @@ text-overflow: ellipsis; } -.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container > a::after { +.xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container>a::after { content: "\f0d7"; position: absolute; - right: calc((var(--baseline) * 0.5)); + right: calc((var(--baseline, 20px) * 0.5)); top: 33%; - color: var(--lighter-base-font-color); + color: var(--lighter-base-font-color, #646464); } .xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container .a11y-menu-list { @@ -1034,7 +1052,7 @@ .xmodule_display.xmodule_VideoBlock .contextmenu, .xmodule_display.xmodule_VideoBlock .submenu { border: 1px solid #333; - background: var(--white); + background: var(--white, #fff); color: #333; padding: 0; margin: 0; @@ -1056,15 +1074,15 @@ .xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item, .xmodule_display.xmodule_VideoBlock .submenu .menu-item, .xmodule_display.xmodule_VideoBlock .submenu .submenu-item { - border-top: 1px solid var(--gray-l3); - padding: calc((var(--baseline) / 4)) calc((var(--baseline) / 2)); + border-top: 1px solid var(--gray-l3, #c8c8c8); + padding: calc((var(--baseline, 20px) / 4)) calc((var(--baseline, 20px) / 2)); outline: none; } -.xmodule_display.xmodule_VideoBlock .contextmenu .menu-item > span, -.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item > span, -.xmodule_display.xmodule_VideoBlock .submenu .menu-item > span, -.xmodule_display.xmodule_VideoBlock .submenu .submenu-item > span { +.xmodule_display.xmodule_VideoBlock .contextmenu .menu-item>span, +.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item>span, +.xmodule_display.xmodule_VideoBlock .submenu .menu-item>span, +.xmodule_display.xmodule_VideoBlock .submenu .submenu-item>span { color: #333; } @@ -1080,20 +1098,20 @@ .xmodule_display.xmodule_VideoBlock .submenu .menu-item:focus, .xmodule_display.xmodule_VideoBlock .submenu .submenu-item:focus { background: #333; - color: var(--white); + color: var(--white, #fff); } -.xmodule_display.xmodule_VideoBlock .contextmenu .menu-item:focus > span, -.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item:focus > span, -.xmodule_display.xmodule_VideoBlock .submenu .menu-item:focus > span, -.xmodule_display.xmodule_VideoBlock .submenu .submenu-item:focus > span { - color: var(--white); +.xmodule_display.xmodule_VideoBlock .contextmenu .menu-item:focus>span, +.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item:focus>span, +.xmodule_display.xmodule_VideoBlock .submenu .menu-item:focus>span, +.xmodule_display.xmodule_VideoBlock .submenu .submenu-item:focus>span { + color: var(--white, #fff); } .xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item, .xmodule_display.xmodule_VideoBlock .submenu .submenu-item { position: relative; - padding: calc((var(--baseline) / 4)) var(--baseline) calc((var(--baseline) / 4)) calc((var(--baseline) / 2)); + padding: calc((var(--baseline, 20px) / 4)) var(--baseline, 20px) calc((var(--baseline, 20px) / 4)) calc((var(--baseline, 20px) / 2)); } .xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item::after, @@ -1113,16 +1131,16 @@ .xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item.is-opened, .xmodule_display.xmodule_VideoBlock .submenu .submenu-item.is-opened { background: #333; - color: var(--white); + color: var(--white, #fff); } -.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item.is-opened > span, -.xmodule_display.xmodule_VideoBlock .submenu .submenu-item.is-opened > span { - color: var(--white); +.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item.is-opened>span, +.xmodule_display.xmodule_VideoBlock .submenu .submenu-item.is-opened>span { + color: var(--white, #fff); } -.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item.is-opened > .submenu, -.xmodule_display.xmodule_VideoBlock .submenu .submenu-item.is-opened > .submenu { +.xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item.is-opened>.submenu, +.xmodule_display.xmodule_VideoBlock .submenu .submenu-item.is-opened>.submenu { display: block; } @@ -1134,7 +1152,7 @@ .xmodule_display.xmodule_VideoBlock .contextmenu .is-disabled, .xmodule_display.xmodule_VideoBlock .submenu .is-disabled { pointer-events: none; - color: var(--gray-l3); + color: var(--gray-l3, #c8c8c8); } .xmodule_display.xmodule_VideoBlock .overlay { diff --git a/xmodule/static/css-builtin-blocks/VideoBlockEditor.css b/xmodule/static/css-builtin-blocks/VideoBlockEditor.css index 4a570a5fec..f995a9e85d 100644 --- a/xmodule/static/css-builtin-blocks/VideoBlockEditor.css +++ b/xmodule/static/css-builtin-blocks/VideoBlockEditor.css @@ -1,8 +1,5 @@ @import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); -.xmodule_edit.xmodule_VideoBlock { -} - .xmodule_edit.xmodule_VideoBlock .ui-col-wide { width: 74.46809%; margin-right: 2.12766%; @@ -64,12 +61,12 @@ .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header { box-sizing: border-box; - padding: 18px var(--baseline); + padding: 18px var(--baseline, 20px); top: 0 !important; right: 0; - background-color: var(--blue); - border-bottom: 1px solid var(--blue-d2); - color: var(--white); + background-color: var(--blue, #0075b4); + border-bottom: 1px solid var(--blue-d2, #00466c); + color: var(--white, #fff); } .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header .component-name { @@ -77,23 +74,23 @@ top: 0; left: 0; width: 50%; - color: var(--white); + color: var(--white, #fff); font-weight: 600; } .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header .component-name em { display: inline-block; - margin-right: calc((var(--baseline) / 4)); + margin-right: calc((var(--baseline, 20px) / 4)); font-weight: 400; - color: var(--white); + color: var(--white, #fff); } .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header .editor-tabs { list-style: none; right: 0; - top: calc((var(--baseline) / 4)); + top: calc((var(--baseline, 20px) / 4)); position: absolute; - padding: 12px calc((var(--baseline) * 0.75)); + padding: 12px calc((var(--baseline, 20px) * 0.75)); } .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header .editor-tabs .inner_tab_wrap { @@ -107,26 +104,27 @@ background-color: rgba(255, 255, 255, 0.3); background-image: -webkit-linear-gradient(top, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0)); background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0)); - border: 1px solid var(--blue-d1); + border: 1px solid var(--blue-d1, #005e90); border-radius: 3px; - padding: calc((var(--baseline) / 4)) var(--baseline); - background-color: var(--blue); + padding: calc((var(--baseline, 20px) / 4)) var(--baseline, 20px); + background-color: var(--blue, #0075b4); font-weight: bold; - color: var(--white); + color: var(--white, #fff); } .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header .editor-tabs .inner_tab_wrap a.tab.current { - background-color: var(--blue); - background-image: -webkit-linear-gradient(var(--blue), var(--blue)); - background-image: linear-gradient(to, var(--blue)); - color: var(--blue-d1); - box-shadow: inset 0 1px 2px 1px var(--shadow-l1); - background-color: var(--blue-d4); + background-color: var(--blue, #0075b4); + background-image: -webkit-linear-gradient(var(--blue, #0075b4), var(--blue, #0075b4)); + background-image: linear-gradient(to, var(--blue, #0075b4)); + color: var(--blue-d1, #005e90); + box-shadow: inset 0 1px 2px 1px var(--shadow-l1, rgba(0, 0, 0, 0.1)); + background-color: var(--blue-d4, #001724); cursor: default; } -.xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header .editor-tabs .inner_tab_wrap a.tab:hover, .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header .editor-tabs .inner_tab_wrap a.tab:focus { - box-shadow: inset 0 1px 2px 1px var(--shadow); +.xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header .editor-tabs .inner_tab_wrap a.tab:hover, +.xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header .editor-tabs .inner_tab_wrap a.tab:focus { + box-shadow: inset 0 1px 2px 1px var(--shadow, rgba(0, 0, 0, 0.2)); background-image: linear-gradient(#009fe6, #009fe6) !important; } @@ -142,9 +140,9 @@ display: none; } -.xmodule_edit.xmodule_VideoBlock .editor-with-tabs .comp-subtitles-entry .comp-subtitles-import-list > li { +.xmodule_edit.xmodule_VideoBlock .editor-with-tabs .comp-subtitles-entry .comp-subtitles-import-list>li { display: block; - margin: calc(var(--baseline) / 2) 0; + margin: calc(var(--baseline, 20px) / 2) 0; } .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .comp-subtitles-entry .comp-subtitles-import-list .blue-button { @@ -156,7 +154,7 @@ } .xmodule_edit.xmodule_VideoBlock .component-tab { - background: var(--white); + background: var(--white, #fff); position: relative; border-top: 1px solid #8891a1; } diff --git a/xmodule/static/css-builtin-blocks/WordCloudBlockDisplay.css b/xmodule/static/css-builtin-blocks/WordCloudBlockDisplay.css index 51050dcba9..85ca354eb9 100644 --- a/xmodule/static/css-builtin-blocks/WordCloudBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/WordCloudBlockDisplay.css @@ -1,12 +1,7 @@ @import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); -.xmodule_display.xmodule_WordCloudBlock { - /* stylelint-disable-line */ - /* stylelint-disable-line */ -} - .xmodule_display.xmodule_WordCloudBlock .input-cloud { - margin: calc((var(--baseline) / 4)); + margin: calc((var(--baseline, 20px) / 4)); } .xmodule_display.xmodule_WordCloudBlock .result_cloud_section { diff --git a/xmodule/tabs.py b/xmodule/tabs.py index b18f6ca12d..7e42d75247 100644 --- a/xmodule/tabs.py +++ b/xmodule/tabs.py @@ -6,7 +6,7 @@ Implement CourseTab import logging from abc import ABCMeta -from django.core.files.storage import get_storage_class +from django.utils.module_loading import import_string from xblock.fields import List from edx_django_utils.plugins import PluginError @@ -281,7 +281,7 @@ class TabFragmentViewMixin: Returns the view that will be used to render the fragment. """ if not self._fragment_view: - self._fragment_view = get_storage_class(self.fragment_view_name)() + self._fragment_view = import_string(self.fragment_view_name)() return self._fragment_view def render_to_fragment(self, request, course, **kwargs): diff --git a/xmodule/template_block.py b/xmodule/template_block.py index cdf83a5566..37d2fac905 100644 --- a/xmodule/template_block.py +++ b/xmodule/template_block.py @@ -1,24 +1,21 @@ """ Template block """ - +import logging from string import Template -from xblock.core import XBlock from lxml import etree from web_fragments.fragment import Fragment +from xblock.core import XBlock + from xmodule.editing_block import EditingMixin +from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.raw_block import RawMixin -from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_css_to_fragment -from xmodule.x_module import ( - ResourceTemplates, - shim_xmodule_js, - XModuleMixin, - XModuleToXBlockMixin, -) +from xmodule.util.builtin_assets import add_css_to_fragment, add_webpack_js_to_fragment +from xmodule.x_module import ResourceTemplates, XModuleMixin, XModuleToXBlockMixin, shim_xmodule_js from xmodule.xml_block import XmlMixin -from openedx.core.djangolib.markup import Text +log = logging.getLogger(__name__) class CustomTagTemplateBlock( # pylint: disable=abstract-method @@ -76,8 +73,10 @@ class CustomTagBlock(CustomTagTemplateBlock): # pylint: disable=abstract-method def render_template(self, system, xml_data): '''Render the template, given the definition xml_data''' + if not xml_data: + return "Please set the template for this custom tag." xmltree = etree.fromstring(xml_data) - if 'impl' in xmltree.attrib: + if 'impl' in xmltree.attrib and xmltree.attrib['impl']: template_name = xmltree.attrib['impl'] else: # VS[compat] backwards compatibility with old nested customtag structure @@ -86,16 +85,19 @@ class CustomTagBlock(CustomTagTemplateBlock): # pylint: disable=abstract-method template_name = child_impl.text else: # TODO (vshnayder): better exception type - raise Exception("Could not find impl attribute in customtag {}" - .format(self.location)) + return Template("Could not find impl attribute in customtag {}").safe_substitute({}) params = dict(list(xmltree.items())) # cdodge: look up the template as a module template_loc = self.location.replace(category='custom_tag_template', name=template_name) + try: + template_block = system.get_block(template_loc) + template_block_data = template_block.data + except ItemNotFoundError as ex: + template_block_data = f"Could not find template block for custom tag with Id {template_name}" + log.info(template_block_data) - template_block = system.get_block(template_loc) - template_block_data = template_block.data template = Template(template_block_data) return template.safe_substitute(params) @@ -120,8 +122,7 @@ class CustomTagBlock(CustomTagTemplateBlock): # pylint: disable=abstract-method class TranslateCustomTagBlock( # pylint: disable=abstract-method - XModuleToXBlockMixin, - XModuleMixin, + CustomTagBlock, ): """ Converts olx of the form `<$custom_tag attr="" attr=""/>` to CustomTagBlock @@ -129,19 +130,20 @@ class TranslateCustomTagBlock( # pylint: disable=abstract-method """ resources_dir = None - @classmethod - def parse_xml(cls, node, runtime, _keys): - """ - Transforms the xml_data from <$custom_tag attr="" attr=""/> to - - """ - - runtime.error_tracker(Text('WARNING: the <{tag}> tag is deprecated. ' - 'Instead, use . ') - .format(tag=node.tag)) + def render_template(self, system, xml_data): + xml_string = "" + if xml_data: + xmltree = etree.fromstring(xml_data) + xmltree = self.replace_xml(xmltree) + xml_string = etree.tostring(xmltree, pretty_print=True).decode("utf-8") + return super().render_template(system, xml_string or xml_data) + def replace_xml(self, node): + """ + Replaces the xml_data from <$custom_tag attr="" attr=""/> to + . + """ tag = node.tag node.tag = 'customtag' node.attrib['impl'] = tag - - return runtime.process_xml(etree.tostring(node)) + return node diff --git a/xmodule/templates/about/overview.yaml b/xmodule/templates/about/overview.yaml index c5ddfcd97e..be564e38e2 100644 --- a/xmodule/templates/about/overview.yaml +++ b/xmodule/templates/about/overview.yaml @@ -42,7 +42,7 @@ data: |
    diff --git a/xmodule/templates/html/zooming_image.yaml b/xmodule/templates/html/zooming_image.yaml index b91717550b..14e9ef2c23 100644 --- a/xmodule/templates/html/zooming_image.yaml +++ b/xmodule/templates/html/zooming_image.yaml @@ -2,56 +2,238 @@ metadata: display_name: Zooming Image Tool data: | -

    Zooming Image Tool

    -

    Use the Zooming Image Tool to enable learners to see details of large, complex images.

    -

    With the Zooming Image Tool, the learner can move the mouse pointer over a part of the image to enlarge it and see more detail.

    -

    To use the Zooming Image Tool, you must first add the jquery.loupeAndLightbox.js JavaScript file to your course.

    -

    You must also add both the regular and magnified image files to your course.

    -

    The following HTML code shows the format required to use the Zooming Image tool. For the example in this template, you must replace the values in italics.

    -
    -        <div class="zooming-image-place" style="position: relative;">
    -          <a class="loupe" href="path to the magnified version of the image">
    -            <img alt="Text for screen readers"
    -              src="path to the image you want to display in the unit" />
    -          </a>
    -          <div class="script_placeholder"
    -            data-src="path to the jquery.loupeAndLightbox.js JavaScript file in your course"/>
    -        </div>
    -        <script type="text/javascript">// >![CDATA[
    -        JavascriptLoader.executeModuleScripts($('.zooming-image-place').eq(0), function() {
    -          $('.loupe').loupeAndLightbox({
    +      

    Use the Zooming Image Tool to enable learners to see details of large, complex images. With the tool, the learner can move the mouse pointer over a part of the image to enlarge it and see more detail.

    +

    To set it up, first upload the regular image file and, optionally, a magnified image file to your course. Then refer to them with the following HTML code, replacing the values in italics accordingly:

    +
    +      <div class="zooming-image">
    +        <a data-src="(Optional) URL to the magnified image">
    +          <img src="URL to the regular image" />
    +        </a>
    +      </div>
    +      
    +

    If a magnified image is not provided, the regular one will be used at its native size.

    +

    Feel free to modify the example below for your own use, but take care not to remove the included Javascript.

    + + -
    + $('.zooming-image').loupe(); + //]]> diff --git a/xmodule/templates/problem/circuitschematic.yaml b/xmodule/templates/problem/circuitschematic.yaml index 9053718af1..6dc411e258 100644 --- a/xmodule/templates/problem/circuitschematic.yaml +++ b/xmodule/templates/problem/circuitschematic.yaml @@ -12,8 +12,8 @@ data: |

    For more information, see - - Circuit Schematic Builder Problem in Building and Running an edX Course. + + Circuit Schematic Builder Problem in Building and Running an Open edX Course.

    When you add the problem, be sure to select Settings diff --git a/xmodule/templates/problem/customgrader.yaml b/xmodule/templates/problem/customgrader.yaml index f03b259005..1600f2a893 100644 --- a/xmodule/templates/problem/customgrader.yaml +++ b/xmodule/templates/problem/customgrader.yaml @@ -23,8 +23,8 @@ data: | click the "Show Answer" button.

    - For more information, see - Write-Your-Own-Grader Problem in Building and Running an edX Course. + For more information, see + Write-Your-Own-Grader Problem in Building and Running an Open edX Course.

    When you add the problem, be sure to select Settings diff --git a/xmodule/templates/problem/drag_and_drop.yaml b/xmodule/templates/problem/drag_and_drop.yaml index d21afd7426..e4d6b605aa 100644 --- a/xmodule/templates/problem/drag_and_drop.yaml +++ b/xmodule/templates/problem/drag_and_drop.yaml @@ -8,8 +8,8 @@ data: |

    In drag and drop problems, students respond to a question by dragging text or objects to a specific location on an image.

    For more information, see - - Drag and Drop Problem (Deprecated) in Building and Running an edX Course. + + Drag and Drop Problem (Deprecated) in Building and Running an Open edX Course.

    When you add the problem, be sure to select Settings diff --git a/xmodule/templates/problem/imageresponse.yaml b/xmodule/templates/problem/imageresponse.yaml index bb2b189f3f..ace6282572 100644 --- a/xmodule/templates/problem/imageresponse.yaml +++ b/xmodule/templates/problem/imageresponse.yaml @@ -7,9 +7,9 @@ data: |

    In an image mapped input problem, also known as a "pointing on a picture" problem, students click inside a defined region in an image. You define this region by including coordinates in the body of the problem. You can define one rectangular region, multiple rectangular regions, or one non-rectangular region. For more information, see - Image Mapped Input Problem + Image Mapped Input Problem in - Building and Running an edx Course. + Building and Running an Open edX Course.

    When you add the problem, be sure to select Settings diff --git a/xmodule/templates/problem/jsinput_response.yaml b/xmodule/templates/problem/jsinput_response.yaml index dd9fdda164..e931886bdc 100644 --- a/xmodule/templates/problem/jsinput_response.yaml +++ b/xmodule/templates/problem/jsinput_response.yaml @@ -20,13 +20,8 @@ data: |

    For more information, see - - Custom JavaScript Problem in Building and Running an edX Course. -

    -

    - JavaScript developers can also see - - Custom JavaScript Applications in the EdX Developer's Guide. + + Custom JavaScript Problem in Building and Running an Open edX Course.

    When you add the problem, be sure to select Settings diff --git a/xmodule/templates/problem/latex_problem.yaml b/xmodule/templates/problem/latex_problem.yaml index bb3493ce52..4f1e18d3be 100644 --- a/xmodule/templates/problem/latex_problem.yaml +++ b/xmodule/templates/problem/latex_problem.yaml @@ -96,8 +96,8 @@ data: |

    For more information, see - - Problem Written in LaTeX in Building and Running an edX Course. + + Problem Written in LaTeX in Building and Running an Open edX Course.

    You can use the following example problems as models.

    Example Option Problem

    diff --git a/xmodule/templates/test/zooming_image.yaml b/xmodule/templates/test/zooming_image.yaml deleted file mode 100644 index 3ac9d63bcb..0000000000 --- a/xmodule/templates/test/zooming_image.yaml +++ /dev/null @@ -1,25 +0,0 @@ ---- -metadata: - display_name: Zooming Image -data: | -

    ZOOMING DIAGRAMS

    -

    Some edX classes use extremely large, extremely detailed graphics. To make it easier to understand we can offer two versions of those graphics, with the zoomed section showing when you click on the main view.

    -

    The example below is from 7.00x: Introduction to Biology and shows a subset of the biochemical reactions that cells carry out.

    -

    You can view the chemical structures of the molecules by clicking on them. The magnified view also lists the enzymes involved in each step.

    -

    Press spacebar to open the magifier.

    -
    - - magnify - -
    -
    - -
    diff --git a/xmodule/tests/__init__.py b/xmodule/tests/__init__.py index b2cdd67b71..786836c050 100644 --- a/xmodule/tests/__init__.py +++ b/xmodule/tests/__init__.py @@ -1,10 +1,5 @@ """ unittests for xmodule - -Run like this: - - paver test_lib -l ./xmodule - """ diff --git a/xmodule/tests/test_annotatable_block.py b/xmodule/tests/test_annotatable_block.py index b9f419ee3a..3de253047a 100644 --- a/xmodule/tests/test_annotatable_block.py +++ b/xmodule/tests/test_annotatable_block.py @@ -134,3 +134,11 @@ class AnnotatableBlockTestCase(unittest.TestCase): # lint-amnesty, pylint: disa xmltree = etree.fromstring('foo') actual = self.annotatable._extract_instructions(xmltree) # lint-amnesty, pylint: disable=protected-access assert actual is None + + def test_instruction_removal(self): + xmltree = etree.fromstring(self.sample_xml) + instructions = self.annotatable._extract_instructions(xmltree) # pylint: disable=protected-access + + assert instructions is not None + assert "Read the text." in instructions + assert xmltree.find("instructions") is None diff --git a/xmodule/tests/test_annotator_mixin.py b/xmodule/tests/test_annotator_mixin.py index baaa7ad5d9..655d18ef34 100644 --- a/xmodule/tests/test_annotator_mixin.py +++ b/xmodule/tests/test_annotator_mixin.py @@ -22,6 +22,10 @@ class HelperFunctionTest(unittest.TestCase): sample_sourceurl = "http://video-js.zencoder.com/oceans-clip.mp4" sample_youtubeurl = "http://www.youtube.com/watch?v=yxLIu-scR9Y" sample_html = '

    Testing here and not bolded here

    ' + # pylint: disable=line-too-long + sample_html_with_image_alt = '''

    Testing here with image:

    the alt text

    ''' + # pylint: disable=line-too-long + sample_html_with_no_image_alt = '''

    Testing here with image:

    ''' def test_get_instructions(self): """ @@ -54,3 +58,13 @@ class HelperFunctionTest(unittest.TestCase): expectedtext = "Testing here and not bolded here" result = html_to_text(self.sample_html) assert expectedtext == result + + def test_html_image_with_alt_text(self): + expectedtext = "Testing here with image: the alt text" + result = html_to_text(self.sample_html_with_image_alt) + assert expectedtext == result + + def test_html_image_with_no_alt_text(self): + expectedtext = "Testing here with image: " + result = html_to_text(self.sample_html_with_no_image_alt) + assert expectedtext == result diff --git a/xmodule/tests/test_capa_block.py b/xmodule/tests/test_capa_block.py index c81b137f1b..21582e55ea 100644 --- a/xmodule/tests/test_capa_block.py +++ b/xmodule/tests/test_capa_block.py @@ -37,6 +37,7 @@ from xmodule.capa.responsetypes import LoncapaProblemError, ResponseError, Stude from xmodule.capa.xqueue_interface import XQueueInterface from xmodule.capa_block import ComplexEncoder, ProblemBlock from xmodule.tests import DATA_DIR +from xmodule.capa.tests.test_util import use_unsafe_codejail from ..capa_block import RANDOMIZATION, SHOWANSWER from . import get_test_system @@ -1213,6 +1214,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss # Expect that the number of attempts is NOT incremented assert block.attempts == 1 + @pytest.mark.django_db @patch.object(XQueueInterface, '_http_post') def test_submit_problem_with_files(self, mock_xqueue_post): # Check a problem with uploaded files, using the submit_problem API. @@ -1263,6 +1265,7 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss for fpath, fileobj in kwargs['files'].items(): assert fpath == fileobj.name + @pytest.mark.django_db @patch.object(XQueueInterface, '_http_post') def test_submit_problem_with_files_as_xblock(self, mock_xqueue_post): # Check a problem with uploaded files, using the XBlock API. @@ -2629,12 +2632,12 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss else: # Since there's a small chance (expected) we might get the - # same seed again, give it 10 chances + # same seed again, give it 60 chances # to generate a different seed - success = _retry_and_check(10, lambda: _reset_and_get_seed(block) != seed) + success = _retry_and_check(60, lambda: _reset_and_get_seed(block) != seed) assert block.seed is not None - msg = 'Could not get a new seed from reset after 10 tries' + msg = 'Could not get a new seed from reset after 60 tries' assert success, msg @ddt.data( @@ -2796,48 +2799,6 @@ class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=miss ('shuffle', ['choice_3', 'choice_1', 'choice_2', 'choice_0']) assert event_info['success'] == 'correct' - @unittest.skip("masking temporarily disabled") - def test_save_unmask(self): - """On problem save, unmasked data should appear on publish.""" - block = CapaFactory.create(xml=self.common_shuffle_xml) - with patch.object(block.runtime, 'publish') as mock_publish: - get_request_dict = {CapaFactory.input_key(): 'mask_0'} - block.save_problem(get_request_dict) - mock_call = mock_publish.mock_calls[0] - event_info = mock_call[1][1] - assert event_info['answers'][CapaFactory.answer_key()] == 'choice_2' - assert event_info['permutation'][CapaFactory.answer_key()] is not None - - @unittest.skip("masking temporarily disabled") - def test_reset_unmask(self): - """On problem reset, unmask names should appear publish.""" - block = CapaFactory.create(xml=self.common_shuffle_xml) - get_request_dict = {CapaFactory.input_key(): 'mask_0'} - block.submit_problem(get_request_dict) - # On reset, 'old_state' should use unmasked names - with patch.object(block.runtime, 'publish') as mock_publish: - block.reset_problem(None) - mock_call = mock_publish.mock_calls[0] - event_info = mock_call[1][1] - assert mock_call[1][0] == 'reset_problem' - assert event_info['old_state']['student_answers'][CapaFactory.answer_key()] == 'choice_2' - assert event_info['permutation'][CapaFactory.answer_key()] is not None - - @unittest.skip("masking temporarily disabled") - def test_rescore_unmask(self): - """On problem rescore, unmasked names should appear on publish.""" - block = CapaFactory.create(xml=self.common_shuffle_xml) - get_request_dict = {CapaFactory.input_key(): 'mask_0'} - block.submit_problem(get_request_dict) - # On rescore, state/student_answers should use unmasked names - with patch.object(block.runtime, 'publish') as mock_publish: - block.rescore_problem(only_if_higher=False) # lint-amnesty, pylint: disable=no-member - mock_call = mock_publish.mock_calls[0] - event_info = mock_call[1][1] - assert mock_call[1][0] == 'problem_rescore' - assert event_info['state']['student_answers'][CapaFactory.answer_key()] == 'choice_2' - assert event_info['permutation'][CapaFactory.answer_key()] is not None - def test_check_unmask_answerpool(self): """Check answer-pool question publish uses unmasked names""" xml = textwrap.dedent(""" @@ -3675,6 +3636,7 @@ class ComplexEncoderTest(unittest.TestCase): # lint-amnesty, pylint: disable=mi @skip_unless_lms +@use_unsafe_codejail() class ProblemCheckTrackingTest(unittest.TestCase): """ Ensure correct tracking information is included in events emitted during problem checks. @@ -3900,6 +3862,7 @@ class ProblemCheckTrackingTest(unittest.TestCase): 'group_label': '', 'variant': block.seed}} + @pytest.mark.django_db @patch.object(XQueueInterface, '_http_post') def test_file_inputs(self, mock_xqueue_post): fnames = ["prog1.py", "prog2.py", "prog3.py"] diff --git a/xmodule/tests/test_export.py b/xmodule/tests/test_export.py index 5a52d145a5..e4a5cbcdae 100644 --- a/xmodule/tests/test_export.py +++ b/xmodule/tests/test_export.py @@ -143,9 +143,22 @@ class RoundTripTestCase(unittest.TestCase): print("Checking block equality") for location in initial_import.modules[course_id].keys(): - print(("Checking", location)) - assert blocks_are_equivalent(initial_import.modules[course_id][location], - second_import.modules[course_id][location]) + initial_block = initial_import.modules[course_id][location] + reimported_block = second_import.modules[course_id][location] + if location.block_type == "error": + # Error blocks store their stacktrace as a field on the block + # itself. We cache failed XBlock tag -> class lookups, so a + # PluginError raised from the uncached state vs cached state + # will generate different stacktraces, making the two blocks + # "different" as far as blocks_are_equivalent() is concerned. It + # doesn't *really* matter if the stacktraces are different + # though, so we'll do a much less thorough comparison for error + # blocks: + assert type(initial_block) == type(reimported_block) # pylint:disable=unidiomatic-typecheck + assert initial_block.display_name == reimported_block.display_name + else: + print(("Checking", location)) + assert blocks_are_equivalent(initial_block, reimported_block) class TestEdxJsonEncoder(unittest.TestCase): diff --git a/xmodule/tests/test_mongo_utils.py b/xmodule/tests/test_mongo_utils.py index cfa08ba157..b4cfb04d74 100644 --- a/xmodule/tests/test_mongo_utils.py +++ b/xmodule/tests/test_mongo_utils.py @@ -36,3 +36,13 @@ class MongoUtilsTests(TestCase): # Support for read_preference given as mongos name. connection = connect_to_mongodb(db, host, read_preference=mongos_name) assert connection.client.read_preference == expected_read_preference + + @ddt.data(True, False) + def test_connect_to_mongo_with_retry_reads(self, is_retry_enabled): + """ + Test that the MongoDB client is created with retryReads=[True | False]. + """ + host = 'localhost' + db = 'test_retry_reads_%s_%s' % (str(is_retry_enabled).lower(), uuid4().hex) + connection = connect_to_mongodb(db, host, retry_reads=is_retry_enabled) + assert connection.client.options.retry_reads is is_retry_enabled diff --git a/xmodule/tests/test_resource_templates.py b/xmodule/tests/test_resource_templates.py index 742a7e9da1..470be95514 100644 --- a/xmodule/tests/test_resource_templates.py +++ b/xmodule/tests/test_resource_templates.py @@ -18,7 +18,6 @@ class ResourceTemplatesTests(unittest.TestCase): def test_templates(self): expected = { 'latex_html.yaml', - 'zooming_image.yaml', 'announcement.yaml', 'anon_user_id.yaml'} got = {t['template_id'] for t in TestClass.templates()} @@ -38,7 +37,6 @@ class ResourceTemplatesTests(unittest.TestCase): def test_custom_templates(self): expected = { 'latex_html.yaml', - 'zooming_image.yaml', 'announcement.yaml', 'anon_user_id.yaml'} got = {t['template_id'] for t in TestClassResourceTemplate.templates()} diff --git a/xmodule/tests/test_sequence.py b/xmodule/tests/test_sequence.py index be773865a7..c299f7bed0 100644 --- a/xmodule/tests/test_sequence.py +++ b/xmodule/tests/test_sequence.py @@ -478,3 +478,97 @@ class SequenceBlockTestCase(XModuleXmlImportTest): # Replace tuple and un-necessary info from inside string and get the dictionary. cleaned_data = data.replace("(('seq_block.html',\n", '').replace("),\n {})", '').strip() return ast.literal_eval(cleaned_data) + + def test_not_gated_blocks_rendered_normally(self): + """ + Test that non-gated blocks are rendered with full content when prerequisites are met. + """ + # Mock child block + child = Mock() + child.scope_ids.usage_id = "block1" + child.scope_ids.block_type = "vertical" + child.display_name_with_default = "Test Block" + children = [child] + + # Mock context + context = {"next_url": "next_url", "prev_url": "prev_url"} + fragment = Mock() + + # Mock `_render_student_view_for_blocks` + self.sequence_3_1._render_student_view_for_blocks = Mock(return_value="rendered_blocks") # pylint: disable=protected-access + + # Call `_get_render_metadata` with prerequisites met + metadata = self.sequence_3_1._get_render_metadata( # pylint: disable=protected-access + context, children, prereq_met=True, prereq_meta_info={}, fragment=fragment + ) + + # Assert that blocks are rendered normally + assert metadata["items"] == "rendered_blocks" + assert metadata["next_url"] == "next_url" + assert metadata["prev_url"] == "prev_url" + + def test_gated_blocks_rendered_with_basic_info(self): + """ + Test that gated blocks are rendered with minimal metadata when prerequisites are not met. + """ + # Mock child block + child = Mock() + child.scope_ids.usage_id = "block1" + child.scope_ids.block_type = "vertical" + child.display_name_with_default = "Test Block" + children = [child] + + # Mock context + context = {"next_url": "next_url", "prev_url": "prev_url"} + + # Mock prereq_meta_info with required keys + prereq_meta_info = { + "url": "http://example.com/prereq", + "display_name": "Prerequisite Section", + "id": "prereq_block_id", + } + + # Call `_get_render_metadata` with prerequisites not met + metadata = self.sequence_3_1._get_render_metadata( # pylint: disable=protected-access + context, children, prereq_met=False, prereq_meta_info=prereq_meta_info + ) + + # Assert that gated blocks are rendered with basic info + assert len(metadata["items"]) == 1 + assert metadata["items"][0]["id"] == "block1" + assert metadata["items"][0]["type"] == "vertical" + assert metadata["items"][0]["display_name"] == "Test Block" + assert metadata["items"][0]["is_gated"] is True + assert metadata["items"][0]["content"] == "" + + # Assert that next and previous URLs are present + assert metadata["next_url"] == "next_url" + assert metadata["prev_url"] == "prev_url" + + def test_prereqs_met_content_rendered_normally(self): + """ + Test that content is rendered normally when prerequisites are met. + """ + # Mock child block + child = Mock() + child.scope_ids.usage_id = "block1" + child.scope_ids.block_type = "vertical" + child.display_name_with_default = "Test Block" + children = [child] + + # Mock context + context = {"next_url": "next_url", "prev_url": "prev_url"} + fragment = Mock() + + # Mock `_render_student_view_for_blocks` + self.sequence_3_1._render_student_view_for_blocks = Mock(return_value="rendered_blocks") # pylint: disable=protected-access + + # Call `_get_render_metadata` with prerequisites met + metadata = self.sequence_3_1._get_render_metadata( # pylint: disable=protected-access + context, children, prereq_met=True, prereq_meta_info={}, fragment=fragment + ) + + # Assert that content is rendered normally + assert metadata["items"] == "rendered_blocks" + assert metadata["next_url"] == "next_url" + assert metadata["prev_url"] == "prev_url" diff --git a/xmodule/tests/test_video.py b/xmodule/tests/test_video.py index 5e95f77082..1179feebf3 100644 --- a/xmodule/tests/test_video.py +++ b/xmodule/tests/test_video.py @@ -37,6 +37,8 @@ from xmodule.tests import get_test_descriptor_system from xmodule.validation import StudioValidationMessage from xmodule.video_block import EXPORT_IMPORT_STATIC_DIR, VideoBlock, create_youtube_string from xmodule.video_block.transcripts_utils import save_to_store +from xblock.core import XBlockAside +from xmodule.modulestore.tests.test_asides import AsideTestType from .test_import import DummySystem @@ -316,6 +318,36 @@ class VideoBlockImportTestCase(TestCase): 'transcripts': {'uk': 'ukrainian_translation.srt', 'de': 'german_translation.srt'}, }) + @XBlockAside.register_temp_plugin(AsideTestType, "test_aside") + @patch('xmodule.video_block.video_block.VideoBlock.load_file') + @patch('xmodule.video_block.video_block.is_pointer_tag') + @ddt.data(True, False) + def test_parse_xml_with_asides(self, video_xml_has_aside, mock_is_pointer_tag, mock_load_file): + """Test that `parse_xml` parses asides from the video xml""" + runtime = DummySystem(load_error_blocks=True) + if video_xml_has_aside: + xml_data = ''' + + ''' + else: + xml_data = ''' + + ''' + mock_is_pointer_tag.return_value = True + xml_object = etree.fromstring(xml_data) + mock_load_file.return_value = xml_object + output = VideoBlock.parse_xml(xml_object, runtime, None) + aside = runtime.get_aside_of_type(output, "test_aside") + if video_xml_has_aside: + assert aside.content == "default_content" + assert aside.data_field == "aside parsed" + else: + assert aside.content == "default_content" + assert aside.data_field == "default_data" + @ddt.data( ('course-v1:test_org+test_course+test_run', '/asset-v1:test_org+test_course+test_run+type@asset+block@test.png'), @@ -741,6 +773,48 @@ class VideoExportTestCase(VideoBlockTestBase): course_id=self.block.scope_ids.usage_id.context_key, ) + def test_export_to_xml_without_video_id(self): + """ + Test that we write the correct XML on export of a video without edx_video_id. + """ + self.block.youtube_id_0_75 = 'izygArpw-Qo' + self.block.youtube_id_1_0 = 'p2Q6BrNhdh8' + self.block.youtube_id_1_25 = '1EeWXzPdhSA' + self.block.youtube_id_1_5 = 'rABDYkeK0x8' + self.block.show_captions = False + self.block.start_time = datetime.timedelta(seconds=1.0) + self.block.end_time = datetime.timedelta(seconds=60) + self.block.track = 'http://www.example.com/track' + self.block.handout = 'http://www.example.com/handout' + self.block.download_track = True + self.block.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source1.ogg'] + self.block.download_video = True + self.block.transcripts = {'ua': 'ukrainian_translation.srt', 'ge': 'german_translation.srt'} + + xml = self.block.definition_to_xml(self.file_system) + parser = etree.XMLParser(remove_blank_text=True) + xml_string = '''\ + + ''' + expected = etree.XML(xml_string, parser=parser) + self.assertXmlEqual(expected, xml) + @patch('xmodule.video_block.video_block.edxval_api') def test_export_to_xml_val_error(self, mock_val_api): # Export should succeed without VAL data if video does not exist diff --git a/xmodule/tests/test_word_cloud.py b/xmodule/tests/test_word_cloud.py index 6c92846526..9fbd02a612 100644 --- a/xmodule/tests/test_word_cloud.py +++ b/xmodule/tests/test_word_cloud.py @@ -6,6 +6,7 @@ from unittest.mock import Mock from django.test import TestCase from fs.memoryfs import MemoryFS from lxml import etree +from webob import Request from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator from webob.multidict import MultiDict from xblock.field_data import DictFieldData @@ -115,3 +116,29 @@ class WordCloudBlockTest(TestCase): {'content_type': 'Word Cloud', 'content': {'display_name': 'Word Cloud Block', 'instructions': 'Enter some random words that comes to your mind'}} + + def test_studio_submit_handler(self): + """ + Test studio_submint handler + """ + TEST_SUBMIT_DATA = { + 'display_name': "New Word Cloud", + 'instructions': "This is a Test", + 'num_inputs': 5, + 'num_top_words': 10, + 'display_student_percents': 'False', + } + module_system = get_test_system() + block = WordCloudBlock(module_system, DictFieldData(self.raw_field_data), Mock()) + body = json.dumps(TEST_SUBMIT_DATA) + request = Request.blank('/') + request.method = 'POST' + request.body = body.encode('utf-8') + res = block.handle('studio_submit', request) + assert json.loads(res.body.decode('utf8')) == {'result': 'success'} + + assert block.display_name == TEST_SUBMIT_DATA['display_name'] + assert block.instructions == TEST_SUBMIT_DATA['instructions'] + assert block.num_inputs == TEST_SUBMIT_DATA['num_inputs'] + assert block.num_top_words == TEST_SUBMIT_DATA['num_top_words'] + assert block.display_student_percents == (TEST_SUBMIT_DATA['display_student_percents'] == "True") diff --git a/xmodule/util/sandboxing.py b/xmodule/util/sandboxing.py index 12c4243acf..a8883ba3e9 100644 --- a/xmodule/util/sandboxing.py +++ b/xmodule/util/sandboxing.py @@ -7,6 +7,18 @@ from django.conf import settings DEFAULT_PYTHON_LIB_FILENAME = 'python_lib.zip' +def course_code_library_asset_name(): + """ + Return the asset name to use for course code libraries, defaulting to python_lib.zip. + """ + # .. setting_name: PYTHON_LIB_FILENAME + # .. setting_default: python_lib.zip + # .. setting_description: Name of the course file to make available to code in + # custom Python-graded problems. By default, this file will not be downloadable + # by learners. + return getattr(settings, 'PYTHON_LIB_FILENAME', DEFAULT_PYTHON_LIB_FILENAME) + + def can_execute_unsafe_code(course_id): """ Determine if this course is allowed to run unsafe code. @@ -34,7 +46,7 @@ def can_execute_unsafe_code(course_id): def get_python_lib_zip(contentstore, course_id): """Return the bytes of the course code library file, if it exists.""" - python_lib_filename = getattr(settings, 'PYTHON_LIB_FILENAME', DEFAULT_PYTHON_LIB_FILENAME) + python_lib_filename = course_code_library_asset_name() asset_key = course_id.make_asset_key("asset", python_lib_filename) zip_lib = contentstore().find(asset_key, throw_on_not_found=False) if zip_lib is not None: diff --git a/xmodule/video_block/transcripts_utils.py b/xmodule/video_block/transcripts_utils.py index 866edf5968..1e2204d1da 100644 --- a/xmodule/video_block/transcripts_utils.py +++ b/xmodule/video_block/transcripts_utils.py @@ -16,10 +16,12 @@ import requests import simplejson as json from django.conf import settings from django.core.exceptions import ObjectDoesNotExist +from django.utils.translation import get_language_info from lxml import etree from opaque_keys.edx.keys import UsageKeyV2 from pysrt import SubRipFile, SubRipItem, SubRipTime from pysrt.srtexc import Error +from opaque_keys.edx.locator import LibraryLocatorV2 from openedx.core.djangoapps.xblock.api import get_component_from_usage_key from xmodule.contentstore.content import StaticContent @@ -498,16 +500,17 @@ def manage_video_subtitles_save(item, user, old_metadata=None, generate_translat remove_subs_from_store(video_id, item, lang) reraised_message = '' - for lang in new_langs: # 3b - try: - generate_sjson_for_all_speeds( - item, - item.transcripts[lang], - {speed: subs_id for subs_id, speed in youtube_speed_dict(item).items()}, - lang, - ) - except TranscriptException: - pass + if not isinstance(item.usage_key.context_key, LibraryLocatorV2): + for lang in new_langs: # 3b + try: + generate_sjson_for_all_speeds( + item, + item.transcripts[lang], + {speed: subs_id for subs_id, speed in youtube_speed_dict(item).items()}, + lang, + ) + except TranscriptException: + pass if reraised_message: item.save_with_metadata(user) raise TranscriptException(reraised_message) @@ -684,6 +687,18 @@ def convert_video_transcript(file_name, content, output_format): return dict(filename=filename, content=converted_transcript) +def clear_transcripts(block): + """ + Deletes all transcripts of a video block from VAL + """ + for language_code in block.transcripts.keys(): + edxval_api.delete_video_transcript( + video_id=block.edx_video_id, + language_code=language_code, + ) + block.transcripts = {} + + class Transcript: """ Container for transcript methods. @@ -869,21 +884,24 @@ class VideoTranscriptsMixin: """ sub, other_lang = transcripts["sub"], transcripts["transcripts"] - # language in plugin selector exists as transcript - if dest_lang and dest_lang in other_lang.keys(): - transcript_language = dest_lang - # language in plugin selector is english and empty transcripts or transcripts and sub exists - elif dest_lang and dest_lang == 'en' and (not other_lang or (other_lang and sub)): - transcript_language = 'en' - elif self.transcript_language in other_lang: - transcript_language = self.transcript_language - elif sub: - transcript_language = 'en' - elif len(other_lang) > 0: - transcript_language = sorted(other_lang)[0] - else: - transcript_language = 'en' - return transcript_language + if dest_lang: + resolved_transcript_dest_lang = resolve_language_code_to_transcript_code(transcripts, dest_lang) + if resolved_transcript_dest_lang: + return resolved_transcript_dest_lang + # language in plugin selector is english and empty transcripts or transcripts and sub exists + if dest_lang == 'en' and (not other_lang or (other_lang and sub)): + return 'en' + + if self.transcript_language in other_lang: + return self.transcript_language + + if sub: + return 'en' + + if len(other_lang) > 0: + return sorted(other_lang)[0] + + return 'en' def get_transcripts_info(self, is_bumper=False): """ @@ -1040,6 +1058,13 @@ def get_transcript_from_contentstore(video, language, output_format, transcripts return transcript_content, transcript_name, Transcript.mime_types[output_format] +def build_components_import_path(usage_key, file_path): + """ + Build components import path + """ + return f"components/{usage_key.block_type}/{usage_key.block_id}/{file_path}" + + def get_transcript_from_learning_core(video_block, language, output_format, transcripts_info): """ Get video transcript from Learning Core (used for Content Libraries) @@ -1178,3 +1203,77 @@ def get_transcript(video, lang=None, output_format=Transcript.SRT, youtube_id=No output_format=output_format, transcripts_info=transcripts_info ) + + +def resolve_language_code_to_transcript_code(transcripts, dest_lang): + """ + Attempts to match the requested dest lang with the existing transcript languages + """ + sub, other_lang = transcripts["sub"], transcripts["transcripts"] + # lang code exists in list of other transcript languages as-is + if dest_lang in other_lang: + return dest_lang + + # Language codes can be base languages, 2-3 characters, or they can include a + # locale (`fr` for french, `fr-ca` for canadian french). Sometimes the part after the + # dash is capitalized, sometimes it is not. Check both variants. + dash_index = dest_lang.find('-') + if dash_index >= 0: + lowercase_dest_lang = dest_lang.lower() + if lowercase_dest_lang in other_lang: + log.debug("language code %s resolved to %s", dest_lang, lowercase_dest_lang) + return lowercase_dest_lang + + generic_lang_code = lowercase_dest_lang[:dash_index] + uppercase_dest_lang = generic_lang_code + lowercase_dest_lang[dash_index:].upper() + if uppercase_dest_lang in other_lang: + log.debug("language code %s resolved to %s", dest_lang, uppercase_dest_lang) + return uppercase_dest_lang + + if generic_lang_code in other_lang: + log.debug("language code %s resolved to generic %s", dest_lang, generic_lang_code) + return generic_lang_code + + +def get_endonym_or_label(language_code): + """ + Given a language code, attempt to look up the endonym, or local name, for that language + """ + + lowercase_code = language_code.lower() + # LANGUAGE_DICT is an edx-configured mapping of language codes to endonym. It's a bit more + # specific than the django utility, so try that first. All language codes in this dict will + # be lowercase + if local_name := settings.LANGUAGE_DICT.get(lowercase_code): + return local_name + + # get_language_info attempts to look up language info in a hardcoded list in + # django.conf.translations. It will do automatic "generalizations", i.e. it doesn't + # have `es-419` so it then tries `es`. That's why we only do this after checking + # LANGUAGE_DICT + try: + lang_info = get_language_info(language_code) + return lang_info['name_local'] + except KeyError: + pass + + # Last place to look is in settings.ALL_LANGUAGES. Ideally we find the actual code, + # but also, check the 'generic' language. If even the generic language isn't found, + # something is wrong, so log an error and throw an exception. + first_dash_index = language_code.find('-') + generic_code = None if first_dash_index == -1 else language_code[:first_dash_index] + potential_generic_label = None + for code, language_label in settings.ALL_LANGUAGES: + # check for lowercase of the whole code, but as far as I can tell, the generic codes are + # always lowercase + if code in (language_code, lowercase_code): + return language_label + if generic_code and code == generic_code: + potential_generic_label = language_label + elif code > language_code: + break + if potential_generic_label: + return potential_generic_label + + log.error("A label was requested for language code `%s` but the code is completely unknown", language_code) + raise NotFoundError(f"Unknown language `{language_code}`") diff --git a/xmodule/video_block/video_block.py b/xmodule/video_block/video_block.py index ea9d1a4428..89718bf224 100644 --- a/xmodule/video_block/video_block.py +++ b/xmodule/video_block/video_block.py @@ -29,6 +29,7 @@ from xblock.completable import XBlockCompletionMode from xblock.core import XBlock from xblock.fields import ScopeIds from xblock.runtime import KvsFieldData +from xblocks_contrib.video import VideoBlock as _ExtractedVideoBlock from common.djangoapps.xblock_django.constants import ATTR_KEY_REQUEST_COUNTRY_CODE, ATTR_KEY_USER_ID from openedx.core.djangoapps.video_config.models import HLSPlaybackEnabledFlag, CourseYoutubeBlockedFlag @@ -47,8 +48,8 @@ from xmodule.exceptions import NotFoundError from xmodule.mako_block import MakoTemplateBlockBase from xmodule.modulestore.inheritance import InheritanceKeyValueStore, own_metadata from xmodule.raw_block import EmptyDataRawMixin +from xmodule.util.builtin_assets import add_css_to_fragment, add_webpack_js_to_fragment from xmodule.validation import StudioValidation, StudioValidationMessage -from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_css_to_fragment from xmodule.video_block import manage_video_subtitles_save from xmodule.x_module import ( PUBLIC_VIEW, STUDENT_VIEW, @@ -56,13 +57,13 @@ from xmodule.x_module import ( XModuleMixin, XModuleToXBlockMixin, ) from xmodule.xml_block import XmlMixin, deserialize_field, is_pointer_tag, name_to_pathname - from .bumper_utils import bumperize from .sharing_sites import sharing_sites_info_for_video from .transcripts_utils import ( Transcript, VideoTranscriptsMixin, clean_video_id, + get_endonym_or_label, get_html5_ids, get_transcript, subs_filename @@ -119,7 +120,7 @@ EXPORT_IMPORT_STATIC_DIR = 'static' @XBlock.wants('settings', 'completion', 'i18n', 'request_cache') @XBlock.needs('mako', 'user') -class VideoBlock( +class _BuiltInVideoBlock( VideoFields, VideoTranscriptsMixin, VideoStudioViewHandlers, VideoStudentViewHandlers, EmptyDataRawMixin, XmlMixin, EditingMixin, XModuleToXBlockMixin, ResourceTemplates, XModuleMixin, LicenseMixin): @@ -134,6 +135,7 @@ class VideoBlock( """ + is_extracted = False has_custom_completion = True completion_mode = XBlockCompletionMode.COMPLETABLE @@ -181,12 +183,13 @@ class VideoBlock( track_url = self.runtime.handler_url(self, 'transcript', 'download').rstrip('/?') transcript_language = self.get_default_transcript_language(transcripts, dest_lang) - native_languages = {lang: label for lang, label in settings.LANGUAGES if len(lang) == 2} - languages = { - lang: native_languages.get(lang, display) - for lang, display in settings.ALL_LANGUAGES - if lang in other_lang - } + languages = {} + for lang_code in other_lang: + try: + label = get_endonym_or_label(lang_code) + languages[lang_code] = label + except NotFoundError: + continue if not other_lang or (other_lang and sub): languages['en'] = 'English' @@ -470,6 +473,8 @@ class VideoBlock( bumperize(self) + is_video_from_same_origin = bool(download_video_link and cdn_url and download_video_link.startswith(cdn_url)) + template_context = { 'autoadvance_enabled': autoadvance_enabled, 'branding_info': branding_info, @@ -478,6 +483,7 @@ class VideoBlock( 'cdn_exp_group': cdn_exp_group, 'display_name': self.display_name_with_default, 'download_video_link': download_video_link, + 'is_video_from_same_origin': is_video_from_same_origin, 'handout': self.handout, 'hide_downloads': is_public_view or is_embed, 'id': self.location.html_id(), @@ -746,10 +752,11 @@ class VideoBlock( block_type = 'video' definition_id = runtime.id_generator.create_definition(block_type, url_name) usage_id = runtime.id_generator.create_usage(definition_id) + aside_children = [] if is_pointer_tag(node): filepath = cls._format_filepath(node.tag, name_to_pathname(url_name)) node = cls.load_file(filepath, runtime.resources_fs, usage_id) - runtime.parse_asides(node, definition_id, usage_id, runtime.id_generator) + aside_children = runtime.parse_asides(node, definition_id, usage_id, runtime.id_generator) field_data = cls.parse_video_xml(node, runtime.id_generator) kvs = InheritanceKeyValueStore(initial_values=field_data) field_data = KvsFieldData(kvs) @@ -769,6 +776,9 @@ class VideoBlock( getattr(runtime.id_generator, 'target_course_id', None) ) + if aside_children: + cls.add_applicable_asides_to_block(video, runtime, aside_children) + return video def definition_to_xml(self, resource_fs): # lint-amnesty, pylint: disable=too-many-statements @@ -854,11 +864,15 @@ class VideoBlock( if new_transcripts.get('en'): xml.set('sub', '') - # Update `transcripts` attribute in the xml - xml.set('transcripts', json.dumps(transcripts, sort_keys=True)) - except edxval_api.ValVideoNotFoundError: pass + else: + if transcripts.get('en'): + xml.set('sub', '') + + if transcripts: + # Update `transcripts` attribute in the xml + xml.set('transcripts', json.dumps(transcripts, sort_keys=True)) # Sorting transcripts for easy testing of resulting xml for transcript_language in sorted(transcripts.keys()): @@ -1260,3 +1274,10 @@ class VideoBlock( edx_video_id=self.edx_video_id.strip() ) return None + + +VideoBlock = ( + _ExtractedVideoBlock if settings.USE_EXTRACTED_VIDEO_BLOCK + else _BuiltInVideoBlock +) +VideoBlock.__name__ = "VideoBlock" diff --git a/xmodule/video_block/video_handlers.py b/xmodule/video_block/video_handlers.py index cdda2da65b..97f40ab277 100644 --- a/xmodule/video_block/video_handlers.py +++ b/xmodule/video_block/video_handlers.py @@ -13,13 +13,14 @@ import math from django.core.files.base import ContentFile from django.utils.timezone import now from edxval.api import create_external_video, create_or_update_video_transcript, delete_video_transcript -from opaque_keys.edx.locator import CourseLocator +from opaque_keys.edx.locator import CourseLocator, LibraryLocatorV2 from webob import Response from xblock.core import XBlock from xblock.exceptions import JsonHandlerError from xmodule.exceptions import NotFoundError from xmodule.fields import RelativeTime +from openedx.core.djangoapps.content_libraries import api as lib_api from .transcripts_utils import ( Transcript, @@ -494,108 +495,14 @@ class VideoStudioViewHandlers: no SRT extension or not parse-able by PySRT UnicodeDecodeError: non-UTF8 uploaded file content encoding. """ - _ = self.runtime.service(self, "i18n").ugettext - if dispatch.startswith('translation'): if request.method == 'POST': - error = self.validate_transcript_upload_data(data=request.POST) - if error: - response = Response(json={'error': error}, status=400) - else: - edx_video_id = clean_video_id(request.POST['edx_video_id']) - language_code = request.POST['language_code'] - new_language_code = request.POST['new_language_code'] - transcript_file = request.POST['file'].file - - if not edx_video_id: - # Back-populate the video ID for an external video. - # pylint: disable=attribute-defined-outside-init - self.edx_video_id = edx_video_id = create_external_video(display_name='external video') - - try: - # Convert SRT transcript into an SJSON format - # and upload it to S3. - sjson_subs = Transcript.convert( - content=transcript_file.read().decode('utf-8'), - input_format=Transcript.SRT, - output_format=Transcript.SJSON - ).encode() - create_or_update_video_transcript( - video_id=edx_video_id, - language_code=language_code, - metadata={ - 'file_format': Transcript.SJSON, - 'language_code': new_language_code - }, - file_data=ContentFile(sjson_subs), - ) - payload = { - 'edx_video_id': edx_video_id, - 'language_code': new_language_code - } - self.transcripts[new_language_code] = f'{edx_video_id}-{new_language_code}.srt' - response = Response(json.dumps(payload), status=201) - except (TranscriptsGenerationException, UnicodeDecodeError): - response = Response( - json={ - 'error': _( - 'There is a problem with this transcript file. Try to upload a different file.' - ) - }, - status=400 - ) + response = self._studio_transcript_upload(request) elif request.method == 'DELETE': - request_data = request.json - - if 'lang' not in request_data or 'edx_video_id' not in request_data: - return Response(status=400) - - language = request_data['lang'] - edx_video_id = clean_video_id(request_data['edx_video_id']) - - if edx_video_id: - delete_video_transcript(video_id=edx_video_id, language_code=language) - - if language == 'en': - # remove any transcript file from content store for the video ids - possible_sub_ids = [ - self.sub, # pylint: disable=access-member-before-definition - self.youtube_id_1_0 - ] + get_html5_ids(self.html5_sources) - for sub_id in possible_sub_ids: - remove_subs_from_store(sub_id, self, language) - - # update metadata as `en` can also be present in `transcripts` field - remove_subs_from_store(self.transcripts.pop(language, None), self, language) - - # also empty `sub` field - self.sub = '' # pylint: disable=attribute-defined-outside-init - else: - remove_subs_from_store(self.transcripts.pop(language, None), self, language) - - return Response(status=200) - + response = self._studio_transcript_delete(request) elif request.method == 'GET': - language = request.GET.get('language_code') - if not language: - return Response(json={'error': _('Language is required.')}, status=400) - - try: - transcript_content, transcript_name, mime_type = get_transcript( - video=self, lang=language, output_format=Transcript.SRT - ) - response = Response(transcript_content, headerlist=[ - ( - 'Content-Disposition', - f'attachment; filename="{transcript_name}"' - ), - ('Content-Language', language), - ('Content-Type', mime_type) - ]) - except (UnicodeDecodeError, TranscriptsGenerationException, NotFoundError): - response = Response(status=404) - + response = self._studio_transcript_get(request) else: # Any other HTTP method is not allowed. response = Response(status=404) @@ -605,3 +512,165 @@ class VideoStudioViewHandlers: response = Response(status=404) return response + + def _save_transcript_field(self): + """ + Save `transcripts` block field. + """ + field = self.fields['transcripts'] + if self.transcripts: + transcripts_copy = self.transcripts.copy() + # Need to delete to overwrite, it's weird behavior, + # but it only works like this. + field.delete_from(self) + field.write_to(self, transcripts_copy) + else: + field.delete_from(self) + + def _studio_transcript_upload(self, request): + """ + Upload transcript. Used in "POST" method in `studio_transcript` + """ + _ = self.runtime.service(self, "i18n").ugettext + error = self.validate_transcript_upload_data(data=request.POST) + if error: + response = Response(json={'error': error}, status=400) + else: + edx_video_id = clean_video_id(request.POST['edx_video_id']) + language_code = request.POST['language_code'] + new_language_code = request.POST['new_language_code'] + transcript_file = request.POST['file'].file + + is_library = isinstance(self.usage_key.context_key, LibraryLocatorV2) + + if is_library: + filename = f'transcript-{new_language_code}.srt' + else: + if not edx_video_id: + # Back-populate the video ID for an external video. + # pylint: disable=attribute-defined-outside-init + self.edx_video_id = edx_video_id = create_external_video(display_name='external video') + filename = f'{edx_video_id}-{new_language_code}.srt' + + try: + # Convert SRT transcript into an SJSON format + # and upload it to S3. + content = transcript_file.read() + payload = { + 'edx_video_id': edx_video_id, + 'language_code': new_language_code + } + if is_library: + # Save transcript as static asset in Learning Core if is a library component + filename = f"static/{filename}" + lib_api.add_library_block_static_asset_file( + self.usage_key, + filename, + content, + ) + else: + sjson_subs = Transcript.convert( + content=content.decode('utf-8'), + input_format=Transcript.SRT, + output_format=Transcript.SJSON + ).encode() + create_or_update_video_transcript( + video_id=edx_video_id, + language_code=language_code, + metadata={ + 'file_format': Transcript.SJSON, + 'language_code': new_language_code + }, + file_data=ContentFile(sjson_subs), + ) + + # If a new transcript is added, then both new_language_code and + # language_code fields will have the same value. + if language_code != new_language_code: + self.transcripts.pop(language_code, None) + self.transcripts[new_language_code] = filename + + if is_library: + self._save_transcript_field() + response = Response(json.dumps(payload), status=201) + except (TranscriptsGenerationException, UnicodeDecodeError): + response = Response( + json={ + 'error': _( + 'There is a problem with this transcript file. Try to upload a different file.' + ) + }, + status=400 + ) + return response + + def _studio_transcript_delete(self, request): + """ + Delete transcript. Used in "DELETE" method in `studio_transcript` + """ + request_data = request.json + + if 'lang' not in request_data or 'edx_video_id' not in request_data: + return Response(status=400) + + language = request_data['lang'] + edx_video_id = clean_video_id(request_data['edx_video_id']) + + if edx_video_id: + delete_video_transcript(video_id=edx_video_id, language_code=language) + + if isinstance(self.usage_key.context_key, LibraryLocatorV2): + transcript_name = self.transcripts.pop(language, None) + if transcript_name: + # TODO: In the future, we need a proper XBlock API + # like `self.static_assets.delete(...)` instead of coding + # these runtime-specific/library-specific APIs. + lib_api.delete_library_block_static_asset_file( + self.usage_key, + f"static/{transcript_name}", + ) + self._save_transcript_field() + else: + if language == 'en': + # remove any transcript file from content store for the video ids + possible_sub_ids = [ + self.sub, # pylint: disable=access-member-before-definition + self.youtube_id_1_0 + ] + get_html5_ids(self.html5_sources) + for sub_id in possible_sub_ids: + remove_subs_from_store(sub_id, self, language) + + # update metadata as `en` can also be present in `transcripts` field + remove_subs_from_store(self.transcripts.pop(language, None), self, language) + + # also empty `sub` field + self.sub = '' # pylint: disable=attribute-defined-outside-init + else: + remove_subs_from_store(self.transcripts.pop(language, None), self, language) + + return Response(status=200) + + def _studio_transcript_get(self, request): + """ + Get transcript. Used in "GET" method in `studio_transcript` + """ + _ = self.runtime.service(self, "i18n").ugettext + language = request.GET.get('language_code') + if not language: + return Response(json={'error': _('Language is required.')}, status=400) + + try: + transcript_content, transcript_name, mime_type = get_transcript( + video=self, lang=language, output_format=Transcript.SRT + ) + response = Response(transcript_content, headerlist=[ + ( + 'Content-Disposition', + f'attachment; filename="{transcript_name}"' + ), + ('Content-Language', language), + ('Content-Type', mime_type) + ]) + except (UnicodeDecodeError, TranscriptsGenerationException, NotFoundError): + response = Response(status=404) + return response diff --git a/xmodule/word_cloud_block.py b/xmodule/word_cloud_block.py index d678f2a9a9..37e82400df 100644 --- a/xmodule/word_cloud_block.py +++ b/xmodule/word_cloud_block.py @@ -6,23 +6,27 @@ If student does not yet answered - `num_inputs` numbers of text inputs. If student have answered - words he entered and cloud. """ +from xblocks_contrib.word_cloud import WordCloudBlock as _ExtractedWordCloudBlock import json import logging +from django.conf import settings from web_fragments.fragment import Fragment from xblock.core import XBlock from xblock.fields import Boolean, Dict, Integer, List, Scope, String + from xmodule.editing_block import EditingMixin from xmodule.raw_block import EmptyDataRawMixin from xmodule.util.builtin_assets import add_webpack_js_to_fragment, add_css_to_fragment -from xmodule.xml_block import XmlMixin from xmodule.x_module import ( ResourceTemplates, shim_xmodule_js, XModuleMixin, XModuleToXBlockMixin, ) +from xmodule.xml_block import XmlMixin + log = logging.getLogger(__name__) # Make '_' a no-op so we can scrape strings. Using lambda instead of @@ -41,7 +45,7 @@ def pretty_bool(value): @XBlock.needs('mako') -class WordCloudBlock( # pylint: disable=abstract-method +class _BuiltInWordCloudBlock( # pylint: disable=abstract-method EmptyDataRawMixin, XmlMixin, EditingMixin, @@ -53,6 +57,8 @@ class WordCloudBlock( # pylint: disable=abstract-method Word Cloud XBlock. """ + is_extracted = False + display_name = String( display_name=_("Display Name"), help=_("The display name for this component."), @@ -308,3 +314,10 @@ class WordCloudBlock( # pylint: disable=abstract-method xblock_body["content_type"] = "Word Cloud" return xblock_body + + +WordCloudBlock = ( + _ExtractedWordCloudBlock if settings.USE_EXTRACTED_WORD_CLOUD_BLOCK + else _BuiltInWordCloudBlock +) +WordCloudBlock.__name__ = "WordCloudBlock" diff --git a/xmodule/x_module.py b/xmodule/x_module.py index d25012275a..d1d23342b7 100644 --- a/xmodule/x_module.py +++ b/xmodule/x_module.py @@ -1,5 +1,6 @@ # lint-amnesty, pylint: disable=missing-module-docstring +import importlib.resources as resources import logging import os import time @@ -13,7 +14,6 @@ from django.conf import settings from lxml import etree from opaque_keys.edx.asides import AsideDefinitionKeyV2, AsideUsageKeyV2 from opaque_keys.edx.keys import UsageKey -from pkg_resources import resource_isdir, resource_filename from web_fragments.fragment import Fragment from webob import Response from webob.multidict import MultiDict @@ -856,16 +856,16 @@ class ResourceTemplates: def get_template_dir(cls): # lint-amnesty, pylint: disable=missing-function-docstring if getattr(cls, 'template_dir_name', None): dirname = os.path.join('templates', cls.template_dir_name) # lint-amnesty, pylint: disable=no-member - if not resource_isdir(__name__, dirname): + template_path = resources.files(__name__.rsplit('.', 1)[0]) / dirname + + if not template_path.is_dir(): log.warning("No resource directory {dir} found when loading {cls_name} templates".format( dir=dirname, cls_name=cls.__name__, )) - return None - else: - return dirname - else: - return None + return + return dirname + return @classmethod def get_template_dirpaths(cls): @@ -874,8 +874,11 @@ class ResourceTemplates: """ template_dirpaths = [] template_dirname = cls.get_template_dir() - if template_dirname and resource_isdir(__name__, template_dirname): - template_dirpaths.append(resource_filename(__name__, template_dirname)) + if template_dirname: + template_path = resources.files(__name__.rsplit('.', 1)[0]) / template_dirname + if template_path.is_dir(): + with resources.as_file(template_path) as template_real_path: + template_dirpaths.append(str(template_real_path)) custom_template_dir = cls.get_custom_template_dir() if custom_template_dir: diff --git a/xmodule/xml_block.py b/xmodule/xml_block.py index 2753d455ad..4093d982fa 100644 --- a/xmodule/xml_block.py +++ b/xmodule/xml_block.py @@ -123,7 +123,11 @@ class XmlMixin: # places in the platform rely on it. 'course', 'org', 'url_name', 'filename', # Used for storing xml attributes between import and export, for roundtrips - 'xml_attributes') + 'xml_attributes', + # Used by _import_xml_node_to_parent in cms/djangoapps/contentstore/helpers.py to prevent + # XmlMixin from treating some XML nodes as "pointer nodes". + "x-is-pointer-node", + ) # This is a categories to fields map that contains the block category specific fields which should not be # cleaned and/or override while adding xml to node. @@ -377,14 +381,21 @@ class XmlMixin: ) if aside_children: - asides_tags = [x.tag for x in aside_children] - asides = runtime.get_asides(xblock) - for asd in asides: - if asd.scope_ids.block_type in asides_tags: - xblock.add_aside(asd) + cls.add_applicable_asides_to_block(xblock, runtime, aside_children) return xblock + @classmethod + def add_applicable_asides_to_block(cls, block, runtime, aside_children): + """ + Add asides to the block. Moved this out of the parse_xml method to use it in the VideoBlock.parse_xml + """ + asides_tags = [aside_child.tag for aside_child in aside_children] + asides = runtime.get_asides(block) + for aside in asides: + if aside.scope_ids.block_type in asides_tags: + block.add_aside(aside) + @classmethod def parse_xml_new_runtime(cls, node, runtime, keys): """