diff --git a/.github/actions/unit-tests/action.yml b/.github/actions/unit-tests/action.yml deleted file mode 100644 index 7d217cd26f8c..000000000000 --- a/.github/actions/unit-tests/action.yml +++ /dev/null @@ -1,35 +0,0 @@ -name: "Run unit tests" -description: "shared steps to run unit tests on both Github hosted and self hosted runners." -runs: - using: "composite" - steps: - - name: set settings path - shell: bash - run: | - echo "settings_path=$(python scripts/unit_test_shards_parser.py --shard-name=${{ matrix.shard_name }} --output settings )" >> $GITHUB_ENV - - - name: get unit tests for shard - shell: bash - run: | - echo "unit_test_paths=$(python scripts/unit_test_shards_parser.py --shard-name=${{ matrix.shard_name }} )" >> $GITHUB_ENV - - - name: run tests - shell: bash - run: | - python -Wd -m pytest -p no:randomly --ds=${{ env.settings_path }} ${{ env.unit_test_paths }} --cov=. - - - name: rename warnings json file - if: success() - shell: bash - run: | - cd test_root/log - mv pytest_warnings.json pytest_warnings_${{ matrix.shard_name }}.json - - - name: save pytest warnings json file - if: success() - uses: actions/upload-artifact@v4 - with: - name: pytest-warnings-json - path: | - test_root/log/pytest_warnings*.json - overwrite: true diff --git a/.github/actions/verify-tests-count/action.yml b/.github/actions/verify-tests-count/action.yml deleted file mode 100644 index d299f1ca11c5..000000000000 --- a/.github/actions/verify-tests-count/action.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: "Verify unit tests count" -description: "shared steps to verify unit tests count on both Github hosted and self hosted runners." -runs: - using: "composite" - steps: - - name: collect tests from all modules - shell: bash - run: | - echo "root_cms_unit_tests_count=$(pytest --disable-warnings --collect-only --ds=cms.envs.test cms/ -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/ pavelib/ -q | head -n -2 | wc -l)" >> $GITHUB_ENV - - - name: get GHA unit test paths - shell: bash - run: | - echo "cms_unit_test_paths=$(python scripts/gha_unit_tests_collector.py --cms-only)" >> $GITHUB_ENV - echo "lms_unit_test_paths=$(python scripts/gha_unit_tests_collector.py --lms-only)" >> $GITHUB_ENV - - - name: collect tests from GHA unit test shards - shell: bash - run: | - echo "cms_unit_tests_count=$(pytest --disable-warnings --collect-only --ds=cms.envs.test ${{ env.cms_unit_test_paths }} -q | head -n -2 | wc -l)" >> $GITHUB_ENV - echo "lms_unit_tests_count=$(pytest --disable-warnings --collect-only --ds=lms.envs.test ${{ env.lms_unit_test_paths }} -q | head -n -2 | wc -l)" >> $GITHUB_ENV - - - name: add unit tests count - shell: bash - run: | - echo "root_all_unit_tests_count=$((${{ env.root_cms_unit_tests_count }}+${{ env.root_lms_unit_tests_count }}))" >> $GITHUB_ENV - echo "shards_all_unit_tests_count=$((${{ env.cms_unit_tests_count }}+${{ env.lms_unit_tests_count }}))" >> $GITHUB_ENV - - - name: print unit tests count - shell: bash - run: | - echo CMS unit tests from root: ${{ env.root_cms_unit_tests_count }} - echo LMS unit tests from root: ${{ env.root_lms_unit_tests_count }} - echo CMS unit tests from shards: ${{ env.cms_unit_tests_count }} - echo LMS unit tests from shards: ${{ env.lms_unit_tests_count }} - echo All root unit tests count: ${{ env.root_all_unit_tests_count }} - echo All shards unit tests count: ${{ env.shards_all_unit_tests_count }} - - - name: fail the check - shell: bash - if: ${{ env.root_all_unit_tests_count != env.shards_all_unit_tests_count }} - run: | - echo "::error title='Unit test modules in unit-test-shards.json (unit-tests.yml workflow) are outdated'::unit tests running in unit-tests - workflow don't match the count for unit tests for entire edx-platform suite, please update the unit-test-shards.json under .github/workflows - to add any missing apps and match the count. for more details please take a look at scripts/gha-shards-readme.md" - exit 1 diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 5345413e561c..d0fde72ac11d 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -7,15 +7,3 @@ updates: interval: "weekly" reviewers: - "openedx/arbi-bom" - - package-ecosystem: "github-actions" - directory: "/.github/actions/unit-tests/" - schedule: - interval: "weekly" - reviewers: - - "openedx/arbi-bom" - - package-ecosystem: "github-actions" - directory: "/.github/actions/verify-tests-count/" - schedule: - interval: "weekly" - reviewers: - - "openedx/arbi-bom" diff --git a/.github/workflows/add-remove-label-on-comment.yml b/.github/workflows/add-remove-label-on-comment.yml index a658064f09f0..0f369db7d293 100644 --- a/.github/workflows/add-remove-label-on-comment.yml +++ b/.github/workflows/add-remove-label-on-comment.yml @@ -17,3 +17,4 @@ on: jobs: add_remove_labels: uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master + diff --git a/.github/workflows/ci-static-analysis.yml b/.github/workflows/ci-static-analysis.yml index 0ae7363601c8..7e768a456463 100644 --- a/.github/workflows/ci-static-analysis.yml +++ b/.github/workflows/ci-static-analysis.yml @@ -27,7 +27,7 @@ jobs: - name: Get pip cache dir id: pip-cache-dir - run: echo "::set-output name=dir::$(pip cache dir)" + run: echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache pip dependencies id: cache-dependencies diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml index 243f56262a1f..4d025e540163 100644 --- a/.github/workflows/js-tests.yml +++ b/.github/workflows/js-tests.yml @@ -13,7 +13,7 @@ jobs: strategy: matrix: os: [ubuntu-20.04] - node-version: [18] + node-version: [18, 20] python-version: - "3.11" @@ -49,7 +49,7 @@ jobs: - name: Get pip cache dir id: pip-cache-dir run: | - echo "::set-output name=dir::$(pip cache dir)" + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache pip dependencies id: cache-dependencies diff --git a/.github/workflows/lint-imports.yml b/.github/workflows/lint-imports.yml index 24f241016bc7..8ead8396bf39 100644 --- a/.github/workflows/lint-imports.yml +++ b/.github/workflows/lint-imports.yml @@ -28,7 +28,7 @@ jobs: - name: Get pip cache dir id: pip-cache-dir - run: echo "::set-output name=dir::$(pip cache dir)" + run: echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache pip dependencies id: cache-dependencies diff --git a/.github/workflows/migrations-check.yml b/.github/workflows/migrations-check.yml index 45919c8f65f6..183b90effa29 100644 --- a/.github/workflows/migrations-check.yml +++ b/.github/workflows/migrations-check.yml @@ -85,7 +85,7 @@ jobs: - name: Get pip cache dir id: pip-cache-dir run: | - echo "::set-output name=dir::$(pip cache dir)" + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache pip dependencies id: cache-dependencies diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml index 90e457bf0b35..eeb53c24ed98 100644 --- a/.github/workflows/pylint-checks.yml +++ b/.github/workflows/pylint-checks.yml @@ -18,7 +18,7 @@ jobs: - module-name: lms-2 path: "--django-settings-module=lms.envs.test 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" - module-name: openedx-1 - path: "--django-settings-module=lms.envs.test 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/demographics/ 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: "--django-settings-module=lms.envs.test 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/" - module-name: openedx-2 path: "--django-settings-module=lms.envs.test 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/learner_pathway/ openedx/core/djangoapps/notifications/ openedx/core/djangoapps/staticfiles/ openedx/core/djangoapps/content_tagging/" - module-name: common @@ -44,7 +44,7 @@ jobs: - name: Get pip cache dir id: pip-cache-dir run: | - echo "::set-output name=dir::$(pip cache dir)" + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache pip dependencies id: cache-dependencies diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index b9e2250cb169..cf8ffd5d2910 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -16,7 +16,7 @@ jobs: os: [ubuntu-20.04] python-version: - "3.11" - node-version: [18] + node-version: [20] steps: - uses: actions/checkout@v4 @@ -45,7 +45,7 @@ jobs: - name: Get pip cache dir id: pip-cache-dir run: | - echo "::set-output name=dir::$(pip cache dir)" + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache pip dependencies id: cache-dependencies diff --git a/.github/workflows/static-assets-check.yml b/.github/workflows/static-assets-check.yml index e6cb1a3b4450..7bbfd3369b6b 100644 --- a/.github/workflows/static-assets-check.yml +++ b/.github/workflows/static-assets-check.yml @@ -15,7 +15,7 @@ jobs: os: [ubuntu-20.04] python-version: - "3.11" - node-version: [18] + node-version: [18, 20] npm-version: [10.5.x] mongo-version: - "7.0" @@ -58,7 +58,7 @@ jobs: - name: Get pip cache dir id: pip-cache-dir run: | - echo "::set-output name=dir::$(pip cache dir)" + echo "dir=$(pip cache dir)" >> $GITHUB_OUTPUT - name: Cache pip dependencies id: cache-dependencies diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json index 8b6c1694da28..4ab126cb4715 100644 --- a/.github/workflows/unit-test-shards.json +++ b/.github/workflows/unit-test-shards.json @@ -106,7 +106,6 @@ "openedx/core/djangoapps/course_live/", "openedx/core/djangoapps/dark_lang/", "openedx/core/djangoapps/debug/", - "openedx/core/djangoapps/demographics/", "openedx/core/djangoapps/discussions/", "openedx/core/djangoapps/django_comment_common/", "openedx/core/djangoapps/embargo/", @@ -187,7 +186,6 @@ "openedx/core/djangoapps/credit/", "openedx/core/djangoapps/dark_lang/", "openedx/core/djangoapps/debug/", - "openedx/core/djangoapps/demographics/", "openedx/core/djangoapps/discussions/", "openedx/core/djangoapps/django_comment_common/", "openedx/core/djangoapps/embargo/", diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index c6513baa0fbb..3e442b75d4e7 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -99,8 +99,36 @@ jobs: run: | pip freeze - - name: Setup and run tests - uses: ./.github/actions/unit-tests + - name: set settings path + shell: bash + run: | + echo "settings_path=$(python scripts/unit_test_shards_parser.py --shard-name=${{ matrix.shard_name }} --output settings )" >> $GITHUB_ENV + + - name: get unit tests for shard + shell: bash + run: | + echo "unit_test_paths=$(python scripts/unit_test_shards_parser.py --shard-name=${{ matrix.shard_name }} )" >> $GITHUB_ENV + + - name: run tests + shell: bash + run: | + python -Wd -m pytest -p no:randomly --ds=${{ env.settings_path }} ${{ env.unit_test_paths }} --cov=. + + - name: rename warnings json file + if: success() + shell: bash + run: | + cd test_root/log + mv pytest_warnings.json pytest_warnings_${{ matrix.shard_name }}.json + + - name: save pytest warnings json file + if: success() + uses: actions/upload-artifact@v4 + with: + name: pytest-warnings-json-${{ matrix.shard_name }} + path: | + test_root/log/pytest_warnings*.json + overwrite: true - name: Renaming coverage data file run: | @@ -109,8 +137,8 @@ jobs: - name: Upload coverage uses: actions/upload-artifact@v4 with: - name: coverage - path: reports/${{matrix.shard_name}}.coverage + name: coverage-${{ matrix.shard_name }} + path: reports/${{ matrix.shard_name }}.coverage overwrite: true collect-and-verify: @@ -130,8 +158,49 @@ jobs: run: | make test-requirements - - name: verify unit tests count - uses: ./.github/actions/verify-tests-count + - name: collect tests from all modules + shell: bash + run: | + echo "root_cms_unit_tests_count=$(pytest --disable-warnings --collect-only --ds=cms.envs.test cms/ -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/ pavelib/ -q | head -n -2 | wc -l)" >> $GITHUB_ENV + + - name: get GHA unit test paths + shell: bash + run: | + echo "cms_unit_test_paths=$(python scripts/gha_unit_tests_collector.py --cms-only)" >> $GITHUB_ENV + echo "lms_unit_test_paths=$(python scripts/gha_unit_tests_collector.py --lms-only)" >> $GITHUB_ENV + + - name: collect tests from GHA unit test shards + shell: bash + run: | + echo "cms_unit_tests_count=$(pytest --disable-warnings --collect-only --ds=cms.envs.test ${{ env.cms_unit_test_paths }} -q | head -n -2 | wc -l)" >> $GITHUB_ENV + echo "lms_unit_tests_count=$(pytest --disable-warnings --collect-only --ds=lms.envs.test ${{ env.lms_unit_test_paths }} -q | head -n -2 | wc -l)" >> $GITHUB_ENV + + - name: add unit tests count + shell: bash + run: | + echo "root_all_unit_tests_count=$((${{ env.root_cms_unit_tests_count }}+${{ env.root_lms_unit_tests_count }}))" >> $GITHUB_ENV + echo "shards_all_unit_tests_count=$((${{ env.cms_unit_tests_count }}+${{ env.lms_unit_tests_count }}))" >> $GITHUB_ENV + + - name: print unit tests count + shell: bash + run: | + echo CMS unit tests from root: ${{ env.root_cms_unit_tests_count }} + echo LMS unit tests from root: ${{ env.root_lms_unit_tests_count }} + echo CMS unit tests from shards: ${{ env.cms_unit_tests_count }} + echo LMS unit tests from shards: ${{ env.lms_unit_tests_count }} + echo All root unit tests count: ${{ env.root_all_unit_tests_count }} + echo All shards unit tests count: ${{ env.shards_all_unit_tests_count }} + + - name: fail the check + shell: bash + if: ${{ env.root_all_unit_tests_count != env.shards_all_unit_tests_count }} + run: | + echo "::error title='Unit test modules in unit-test-shards.json (unit-tests.yml workflow) are outdated'::unit tests running in unit-tests + workflow don't match the count for unit tests for entire edx-platform suite, please update the unit-test-shards.json under .github/workflows + 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 @@ -156,7 +225,8 @@ jobs: - name: collect pytest warnings files uses: actions/download-artifact@v4 with: - name: pytest-warnings-json + pattern: pytest-warnings-json-* + merge-multiple: true path: test_root/log - name: display structure of downloaded files @@ -175,6 +245,24 @@ jobs: reports/pytest_warnings/warning_report_all.html overwrite: true + merge-artifacts: + runs-on: ubuntu-20.04 + needs: [compile-warnings-report] + steps: + - name: Merge Pytest Warnings JSON Artifacts + uses: actions/upload-artifact/merge@v4 + with: + name: pytest-warnings-json + pattern: pytest-warnings-json-* + delete-merged: true + + - name: Merge Coverage Artifacts + uses: actions/upload-artifact/merge@v4 + with: + name: coverage + pattern: coverage-* + delete-merged: true + # Combine and upload coverage reports. coverage: if: (github.repository == 'edx/edx-platform-private') || (github.repository == 'openedx/edx-platform' && (startsWith(github.base_ref, 'open-release') == false)) @@ -196,7 +284,8 @@ jobs: - name: Download all artifacts uses: actions/download-artifact@v4 with: - name: coverage + pattern: coverage-* + merge-multiple: true path: reports - name: Install Python dependencies diff --git a/Makefile b/Makefile index 098236fed8cb..15bab5df67a9 100644 --- a/Makefile +++ b/Makefile @@ -50,7 +50,7 @@ pull_xblock_translations: ## pull xblock translations via atlas clean_translations: ## Remove existing translations to prepare for a fresh pull # Removes core edx-platform translations but keeps config files and Esperanto (eo) test translations - find conf/locale -mindepth 1 -maxdepth 1 -type d -a ! -name eo -exec rm -rf {} + + find conf/locale/ -type f \! -path '*/eo/*' \( -name '*.mo' -o -name '*.po' \) -delete # Removes the xblocks/plugins and js-compiled translations rm -rf conf/plugins-locale cms/static/js/i18n/ lms/static/js/i18n/ cms/static/js/xblock.v1-i18n/ lms/static/js/xblock.v1-i18n/ @@ -137,6 +137,8 @@ compile-requirements: pre-requirements $(COMMON_CONSTRAINTS_TXT) ## Re-compile * mv requirements/common_constraints.tmp requirements/common_constraints.txt sed 's/Django<4.0//g' requirements/common_constraints.txt > requirements/common_constraints.tmp mv requirements/common_constraints.tmp requirements/common_constraints.txt + sed 's/event-tracking<2.4.1//g' requirements/common_constraints.txt > requirements/common_constraints.tmp + mv requirements/common_constraints.tmp requirements/common_constraints.txt pip-compile -v --allow-unsafe ${COMPILE_OPTS} -o requirements/pip.txt requirements/pip.in pip install -r requirements/pip.txt diff --git a/cms/djangoapps/api/v1/tests/test_serializers/test_course_runs.py b/cms/djangoapps/api/v1/tests/test_serializers/test_course_runs.py index 619e6bc21869..c648b2e5b875 100644 --- a/cms/djangoapps/api/v1/tests/test_serializers/test_course_runs.py +++ b/cms/djangoapps/api/v1/tests/test_serializers/test_course_runs.py @@ -2,7 +2,6 @@ import datetime - import ddt import pytz from django.test import RequestFactory diff --git a/cms/djangoapps/contentstore/config/waffle.py b/cms/djangoapps/contentstore/config/waffle.py index 49dbab571539..f84290ba83ae 100644 --- a/cms/djangoapps/contentstore/config/waffle.py +++ b/cms/djangoapps/contentstore/config/waffle.py @@ -53,16 +53,3 @@ # .. toggle_warning: Flag course_experience.relative_dates should also be active for relative dates functionalities to work. # .. toggle_tickets: https://openedx.atlassian.net/browse/AA-844 CUSTOM_RELATIVE_DATES = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.custom_relative_dates', __name__) - - -# .. toggle_name: studio.enable_course_update_notifications -# .. toggle_implementation: CourseWaffleFlag -# .. toggle_default: False -# .. toggle_description: Waffle flag to enable course update notifications. -# .. toggle_use_cases: temporary, open_edx -# .. toggle_creation_date: 14-Feb-2024 -# .. toggle_target_removal_date: 14-Mar-2024 -ENABLE_COURSE_UPDATE_NOTIFICATIONS = CourseWaffleFlag( - f'{WAFFLE_NAMESPACE}.enable_course_update_notifications', - __name__ -) diff --git a/cms/djangoapps/contentstore/course_info_model.py b/cms/djangoapps/contentstore/course_info_model.py index 77a6a00c4b58..e8a359d80564 100644 --- a/cms/djangoapps/contentstore/course_info_model.py +++ b/cms/djangoapps/contentstore/course_info_model.py @@ -19,7 +19,6 @@ from django.http import HttpResponseBadRequest from django.utils.translation import gettext as _ -from cms.djangoapps.contentstore.config.waffle import ENABLE_COURSE_UPDATE_NOTIFICATIONS from cms.djangoapps.contentstore.utils import track_course_update_event, send_course_update_notification from openedx.core.lib.xblock_utils import get_course_update_items from xmodule.html_block import CourseInfoBlock # lint-amnesty, pylint: disable=wrong-import-order @@ -93,10 +92,9 @@ def update_course_updates(location, update, passed_id=None, user=None, request_m track_course_update_event(location.course_key, user, course_update_dict) # send course update notification - if ENABLE_COURSE_UPDATE_NOTIFICATIONS.is_enabled(location.course_key): - send_course_update_notification( - location.course_key, course_update_dict["content"], user, - ) + send_course_update_notification( + location.course_key, course_update_dict["content"], user, + ) # remove status key if "status" in course_update_dict: diff --git a/cms/djangoapps/contentstore/docs/diagrams/visible_date_and_certificate_available_date.dsl b/cms/djangoapps/contentstore/docs/diagrams/certificate_available_date.dsl similarity index 86% rename from cms/djangoapps/contentstore/docs/diagrams/visible_date_and_certificate_available_date.dsl rename to cms/djangoapps/contentstore/docs/diagrams/certificate_available_date.dsl index e55b7ad8dd9e..bc082e349fd0 100644 --- a/cms/djangoapps/contentstore/docs/diagrams/visible_date_and_certificate_available_date.dsl +++ b/cms/djangoapps/contentstore/docs/diagrams/certificate_available_date.dsl @@ -1,6 +1,6 @@ /* - * This is a high level diagram visualizing how the `CERTIFICATE_AVAILBLE_DATE` and "visible date" attribute updates - * are updated internally and transmit to the Credentials IDA. + * This is a high level diagram visualizing how the `CERTIFICATE_AVAILBLE_DATE` update is + * updated internally and transmitted to the Credentials IDA. * * It is written using Structurizr DSL (https://structurizr.org/). */ @@ -33,9 +33,7 @@ workspace { co_app -> modulestore "Retrieves course details from Mongo" co_app -> monolith_db "Updates CourseOverview record" co_app -> programs_app "Emits COURSE_CERT_DATE_CHANGED signal" - programs_app -> celery "Enqueue UPDATE_CERTIFICATE_VISIBLE_DATE task" programs_app -> celery "Enqueue UPDATE_CERTIFICATE_AVAILABLE_DATE task" - celery -> credentials "REST requests to update `visible_date` attributes" celery -> credentials "REST request to update `certificate_available_date` setting" } diff --git a/cms/djangoapps/contentstore/docs/diagrams/rendered/certificate_available_date.png b/cms/djangoapps/contentstore/docs/diagrams/rendered/certificate_available_date.png new file mode 100644 index 000000000000..988d9e5dc3c4 Binary files /dev/null and b/cms/djangoapps/contentstore/docs/diagrams/rendered/certificate_available_date.png differ diff --git a/cms/djangoapps/contentstore/docs/diagrams/rendered/visible_date_and_certificate_available_date.png b/cms/djangoapps/contentstore/docs/diagrams/rendered/visible_date_and_certificate_available_date.png deleted file mode 100644 index d56eda232e0e..000000000000 Binary files a/cms/djangoapps/contentstore/docs/diagrams/rendered/visible_date_and_certificate_available_date.png and /dev/null differ diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py index 1afc51ed77af..0aa06d8b8dcc 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py @@ -51,6 +51,7 @@ class CourseHomeSerializer(serializers.Serializer): allow_empty=True ) archived_courses = CourseCommonSerializer(required=False, many=True) + can_access_advanced_settings = serializers.BooleanField() can_create_organizations = serializers.BooleanField() course_creator_status = serializers.CharField() courses = CourseCommonSerializer(required=False, many=True) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/home.py b/cms/djangoapps/contentstore/rest_api/v1/views/home.py index d41ceb2647c5..d72042cff611 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/home.py @@ -52,6 +52,7 @@ def get(self, request: Request): "allow_unicode_course_id": false, "allowed_organizations": [], "archived_courses": [], + "can_access_advanced_settings": true, "can_create_organizations": true, "course_creator_status": "granted", "courses": [], 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 1b8bfaa84728..a8b4cf5e3933 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 @@ -44,6 +44,7 @@ def test_home_page_courses_response(self): "allow_unicode_course_id": False, "allowed_organizations": [], "archived_courses": [], + "can_access_advanced_settings": True, "can_create_organizations": True, "course_creator_status": "granted", "courses": [], diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py index 66b5f46128c8..8e220a334c06 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py @@ -277,18 +277,14 @@ def test_update_exam_settings_invalid_value(self): # response is correct assert response.status_code == status.HTTP_400_BAD_REQUEST - self.assertDictEqual( - response.data, + self.assertIn( { - "detail": [ - { - "proctoring_provider": ( - "The selected proctoring provider, notvalidprovider, is not a valid provider. " - "Please select from one of ['test_proctoring_provider']." - ) - } - ] + "proctoring_provider": ( + "The selected proctoring provider, notvalidprovider, is not a valid provider. " + "Please select from one of ['test_proctoring_provider']." + ) }, + response.data['detail'], ) # course settings have been updated @@ -408,18 +404,14 @@ def test_400_for_disabled_lti(self): # response is correct assert response.status_code == status.HTTP_400_BAD_REQUEST - self.assertDictEqual( - response.data, + self.assertIn( { - "detail": [ - { - "proctoring_provider": ( - "The selected proctoring provider, lti_external, is not a valid provider. " - "Please select from one of ['null']." - ) - } - ] + "proctoring_provider": ( + "The selected proctoring provider, lti_external, is not a valid provider. " + "Please select from one of ['null']." + ) }, + response.data['detail'], ) # course settings have been updated diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index 450040c80374..9c478ddfe5d7 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -9,6 +9,7 @@ from django.conf import settings from django.test import TestCase 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 path import Path as path @@ -19,7 +20,11 @@ from cms.djangoapps.contentstore import utils from cms.djangoapps.contentstore.tasks import ALL_ALLOWED_XBLOCKS, validate_course_olx from cms.djangoapps.contentstore.tests.utils import TEST_DATA_DIR, CourseTestCase +from cms.djangoapps.contentstore.utils import send_course_update_notification +from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import GlobalStaffFactory, InstructorFactory, UserFactory +from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS +from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, Notification from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context 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 @@ -927,3 +932,32 @@ def test_update_course_details_instructor_paced(self, mock_update): utils.update_course_details(mock_request, self.course.id, payload, None) mock_update.assert_called_once_with(self.course.id, payload, mock_request.user) + + +@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) +class CourseUpdateNotificationTests(ModuleStoreTestCase): + """ + Unit tests for the course_update notification. + """ + + def setUp(self): + """ + Setup the test environment. + """ + super().setUp() + self.user = UserFactory() + self.course = CourseFactory.create(org='testorg', number='testcourse', run='testrun') + CourseNotificationPreference.objects.create(user_id=self.user.id, course_id=self.course.id) + + def test_course_update_notification_sent(self): + """ + Test that the course_update notification is sent. + """ + user = UserFactory() + CourseEnrollment.enroll(user=user, course_key=self.course.id) + assert Notification.objects.all().count() == 0 + content = "

content

" + send_course_update_notification(self.course.id, content, self.user) + assert Notification.objects.all().count() == 1 + notification = Notification.objects.first() + assert notification.content == "

content

" diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index c55a0a8a2238..7c3a369fed62 100644 --- a/cms/djangoapps/contentstore/toggles.py +++ b/cms/djangoapps/contentstore/toggles.py @@ -159,6 +159,18 @@ def use_new_problem_editor(): 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 diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 9ed3f1ce4b36..214193918eb4 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -11,16 +11,19 @@ from urllib.parse import quote_plus from uuid import uuid4 +from bs4 import BeautifulSoup from django.conf import settings from django.core.exceptions import ValidationError from django.urls import reverse from django.utils import translation +from django.utils.text import Truncator 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 openedx_events.content_authoring.data import DuplicatedXBlockData from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED @@ -1534,6 +1537,7 @@ def get_library_context(request, request_is_json=False): ) from cms.djangoapps.contentstore.views.library import ( LIBRARIES_ENABLED, + user_can_view_create_library_button, ) libraries = _accessible_libraries_iter(request.user) if LIBRARIES_ENABLED else [] @@ -1547,7 +1551,7 @@ def get_library_context(request, request_is_json=False): 'in_process_course_actions': [], 'courses': [], 'libraries_enabled': LIBRARIES_ENABLED, - 'show_new_library_button': LIBRARIES_ENABLED and request.user.is_active, + 'show_new_library_button': user_can_view_create_library_button(request.user) and request.user.is_active, 'user': request.user, 'request_course_creator_url': reverse('request_course_creator'), 'course_creator_status': _get_course_creator_status(request.user), @@ -1670,7 +1674,7 @@ def get_home_context(request, no_course=False): LIBRARY_AUTHORING_MICROFRONTEND_URL, LIBRARIES_ENABLED, should_redirect_to_library_authoring_mfe, - user_can_create_library, + user_can_view_create_library_button, ) active_courses = [] @@ -1699,7 +1703,8 @@ def get_home_context(request, no_course=False): 'library_authoring_mfe_url': LIBRARY_AUTHORING_MICROFRONTEND_URL, 'taxonomy_list_mfe_url': get_taxonomy_list_url(), 'libraries': libraries, - 'show_new_library_button': user_can_create_library(user) and not should_redirect_to_library_authoring_mfe(), + 'show_new_library_button': user_can_view_create_library_button(user) + and not should_redirect_to_library_authoring_mfe(), 'user': user, 'request_course_creator_url': reverse('request_course_creator'), 'course_creator_status': _get_course_creator_status(user), @@ -1711,6 +1716,7 @@ def get_home_context(request, no_course=False): 'allowed_organizations': get_allowed_organizations(user), 'allowed_organizations_for_libraries': get_allowed_organizations_for_libraries(user), 'can_create_organizations': user_can_create_organizations(user), + 'can_access_advanced_settings': auth.has_studio_advanced_settings_access(user), } return home_context @@ -2238,11 +2244,34 @@ def track_course_update_event(course_key, user, course_update_content=None): tracker.emit(event_name, event_data) +def clean_html_body(html_body): + """ + Get html body, remove tags and limit to 500 characters + """ + html_body = BeautifulSoup(Truncator(html_body).chars(500, html=True), 'html.parser') + + tags_to_remove = [ + "a", "link", # Link Tags + "img", "picture", "source", # Image Tags + "video", "track", # Video Tags + "audio", # Audio Tags + "embed", "object", "iframe", # Embedded Content + "script" + ] + + # Remove the specified tags while keeping their content + for tag in tags_to_remove: + for match in html_body.find_all(tag): + match.unwrap() + + return str(html_body) + + def send_course_update_notification(course_key, content, user): """ Send course update notification """ - text_content = re.sub(r"(\s| |//)+", " ", html_to_text(content)) + text_content = re.sub(r"(\s| |//)+", " ", clean_html_body(content)) course = modulestore().get_course(course_key) extra_context = { 'author_id': user.id, @@ -2251,10 +2280,10 @@ def send_course_update_notification(course_key, content, user): notification_data = CourseNotificationData( course_key=course_key, content_context={ - "course_update_content": text_content if len(text_content.strip()) < 10 else "Click here to view", + "course_update_content": text_content, **extra_context, }, - notification_type="course_update", + notification_type="course_updates", content_url=f"{settings.LMS_ROOT_URL}/courses/{str(course_key)}/course/updates", app_name="updates", audience_filters={}, diff --git a/cms/djangoapps/contentstore/video_storage_handlers.py b/cms/djangoapps/contentstore/video_storage_handlers.py index b84d7f0f7c31..6827b295b0a8 100644 --- a/cms/djangoapps/contentstore/video_storage_handlers.py +++ b/cms/djangoapps/contentstore/video_storage_handlers.py @@ -42,6 +42,7 @@ update_video_status ) from fs.osfs import OSFS +from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from path import Path as path from pytz import UTC @@ -960,3 +961,32 @@ def _update_pagination_context(request): request.session['VIDEOS_PER_PAGE'] = videos_per_page return JsonResponse() + + +def get_course_youtube_edx_video_ids(course_id): + """ + Get a list of youtube edx_video_ids + """ + error_msg = "Invalid course_key: '%s'." % course_id + try: + course_key = CourseKey.from_string(course_id) + course = modulestore().get_course(course_key) + except InvalidKeyError: + return JsonResponse({'error': error_msg}, status=500) + blocks = [] + block_yt_field = 'youtube_id_1_0' + block_edx_id_field = 'edx_video_id' + if hasattr(course, 'get_children'): + for section in course.get_children(): + for subsection in section.get_children(): + for vertical in subsection.get_children(): + for block in vertical.get_children(): + blocks.append(block) + + edx_video_ids = [] + for block in blocks: + if hasattr(block, block_yt_field) and getattr(block, block_yt_field): + if getattr(block, block_edx_id_field): + edx_video_ids.append(getattr(block, block_edx_id_field)) + + return JsonResponse({'edx_video_ids': edx_video_ids}, status=200) diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index d52cc5eecfd4..4d6c17838e57 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -13,6 +13,7 @@ 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.student.auth import ( @@ -47,7 +48,6 @@ from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import ( handle_xblock, create_xblock_info, - load_services_for_studio, get_block_info, get_xblock, delete_orphans, diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py index 17aa24c5712a..8c314caa6697 100644 --- a/cms/djangoapps/contentstore/views/library.py +++ b/cms/djangoapps/contentstore/views/library.py @@ -10,7 +10,7 @@ from django.conf import settings from django.contrib.auth.decorators import login_required from django.core.exceptions import PermissionDenied -from django.http import Http404, HttpResponseForbidden, HttpResponseNotAllowed +from django.http import Http404, HttpResponseNotAllowed from django.utils.translation import gettext as _ from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.http import require_http_methods @@ -69,22 +69,44 @@ def should_redirect_to_library_authoring_mfe(): ) -def user_can_create_library(user, org=None): +def _user_can_create_library_for_org(user, org=None): """ Helper method for returning the library creation status for a particular user, taking into account the value LIBRARIES_ENABLED. - """ + if the ENABLE_CREATOR_GROUP value is False, then any user can create a library (in any org), + if library creation is enabled. + + if the ENABLE_CREATOR_GROUP value is true, then what a user can do varies by thier role. + + Global Staff: can make libraries in any org. + Course Creator Group Members: can make libraries in any org. + Organization Staff: Can make libraries in the organization for which they are staff. + Course Staff: Can make libraries in the organization which has courses of which they are staff. + Course Admin: Can make libraries in the organization which has courses of which they are Admin. + """ if not LIBRARIES_ENABLED: return False elif user.is_staff: return True elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False): + org_filter_params = {} + if org: + org_filter_params['org'] = org is_course_creator = get_course_creator_status(user) == 'granted' - has_org_staff_role = OrgStaffRole().get_orgs_for_user(user).exists() - has_course_staff_role = UserBasedRole(user=user, role=CourseStaffRole.ROLE).courses_with_role().exists() - has_course_admin_role = UserBasedRole(user=user, role=CourseInstructorRole.ROLE).courses_with_role().exists() - + has_org_staff_role = OrgStaffRole().get_orgs_for_user(user).filter(**org_filter_params).exists() + has_course_staff_role = ( + UserBasedRole(user=user, role=CourseStaffRole.ROLE) + .courses_with_role() + .filter(**org_filter_params) + .exists() + ) + has_course_admin_role = ( + UserBasedRole(user=user, role=CourseInstructorRole.ROLE) + .courses_with_role() + .filter(**org_filter_params) + .exists() + ) return is_course_creator or has_org_staff_role or has_course_staff_role or has_course_admin_role else: # EDUCATOR-1924: DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION, if present. @@ -96,6 +118,22 @@ def user_can_create_library(user, org=None): return not disable_course_creation +def user_can_view_create_library_button(user): + """ + Helper method for displaying the visibilty of the create_library_button. + """ + return _user_can_create_library_for_org(user) + + +def user_can_create_library(user, org): + """ + Helper method for to check if user can create library for given org. + """ + if org is None: + return False + return _user_can_create_library_for_org(user, org) + + @login_required @ensure_csrf_cookie @require_http_methods(('GET', 'POST')) @@ -108,12 +146,8 @@ def library_handler(request, library_key_string=None): raise Http404 # Should never happen because we test the feature in urls.py also if request.method == 'POST': - if not user_can_create_library(request.user): - return HttpResponseForbidden() - if library_key_string is not None: return HttpResponseNotAllowed(("POST",)) - return _create_library(request) else: diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 9c9926a5b25d..acc5fc95dfe3 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -45,7 +45,7 @@ wrap_xblock_aside ) -from ..utils import get_visibility_partition_info, StudioPermissionsService +from ..utils import StudioPermissionsService, get_visibility_partition_info from .access import get_user_role from .session_kv_store import SessionKeyValueStore diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index 2e8f60c01150..fc119c7edd58 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -521,6 +521,7 @@ def test_ancestor_info(self, field_type): problem1 = self.create_xblock( parent_usage_key=vert_usage_key, display_name="problem1", category="problem" ) + print(problem1) problem_usage_key = self.response_usage_key(problem1) def assert_xblock_info(xblock, xblock_info): @@ -556,7 +557,11 @@ def assert_xblock_info(xblock, xblock_info): xblock = parent_xblock else: self.assertNotIn("ancestors", response) - self.assertEqual(get_block_info(xblock), 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) @ddt.ddt 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 a7ee7f0ab0cd..0f38722e1208 100644 --- a/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py +++ b/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py @@ -162,6 +162,39 @@ def test_exam_settings_alert_with_exam_settings_disabled(self, page_handler): else: assert 'To update these settings go to the Advanced Settings page.' in alert_text + @override_settings( + PROCTORING_BACKENDS={ + 'DEFAULT': 'test_proctoring_provider', + 'proctortrack': {}, + 'test_proctoring_provider': {}, + }, + FEATURES=FEATURES_WITH_EXAM_SETTINGS_ENABLED, + ) + @ddt.data( + "advanced_settings_handler", + "course_handler", + ) + def test_invalid_provider_alert(self, page_handler): + """ + An alert should appear if the course has a proctoring provider that is not valid. + """ + # create an error by setting an invalid proctoring provider + self.course.proctoring_provider = 'invalid_provider' + self.course.enable_proctored_exams = True + self.save_course() + + url = reverse_course_url(page_handler, self.course.id) + resp = self.client.get(url, HTTP_ACCEPT='text/html') + alert_text = self._get_exam_settings_alert_text(resp.content) + assert ( + 'This course has proctored exam settings that are incomplete or invalid.' + in alert_text + ) + assert ( + 'The proctoring provider configured for this course, \'invalid_provider\', is not valid.' + in alert_text + ) + @ddt.data( "advanced_settings_handler", "course_handler", diff --git a/cms/djangoapps/contentstore/views/tests/test_library.py b/cms/djangoapps/contentstore/views/tests/test_library.py index f6b7a48a68e1..fa6505419725 100644 --- a/cms/djangoapps/contentstore/views/tests/test_library.py +++ b/cms/djangoapps/contentstore/views/tests/test_library.py @@ -59,55 +59,66 @@ def setUp(self): @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", False) def test_library_creator_status_libraries_not_enabled(self): _, nostaff_user = self.create_non_staff_authed_user_client() - self.assertEqual(user_can_create_library(nostaff_user), False) + self.assertEqual(user_can_create_library(nostaff_user, None), False) # When creator group is disabled, non-staff users can create libraries @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) def test_library_creator_status_with_no_course_creator_role(self): _, nostaff_user = self.create_non_staff_authed_user_client() - self.assertEqual(user_can_create_library(nostaff_user), True) + self.assertEqual(user_can_create_library(nostaff_user, 'An Org'), True) # When creator group is enabled, Non staff users cannot create libraries @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) def test_library_creator_status_for_enabled_creator_group_setting_for_non_staff_users(self): _, nostaff_user = self.create_non_staff_authed_user_client() with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}): - self.assertEqual(user_can_create_library(nostaff_user), False) + self.assertEqual(user_can_create_library(nostaff_user, None), False) - # Global staff can create libraries + # Global staff can create libraries for any org, even ones that don't exist. @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) def test_library_creator_status_with_is_staff_user(self): - self.assertEqual(user_can_create_library(self.user), True) + print(self.user.is_staff) + self.assertEqual(user_can_create_library(self.user, 'aNyOrg'), True) - # When creator groups are enabled, global staff can create libraries + # Global staff can create libraries for any org, but an org has to be supplied. + @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) + def test_library_creator_status_with_is_staff_user_no_org(self): + print(self.user.is_staff) + self.assertEqual(user_can_create_library(self.user, None), False) + + # When creator groups are enabled, global staff can create libraries in any org @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) def test_library_creator_status_for_enabled_creator_group_setting_with_is_staff_user(self): with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}): - self.assertEqual(user_can_create_library(self.user), True) + self.assertEqual(user_can_create_library(self.user, 'RandomOrg'), True) - # When creator groups are enabled, course creators can create libraries + # When creator groups are enabled, course creators can create libraries in any org. @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) def test_library_creator_status_with_course_creator_role_for_enabled_creator_group_setting(self): _, nostaff_user = self.create_non_staff_authed_user_client() with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}): grant_course_creator_status(self.user, nostaff_user) - self.assertEqual(user_can_create_library(nostaff_user), True) + self.assertEqual(user_can_create_library(nostaff_user, 'soMeRandOmoRg'), True) # When creator groups are enabled, course staff members can create libraries + # but only in the org they are course staff for. @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) def test_library_creator_status_with_course_staff_role_for_enabled_creator_group_setting(self): _, nostaff_user = self.create_non_staff_authed_user_client() with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}): auth.add_users(self.user, CourseStaffRole(self.course.id), nostaff_user) - self.assertEqual(user_can_create_library(nostaff_user), True) + self.assertEqual(user_can_create_library(nostaff_user, self.course.org), True) + self.assertEqual(user_can_create_library(nostaff_user, 'SomEOtherOrg'), False) # When creator groups are enabled, course instructor members can create libraries + # but only in the org they are course staff for. @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) def test_library_creator_status_with_course_instructor_role_for_enabled_creator_group_setting(self): _, nostaff_user = self.create_non_staff_authed_user_client() with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}): auth.add_users(self.user, CourseInstructorRole(self.course.id), nostaff_user) - self.assertEqual(user_can_create_library(nostaff_user), True) + self.assertEqual(user_can_create_library(nostaff_user, self.course.org), True) + self.assertEqual(user_can_create_library(nostaff_user, 'SomEOtherOrg'), False) @ddt.data( (False, False, True), @@ -131,7 +142,7 @@ def test_library_creator_status_settings(self, disable_course, disable_library, "DISABLE_LIBRARY_CREATION": disable_library } ): - self.assertEqual(user_can_create_library(nostaff_user), expected_status) + self.assertEqual(user_can_create_library(nostaff_user, 'SomEOrg'), expected_status) @mock.patch.dict('django.conf.settings.FEATURES', {'DISABLE_COURSE_CREATION': True}) @mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True) @@ -140,7 +151,7 @@ def test_library_creator_status_with_no_course_creator_role_and_disabled_nonstaf Ensure that `DISABLE_COURSE_CREATION` feature works with libraries as well. """ nostaff_client, nostaff_user = self.create_non_staff_authed_user_client() - self.assertFalse(user_can_create_library(nostaff_user)) + self.assertFalse(user_can_create_library(nostaff_user, 'SomEOrg')) # To be explicit, this user can GET, but not POST get_response = nostaff_client.get_json(LIBRARY_REST_URL) @@ -251,7 +262,7 @@ def test_lib_create_permission_course_staff_role(self): auth.add_users(self.user, CourseStaffRole(self.course.id), ns_user) self.assertTrue(auth.user_has_role(ns_user, CourseStaffRole(self.course.id))) response = self.client.ajax_post(LIBRARY_REST_URL, { - 'org': 'org', 'library': 'lib', 'display_name': "New Library", + 'org': self.course.org, 'library': 'lib', 'display_name': "New Library", }) self.assertEqual(response.status_code, 200) diff --git a/cms/djangoapps/contentstore/views/tests/test_videos.py b/cms/djangoapps/contentstore/views/tests/test_videos.py index 5d96e6797d9d..2d17229f59c1 100644 --- a/cms/djangoapps/contentstore/views/tests/test_videos.py +++ b/cms/djangoapps/contentstore/views/tests/test_videos.py @@ -12,6 +12,7 @@ from unittest.mock import Mock, patch import dateutil.parser +from common.djangoapps.student.tests.factories import UserFactory import ddt import pytz from django.test import TestCase @@ -37,6 +38,8 @@ ENABLE_DEVSTACK_VIDEO_UPLOADS, ) from openedx.core.djangoapps.waffle_utils.models import WaffleFlagCourseOverrideModel +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order from ..videos import ( @@ -1662,3 +1665,82 @@ def test_storage_bucket(self): self.assertIn("https://vem_test_bucket.s3.amazonaws.com:443/test_root/", upload_url) self.assertIn(edx_video_id, upload_url) + + +class CourseYoutubeEdxVideoIds(ModuleStoreTestCase): + """ + This test checks youtube videos in a course + """ + VIEW_NAME = 'youtube_edx_video_ids' + + def setUp(self): + super().setUp() + self.course = CourseFactory.create() + self.course_with_no_youtube_videos = CourseFactory.create() + self.store = modulestore() + self.user = UserFactory() + self.client.login(username=self.user.username, password='Password1234') + + def get_url_for_course_key(self, course_key, kwargs=None): + """Return video handler URL for the given course""" + return reverse_course_url(self.VIEW_NAME, course_key, kwargs) # lint-amnesty, pylint: disable=no-member + + def test_course_with_youtube_videos(self): + course_key = self.course.id + + with self.store.bulk_operations(course_key): + chapter_loc = self.store.create_child( + self.user.id, self.course.location, 'chapter', 'test_chapter' + ).location + seq_loc = self.store.create_child( + self.user.id, chapter_loc, 'sequential', 'test_seq' + ).location + vert_loc = self.store.create_child(self.user.id, seq_loc, 'vertical', 'test_vert').location + self.store.create_child( + self.user.id, + vert_loc, + 'problem', + 'test_problem', + fields={"data": "Test"} + ) + self.store.create_child( + self.user.id, vert_loc, 'video', fields={ + "youtube_is_available": False, + "name": "sample_video", + "edx_video_id": "youtube_193_84709099", + } + ) + + response = self.client.get(self.get_url_for_course_key(course_key)) + self.assertEqual(response.status_code, 200) + + edx_video_ids = json.loads(response.content.decode('utf-8'))['edx_video_ids'] + self.assertEqual(len(edx_video_ids), 1) + + def test_course_with_no_youtube_videos(self): + course_key = self.course_with_no_youtube_videos.id + + with self.store.bulk_operations(course_key): + chapter_loc = self.store.create_child( + self.user.id, self.course_with_no_youtube_videos.location, 'chapter', 'test_chapter' + ).location + seq_loc = self.store.create_child( + self.user.id, chapter_loc, 'sequential', 'test_seq' + ).location + vert_loc = self.store.create_child(self.user.id, seq_loc, 'vertical', 'test_vert').location + self.store.create_child( + self.user.id, vert_loc, 'problem', 'test_problem', fields={"data": "Test"} + ) + self.store.create_child( + self.user.id, vert_loc, 'video', fields={ + "youtube_id_1_0": None, + "name": "sample_video", + "edx_video_id": "no_youtube_193_84709099", + } + ) + + response = self.client.get(self.get_url_for_course_key(course_key)) + + edx_video_ids = json.loads(response.content.decode('utf-8'))['edx_video_ids'] + self.assertEqual(response.status_code, 200) + self.assertEqual(len(edx_video_ids), 0) diff --git a/cms/djangoapps/contentstore/views/videos.py b/cms/djangoapps/contentstore/views/videos.py index e1bdfa1cde1c..2eac141b9c9e 100644 --- a/cms/djangoapps/contentstore/views/videos.py +++ b/cms/djangoapps/contentstore/views/videos.py @@ -27,6 +27,7 @@ storage_service_key as storage_service_key_source_function, send_video_status_update as send_video_status_update_source_function, is_status_update_request as is_status_update_request_source_function, + get_course_youtube_edx_video_ids, ) from common.djangoapps.util.json_request import expect_json @@ -41,6 +42,7 @@ 'get_video_features', 'transcript_preferences_handler', 'generate_video_upload_link_handler', + 'get_course_youtube_edx_videos_ids', ] LOGGER = logging.getLogger(__name__) @@ -236,3 +238,18 @@ def is_status_update_request(request_data): Exposes helper method without breaking existing bindings/dependencies """ return is_status_update_request_source_function(request_data) + + +@api_view(['GET']) +@view_auth_classes() +@require_GET +def get_course_youtube_edx_videos_ids(request, course_key_string): + """ + Get an object containing course videos. + **Example Request** + GET /api/contentstore/v1/videos/youtube_ids{course_id} + **Response Values** + If the request is successful, an HTTP 200 "OK" response is returned. + The HTTP 200 response contains a list of youtube edx_video_ids for a given course. + """ + return get_course_youtube_edx_video_ids(course_key_string) diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 6959e22b94dc..e7dbec01f8e0 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -33,6 +33,7 @@ from xblock.fields import Scope from cms.djangoapps.contentstore.config.waffle import SHOW_REVIEW_RULES_FLAG +from cms.djangoapps.contentstore.toggles import ENABLE_DEFAULT_ADVANCED_PROBLEM_EDITOR_FLAG from cms.djangoapps.models.settings.course_grading import CourseGradingModel from cms.lib.ai_aside_summary_config import AiAsideSummaryConfig from common.djangoapps.static_replace import replace_static_urls @@ -184,6 +185,11 @@ def handle_xblock(request, usage_key_string=None): # TODO: pass fields to get_block_info and only return those with modulestore().bulk_operations(usage_key.course_key): response = get_block_info(get_xblock(usage_key, request.user)) + # TODO: remove after beta testing for the new problem editor parser + if response["category"] == "problem": + response["metadata"]["default_to_advanced"] = ( + ENABLE_DEFAULT_ADVANCED_PROBLEM_EDITOR_FLAG.is_enabled() + ) if "customReadToken" in fields: parent_children = _get_block_parent_children(get_xblock(usage_key, request.user)) response.update(parent_children) @@ -305,13 +311,10 @@ def _update_with_callback(xblock, user, old_metadata=None, old_content=None): old_metadata = own_metadata(xblock) if old_content is None: old_content = xblock.get_explicitly_set_fields_by_scope(Scope.content) - if hasattr(xblock, "editor_saved"): - load_services_for_studio(xblock.runtime, user) - xblock.editor_saved(user, old_metadata, old_content) + load_services_for_studio(xblock.runtime, user) + xblock.editor_saved(user, old_metadata, old_content) xblock_updated = modulestore().update_item(xblock, user.id) - if hasattr(xblock_updated, "post_editor_saved"): - load_services_for_studio(xblock_updated.runtime, user) - xblock_updated.post_editor_saved(user, old_metadata, old_content) + xblock_updated.post_editor_saved(user, old_metadata, old_content) return xblock_updated diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index fd5219dfb472..5d4ac5a4a336 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -217,7 +217,10 @@ def update_from_json(cls, block, jsondict, user, filter_tabs=True): try: val = model['value'] if hasattr(block, key) and getattr(block, key) != val: - key_values[key] = block.fields[key].from_json(val) + if key == 'proctoring_provider': + key_values[key] = block.fields[key].from_json(val, validate_providers=True) + else: + key_values[key] = block.fields[key].from_json(val) except (TypeError, ValueError) as err: raise ValueError(_("Incorrect format for field '{name}'. {detailed_message}").format( # lint-amnesty, pylint: disable=raise-missing-from name=model['display_name'], detailed_message=str(err))) @@ -253,7 +256,10 @@ def validate_and_update_from_json(cls, block, jsondict, user, filter_tabs=True): try: val = model['value'] if hasattr(block, key) and getattr(block, key) != val: - key_values[key] = block.fields[key].from_json(val) + if key == 'proctoring_provider': + key_values[key] = block.fields[key].from_json(val, validate_providers=True) + else: + key_values[key] = block.fields[key].from_json(val) except (TypeError, ValueError, ValidationError) as err: did_validate = False errors.append({'key': key, 'message': str(err), 'model': model}) @@ -484,6 +490,24 @@ def validate_proctoring_settings(cls, block, settings_dict, user): enable_proctoring = block.enable_proctored_exams if enable_proctoring: + + if proctoring_provider_model: + proctoring_provider = proctoring_provider_model.get('value') + else: + proctoring_provider = block.proctoring_provider + + # If the proctoring provider stored in the course block no longer + # matches the available providers for this instance, show an error + if proctoring_provider not in available_providers: + message = ( + f'The proctoring provider configured for this course, \'{proctoring_provider}\', is not valid.' + ) + errors.append({ + 'key': 'proctoring_provider', + 'message': message, + 'model': proctoring_provider_model + }) + # Require a valid escalation email if Proctortrack is chosen as the proctoring provider escalation_email_model = settings_dict.get('proctoring_escalation_email') if escalation_email_model: @@ -491,11 +515,6 @@ def validate_proctoring_settings(cls, block, settings_dict, user): else: escalation_email = block.proctoring_escalation_email - if proctoring_provider_model: - proctoring_provider = proctoring_provider_model.get('value') - else: - proctoring_provider = block.proctoring_provider - missing_escalation_email_msg = 'Provider \'{provider}\' requires an exam escalation contact.' if proctoring_provider_model and proctoring_provider == 'proctortrack': if not escalation_email: diff --git a/cms/envs/common.py b/cms/envs/common.py index e2ad3e239f61..34dd8503f35e 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -841,7 +841,8 @@ ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS = {} # Setting for Open API key and prompts used by edx-enterprise. -OPENAI_API_KEY = '' +CHAT_COMPLETION_API = 'https://example.com/chat/completion' +CHAT_COMPLETION_API_KEY = 'i am a key' LEARNER_ENGAGEMENT_PROMPT_FOR_ACTIVE_CONTRACT = '' LEARNER_ENGAGEMENT_PROMPT_FOR_NON_ACTIVE_CONTRACT = '' LEARNER_PROGRESS_PROMPT_FOR_ACTIVE_CONTRACT = '' @@ -948,7 +949,6 @@ 'openedx.core.djangoapps.cache_toolbox.middleware.CacheBackedAuthenticationMiddleware', 'common.djangoapps.student.middleware.UserStandingMiddleware', - 'openedx.core.djangoapps.contentserver.middleware.StaticContentServerMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'common.djangoapps.track.middleware.TrackMiddleware', @@ -1448,9 +1448,8 @@ 'edx-ui-toolkit/js/utils/string-utils.js', 'edx-ui-toolkit/js/utils/html-utils.js', - # Load Bootstrap and supporting libraries - 'common/js/vendor/popper.js', - 'common/js/vendor/bootstrap.js', + # Here we were loading Bootstrap and supporting libraries, but it no longer seems to be needed for any Studio UI. + # 'common/js/vendor/bootstrap.bundle.js', # Finally load RequireJS 'common/js/vendor/require.js' @@ -1879,6 +1878,7 @@ 'openedx_events', # Learning Core Apps, used by v2 content libraries (content_libraries app) + "openedx_learning.apps.authoring.collections", "openedx_learning.apps.authoring.components", "openedx_learning.apps.authoring.contents", "openedx_learning.apps.authoring.publishing", @@ -2687,7 +2687,7 @@ ############## NOTIFICATIONS EXPIRY ############## NOTIFICATIONS_EXPIRY = 60 EXPIRED_NOTIFICATIONS_DELETE_BATCH_SIZE = 10000 -NOTIFICATION_CREATION_BATCH_SIZE = 83 +NOTIFICATION_CREATION_BATCH_SIZE = 76 ############################ AI_TRANSLATIONS ################################## AI_TRANSLATIONS_API_URL = 'http://localhost:18760/api/v1' diff --git a/cms/envs/production.py b/cms/envs/production.py index cf2a7d2f3fad..50519b55229b 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -169,7 +169,8 @@ def get_env_setting(setting): AUTHORING_API_URL = ENV_TOKENS.get('AUTHORING_API_URL', '') # Note that FEATURES['PREVIEW_LMS_BASE'] gets read in from the environment file. -OPENAI_API_KEY = ENV_TOKENS.get('OPENAI_API_KEY', '') +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', diff --git a/cms/lib/xblock/authoring_mixin.py b/cms/lib/xblock/authoring_mixin.py index a3d3b3298cea..b9057391b18b 100644 --- a/cms/lib/xblock/authoring_mixin.py +++ b/cms/lib/xblock/authoring_mixin.py @@ -10,6 +10,7 @@ from xblock.core import XBlock, XBlockMixin from xblock.fields import String, Scope + log = logging.getLogger(__name__) VISIBILITY_VIEW = 'visibility_view' @@ -21,6 +22,7 @@ class AuthoringMixin(XBlockMixin): """ Mixin class that provides authoring capabilities for XBlocks. """ + def _get_studio_resource_url(self, relative_url): """ Returns the Studio URL to a static resource. @@ -51,3 +53,17 @@ def visibility_view(self, _context=None): scope=Scope.settings, enforce_type=True, ) + + def editor_saved(self, user, old_metadata, old_content) -> None: # pylint: disable=unused-argument + """ + Called right *before* the block is written to the DB. Can be used, e.g., to modify fields before saving. + + By default, is a no-op. Can be overriden in subclasses. + """ + + def post_editor_saved(self, user, old_metadata, old_content) -> None: # pylint: disable=unused-argument + """ + Called right *after* the block is written to the DB. Can be used, e.g., to spin up followup tasks. + + By default, is a no-op. Can be overriden in subclasses. + """ diff --git a/cms/static/sass/studio-main-v1.scss b/cms/static/sass/studio-main-v1.scss index ac649970d644..5d0cdda2ea5f 100644 --- a/cms/static/sass/studio-main-v1.scss +++ b/cms/static/sass/studio-main-v1.scss @@ -15,6 +15,8 @@ // +Libs and Resets - *do not edit* // ==================== + +@import '_builtin-block-variables'; @import 'bourbon/bourbon'; // lib - bourbon @import 'vendor/bi-app/bi-app-ltr'; // set the layout for left to right languages @import 'build-v1'; // shared app style assets/rendering diff --git a/cms/urls.py b/cms/urls.py index e21d07083eea..1c5f191b2728 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -194,6 +194,8 @@ path('api/val/v0/', include('edxval.urls')), path('api/tasks/v0/', include('user_tasks.urls')), 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'), ] if not settings.DISABLE_DEPRECATED_SIGNIN_URL: diff --git a/common/djangoapps/entitlements/rest_api/v1/permissions.py b/common/djangoapps/entitlements/rest_api/v1/permissions.py index 6a705d9feed5..db14f05049c3 100644 --- a/common/djangoapps/entitlements/rest_api/v1/permissions.py +++ b/common/djangoapps/entitlements/rest_api/v1/permissions.py @@ -4,7 +4,6 @@ """ -from django.conf import settings from rest_framework.permissions import SAFE_METHODS, BasePermission from lms.djangoapps.courseware.access import has_access @@ -22,12 +21,3 @@ def has_permission(self, request, view): return request.user.is_authenticated else: return request.user.is_staff or has_access(request.user, "support", "global") - - -class IsSubscriptionWorkerUser(BasePermission): - """ - Method that will require the request to be coming from the subscriptions service worker user. - """ - - def has_permission(self, request, view): - return request.user.username == settings.SUBSCRIPTIONS_SERVICE_WORKER_USERNAME diff --git a/common/djangoapps/entitlements/rest_api/v1/tests/test_views.py b/common/djangoapps/entitlements/rest_api/v1/tests/test_views.py index 34abc39c0096..86d4ae6a87e1 100644 --- a/common/djangoapps/entitlements/rest_api/v1/tests/test_views.py +++ b/common/djangoapps/entitlements/rest_api/v1/tests/test_views.py @@ -6,7 +6,6 @@ import uuid from datetime import datetime, timedelta from unittest.mock import patch -from uuid import uuid4 from django.conf import settings from django.urls import reverse @@ -1236,160 +1235,3 @@ def test_user_is_not_unenrolled_on_failed_refund( assert CourseEnrollment.is_enrolled(self.user, self.course.id) assert course_entitlement.enrollment_course_run is not None assert course_entitlement.expired_at is None - - -@skip_unless_lms -class RevokeSubscriptionsVerifiedAccessViewTest(ModuleStoreTestCase): - """ - Tests for the RevokeVerifiedAccessView - """ - REVOKE_VERIFIED_ACCESS_PATH = 'entitlements_api:v1:revoke_subscriptions_verified_access' - - def setUp(self): - super().setUp() - self.user = UserFactory(username="subscriptions_worker", is_staff=True) - self.client.login(username=self.user.username, password=TEST_PASSWORD) - self.course = CourseFactory() - self.course_mode1 = CourseModeFactory( - course_id=self.course.id, # pylint: disable=no-member - mode_slug=CourseMode.VERIFIED, - expiration_datetime=now() + timedelta(days=1) - ) - self.course_mode2 = CourseModeFactory( - course_id=self.course.id, # pylint: disable=no-member - mode_slug=CourseMode.AUDIT, - expiration_datetime=now() + timedelta(days=1) - ) - - @patch('common.djangoapps.entitlements.rest_api.v1.views.get_courses_completion_status') - def test_revoke_access_success(self, mock_get_courses_completion_status): - mock_get_courses_completion_status.return_value = ([], False) - enrollment = CourseEnrollmentFactory.create( - user=self.user, - course_id=self.course.id, # pylint: disable=no-member - is_active=True, - mode=CourseMode.VERIFIED - ) - course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment) - url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH) - - assert course_entitlement.enrollment_course_run is not None - - response = self.client.post( - url, - data={ - "entitlement_uuids": [str(course_entitlement.uuid)], - "lms_user_id": self.user.id - }, - content_type='application/json', - ) - assert response.status_code == 204 - - course_entitlement.refresh_from_db() - enrollment.refresh_from_db() - assert course_entitlement.expired_at is not None - assert course_entitlement.enrollment_course_run is None - assert enrollment.mode == CourseMode.AUDIT - - @patch('common.djangoapps.entitlements.rest_api.v1.views.get_courses_completion_status') - def test_already_completed_course(self, mock_get_courses_completion_status): - enrollment = CourseEnrollmentFactory.create( - user=self.user, - course_id=self.course.id, # pylint: disable=no-member - is_active=True, - mode=CourseMode.VERIFIED - ) - mock_get_courses_completion_status.return_value = ([str(enrollment.course_id)], False) - course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment) - url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH) - - assert course_entitlement.enrollment_course_run is not None - - response = self.client.post( - url, - data={ - "entitlement_uuids": [str(course_entitlement.uuid)], - "lms_user_id": self.user.id - }, - content_type='application/json', - ) - assert response.status_code == 204 - - course_entitlement.refresh_from_db() - assert course_entitlement.expired_at is None - assert course_entitlement.enrollment_course_run.mode == CourseMode.VERIFIED - - @patch('common.djangoapps.entitlements.rest_api.v1.views.log.info') - def test_revoke_access_invalid_uuid(self, mock_log): - url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH) - entitlement_uuids = [str(uuid4())] - response = self.client.post( - url, - data={ - "entitlement_uuids": entitlement_uuids, - "lms_user_id": self.user.id - }, - content_type='application/json', - ) - - mock_log.assert_called_once_with("B2C_SUBSCRIPTIONS: Entitlements not found for the provided" - " entitlements data: %s and user: %s", - entitlement_uuids, - self.user.id) - assert response.status_code == 204 - - def test_revoke_access_unauthorized_user(self): - user = UserFactory(is_staff=True, username='not_subscriptions_worker') - self.client.login(username=user.username, password=TEST_PASSWORD) - - enrollment = CourseEnrollmentFactory.create( - user=self.user, - course_id=self.course.id, # pylint: disable=no-member - is_active=True, - mode=CourseMode.VERIFIED - ) - course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment) - url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH) - - assert course_entitlement.enrollment_course_run is not None - - response = self.client.post( - url, - data={ - "entitlement_uuids": [], - "lms_user_id": self.user.id - }, - content_type='application/json', - ) - assert response.status_code == 403 - - course_entitlement.refresh_from_db() - assert course_entitlement.expired_at is None - assert course_entitlement.enrollment_course_run.mode == CourseMode.VERIFIED - - @patch('common.djangoapps.entitlements.tasks.retry_revoke_subscriptions_verified_access.apply_async') - @patch('common.djangoapps.entitlements.rest_api.v1.views.get_courses_completion_status') - def test_course_completion_exception_triggers_task(self, mock_get_courses_completion_status, mock_task): - mock_get_courses_completion_status.return_value = ([], True) - enrollment = CourseEnrollmentFactory.create( - user=self.user, - course_id=self.course.id, # pylint: disable=no-member - is_active=True, - mode=CourseMode.VERIFIED - ) - course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment) - - url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH) - - response = self.client.post( - url, - data={ - "entitlement_uuids": [str(course_entitlement.uuid)], - "lms_user_id": self.user.id - }, - content_type='application/json', - ) - assert response.status_code == 204 - mock_task.assert_called_once_with(args=([str(course_entitlement.uuid)], - [str(enrollment.course_id)], - self.user.username)) diff --git a/common/djangoapps/entitlements/rest_api/v1/throttles.py b/common/djangoapps/entitlements/rest_api/v1/throttles.py deleted file mode 100644 index 3a010c76afe7..000000000000 --- a/common/djangoapps/entitlements/rest_api/v1/throttles.py +++ /dev/null @@ -1,21 +0,0 @@ -""" -Throttle classes for the entitlements API. -""" - -from django.conf import settings -from rest_framework.throttling import UserRateThrottle - - -class ServiceUserThrottle(UserRateThrottle): - """A throttle allowing service users to override rate limiting""" - - def allow_request(self, request, view): - """Returns True if the request is coming from one of the service users - and defaults to UserRateThrottle's configured setting otherwise. - """ - service_users = [ - settings.SUBSCRIPTIONS_SERVICE_WORKER_USERNAME - ] - if request.user.username in service_users: - return True - return super().allow_request(request, view) diff --git a/common/djangoapps/entitlements/rest_api/v1/urls.py b/common/djangoapps/entitlements/rest_api/v1/urls.py index e1d98a2485c3..e04341b5ef50 100644 --- a/common/djangoapps/entitlements/rest_api/v1/urls.py +++ b/common/djangoapps/entitlements/rest_api/v1/urls.py @@ -6,7 +6,7 @@ from django.urls import path, re_path from rest_framework.routers import DefaultRouter -from .views import EntitlementEnrollmentViewSet, EntitlementViewSet, SubscriptionsRevokeVerifiedAccessView +from .views import EntitlementEnrollmentViewSet, EntitlementViewSet router = DefaultRouter() router.register(r'entitlements', EntitlementViewSet, basename='entitlements') @@ -24,9 +24,4 @@ ENROLLMENTS_VIEW, name='enrollments' ), - path( - 'subscriptions/entitlements/revoke', - SubscriptionsRevokeVerifiedAccessView.as_view(), - name='revoke_subscriptions_verified_access' - ) ] diff --git a/common/djangoapps/entitlements/rest_api/v1/views.py b/common/djangoapps/entitlements/rest_api/v1/views.py index 3306604d5d13..4f3dd54b52a7 100644 --- a/common/djangoapps/entitlements/rest_api/v1/views.py +++ b/common/djangoapps/entitlements/rest_api/v1/views.py @@ -15,7 +15,6 @@ from opaque_keys.edx.keys import CourseKey from rest_framework import permissions, status, viewsets from rest_framework.response import Response -from rest_framework.views import APIView from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.entitlements.models import ( # lint-amnesty, pylint: disable=line-too-long @@ -24,22 +23,13 @@ CourseEntitlementSupportDetail ) from common.djangoapps.entitlements.rest_api.v1.filters import CourseEntitlementFilter -from common.djangoapps.entitlements.rest_api.v1.permissions import ( - IsAdminOrSupportOrAuthenticatedReadOnly, - IsSubscriptionWorkerUser -) +from common.djangoapps.entitlements.rest_api.v1.permissions import IsAdminOrSupportOrAuthenticatedReadOnly from common.djangoapps.entitlements.rest_api.v1.serializers import CourseEntitlementSerializer -from common.djangoapps.entitlements.rest_api.v1.throttles import ServiceUserThrottle -from common.djangoapps.entitlements.tasks import retry_revoke_subscriptions_verified_access -from common.djangoapps.entitlements.utils import ( - is_course_run_entitlement_fulfillable, - revoke_entitlements_and_downgrade_courses_to_audit -) +from common.djangoapps.entitlements.utils import is_course_run_entitlement_fulfillable from common.djangoapps.student.models import AlreadyEnrolledError, CourseEnrollment, CourseEnrollmentException from openedx.core.djangoapps.catalog.utils import get_course_runs_for_course, get_owners_for_course from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf -from openedx.core.djangoapps.credentials.utils import get_courses_completion_status from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in User = get_user_model() @@ -132,7 +122,6 @@ class EntitlementViewSet(viewsets.ModelViewSet): filter_backends = (DjangoFilterBackend,) filterset_class = CourseEntitlementFilter pagination_class = EntitlementsPagination - throttle_classes = (ServiceUserThrottle,) def get_queryset(self): user = self.request.user @@ -530,68 +519,3 @@ def destroy(self, request, uuid): }) return Response(status=status.HTTP_204_NO_CONTENT) - - -class SubscriptionsRevokeVerifiedAccessView(APIView): - """ - Endpoint for expiring entitlements for a user and downgrading the enrollments - to Audit mode. This endpoint accepts a list of entitlement UUIDs and will expire - the entitlements along with downgrading the related enrollments to Audit mode. - Only those enrollments are downgraded to Audit for which user has not been awarded - a completion certificate yet. - """ - authentication_classes = (JwtAuthentication, SessionAuthenticationCrossDomainCsrf,) - permission_classes = (permissions.IsAuthenticated, IsSubscriptionWorkerUser,) - throttle_classes = (ServiceUserThrottle,) - - def _process_revoke_and_downgrade_to_audit(self, course_entitlements, user_id, revocable_entitlement_uuids): - """ - Gets course completion status for the provided course entitlements and triggers the - revoke and downgrade to audit process for the course entitlements which are not completed. - Triggers the retry task asynchronously if there is an exception while getting the - course completion status. - """ - entitled_course_ids = [] - user = User.objects.get(id=user_id) - username = user.username - for course_entitlement in course_entitlements: - if course_entitlement.enrollment_course_run is not None: - entitled_course_ids.append(str(course_entitlement.enrollment_course_run.course_id)) - - log.info('B2C_SUBSCRIPTIONS: Getting course completion status for user [%s] and entitled_course_ids %s', - username, - entitled_course_ids) - awarded_cert_course_ids, is_exception = get_courses_completion_status(username, entitled_course_ids) - - if is_exception: - # Trigger the retry task asynchronously - log.exception('B2C_SUBSCRIPTIONS: Exception occurred while getting course completion status for user %s ' - 'and entitled_course_ids %s', - username, - entitled_course_ids) - retry_revoke_subscriptions_verified_access.apply_async(args=(revocable_entitlement_uuids, - entitled_course_ids, - username)) - return - revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, username, awarded_cert_course_ids, - revocable_entitlement_uuids) - - def post(self, request): - """ - Invokes the entitlements expiration process for the provided uuids and downgrades the - enrollments to Audit mode. - """ - revocable_entitlement_uuids = request.data.get('entitlement_uuids', []) - user_id = request.data.get('lms_user_id', None) - course_entitlements = (CourseEntitlement.objects.filter(uuid__in=revocable_entitlement_uuids). - select_related('user'). - select_related('enrollment_course_run')) - - if course_entitlements.exists(): - self._process_revoke_and_downgrade_to_audit(course_entitlements, user_id, revocable_entitlement_uuids) - return Response(status=status.HTTP_204_NO_CONTENT) - else: - log.info('B2C_SUBSCRIPTIONS: Entitlements not found for the provided entitlements data: %s and user: %s', - revocable_entitlement_uuids, - user_id) - return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/common/djangoapps/entitlements/tasks.py b/common/djangoapps/entitlements/tasks.py index 981879e21793..9bd200bc9056 100644 --- a/common/djangoapps/entitlements/tasks.py +++ b/common/djangoapps/entitlements/tasks.py @@ -4,15 +4,12 @@ import logging from celery import shared_task -from celery.exceptions import MaxRetriesExceededError from celery.utils.log import get_task_logger from django.conf import settings # lint-amnesty, pylint: disable=unused-import from django.contrib.auth import get_user_model from edx_django_utils.monitoring import set_code_owner_attribute from common.djangoapps.entitlements.models import CourseEntitlement, CourseEntitlementSupportDetail -from common.djangoapps.entitlements.utils import revoke_entitlements_and_downgrade_courses_to_audit -from openedx.core.djangoapps.credentials.utils import get_courses_completion_status LOGGER = get_task_logger(__name__) log = logging.getLogger(__name__) @@ -154,40 +151,3 @@ def expire_and_create_entitlements(self, entitlement_ids, support_username): '%d entries, task id :%s', len(entitlement_ids), self.request.id) - - -@shared_task(bind=True) -@set_code_owner_attribute -def retry_revoke_subscriptions_verified_access(self, revocable_entitlement_uuids, entitled_course_ids, username): - """ - Task to process course access revoke and move to audit. - This is called only if call to get_courses_completion_status fails due to any exception. - """ - LOGGER.info("B2C_SUBSCRIPTIONS: Running retry_revoke_subscriptions_verified_access for user [%s]," - " entitlement_uuids %s and entitled_course_ids %s", - username, - revocable_entitlement_uuids, - entitled_course_ids) - course_entitlements = CourseEntitlement.objects.filter(uuid__in=revocable_entitlement_uuids) - course_entitlements = course_entitlements.select_related('user').select_related('enrollment_course_run') - if course_entitlements.exists(): - awarded_cert_course_ids, is_exception = get_courses_completion_status(username, entitled_course_ids) - if is_exception: - try: - countdown = 2 ** self.request.retries - self.retry(countdown=countdown, max_retries=3) - except MaxRetriesExceededError: - LOGGER.exception( - 'B2C_SUBSCRIPTIONS: Failed to process retry_revoke_subscriptions_verified_access ' - 'for user [%s] and entitlement_uuids %s', - username, - revocable_entitlement_uuids - ) - return - revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, username, awarded_cert_course_ids, - revocable_entitlement_uuids) - else: - LOGGER.info('B2C_SUBSCRIPTIONS: Entitlements not found for the provided entitlements uuids %s ' - 'for user [%s] duing the retry_revoke_subscriptions_verified_access task', - revocable_entitlement_uuids, - username) diff --git a/common/djangoapps/student/migrations/0046_alter_userprofile_phone_number.py b/common/djangoapps/student/migrations/0046_alter_userprofile_phone_number.py new file mode 100644 index 000000000000..9cfe9588b6d9 --- /dev/null +++ b/common/djangoapps/student/migrations/0046_alter_userprofile_phone_number.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.14 on 2024-07-16 22:21 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('student', '0045_auto_20230808_0944'), + ] + + operations = [ + migrations.AlterField( + model_name='userprofile', + name='phone_number', + field=models.CharField(blank=True, max_length=50, null=True, validators=[django.core.validators.RegexValidator(message="Phone number must start with '+' (optional) followed by digits (0-9) only.", regex='^\\+?1?\\d*$')]), + ), + ] diff --git a/common/djangoapps/student/models/course_enrollment.py b/common/djangoapps/student/models/course_enrollment.py index 729e80eb6594..09862916e321 100644 --- a/common/djangoapps/student/models/course_enrollment.py +++ b/common/djangoapps/student/models/course_enrollment.py @@ -129,11 +129,73 @@ class UnenrollmentNotAllowed(CourseEnrollmentException): pass +class CourseEnrollmentQuerySet(models.QuerySet): + """ + Custom queryset for CourseEnrollment with Table-level filter methods. + """ + + def active(self): + """ + Returns a queryset of CourseEnrollment objects for courses that are currently active. + """ + return self.filter(is_active=True) + + def without_certificates(self, username): + """ + Returns a queryset of CourseEnrollment objects for courses that do not have a certificate. + """ + return self.exclude(course_id__in=self.get_user_course_ids_with_certificates(username)) + + def with_certificates(self, username): + """ + Returns a queryset of CourseEnrollment objects for courses that have a certificate. + """ + return self.filter(course_id__in=self.get_user_course_ids_with_certificates(username)) + + def in_progress(self, username, time_zone=UTC): + """ + Returns a queryset of CourseEnrollment objects for courses that are currently in progress. + """ + now = datetime.now(time_zone) + return self.active().without_certificates(username).filter( + Q(course__start__lte=now, course__end__gte=now) + | Q(course__start__isnull=True, course__end__isnull=True) + | Q(course__start__isnull=True, course__end__gte=now) + | Q(course__start__lte=now, course__end__isnull=True), + ) + + def completed(self, username): + """ + Returns a queryset of CourseEnrollment objects for courses that have been completed. + """ + return self.active().with_certificates(username) + + def expired(self, username, time_zone=UTC): + """ + Returns a queryset of CourseEnrollment objects for courses that have expired. + """ + now = datetime.now(time_zone) + return self.active().without_certificates(username).filter(course__end__lt=now) + + def get_user_course_ids_with_certificates(self, username): + """ + Gets user's course ids with certificates. + """ + from lms.djangoapps.certificates.models import GeneratedCertificate # pylint: disable=import-outside-toplevel + course_ids_with_certificates = GeneratedCertificate.objects.filter( + user__username=username + ).values_list('course_id', flat=True) + return course_ids_with_certificates + + class CourseEnrollmentManager(models.Manager): """ Custom manager for CourseEnrollment with Table-level filter methods. """ + def get_queryset(self): + return CourseEnrollmentQuerySet(self.model, using=self._db) + def is_small_course(self, course_id): """ Returns false if the number of enrollments are one greater than 'max_enrollments' else true diff --git a/common/djangoapps/student/models/user.py b/common/djangoapps/student/models/user.py index 2a4a9deb3664..aa3de546ef2b 100644 --- a/common/djangoapps/student/models/user.py +++ b/common/djangoapps/student/models/user.py @@ -552,7 +552,10 @@ class Meta: goals = models.TextField(blank=True, null=True) bio = models.CharField(blank=True, null=True, max_length=3000, db_index=False) profile_image_uploaded_at = models.DateTimeField(null=True, blank=True) - phone_regex = RegexValidator(regex=r'^\+?1?\d*$', message="Phone number can only contain numbers.") + phone_regex = RegexValidator( + regex=r'^\+?1?\d*$', + message="Phone number must start with '+' (optional) followed by digits (0-9) only.", + ) phone_number = models.CharField(validators=[phone_regex], blank=True, null=True, max_length=50) @property diff --git a/common/djangoapps/student/tests/test_user_profile_properties.py b/common/djangoapps/student/tests/test_user_profile_properties.py index 8310759266bf..886c026421e6 100644 --- a/common/djangoapps/student/tests/test_user_profile_properties.py +++ b/common/djangoapps/student/tests/test_user_profile_properties.py @@ -107,23 +107,43 @@ def test_invalidate_cache_user_profile_country_updated(self): assert cache.get(cache_key) != country assert cache.get(cache_key) is None - def test_phone_number_can_only_contain_digits(self): - # validating the profile will fail, because there are letters - # in the phone number - self.profile.phone_number = 'abc' - pytest.raises(ValidationError, self.profile.full_clean) - # fail if mixed digits/letters - self.profile.phone_number = '1234gb' - pytest.raises(ValidationError, self.profile.full_clean) - # fail if whitespace - self.profile.phone_number = ' 123' - pytest.raises(ValidationError, self.profile.full_clean) - # fail with special characters - self.profile.phone_number = '123!@#$%^&*' - pytest.raises(ValidationError, self.profile.full_clean) - # valid phone number - self.profile.phone_number = '123456789' - try: - self.profile.full_clean() - except ValidationError: - self.fail("This phone number should be valid.") + def test_valid_phone_numbers(self): + """ + Test that valid phone numbers are accepted. + + Expected behavior: + - The phone number '+123456789' should be considered valid. + - The phone number '123456789' (without '+') should also be valid. + + This test verifies that valid phone numbers are accepted by the profile model validation. + """ + valid_numbers = ['+123456789', '123456789'] + + for number in valid_numbers: + self.profile.phone_number = number + + try: + self.profile.full_clean() + except ValidationError: + self.fail("This phone number should be valid.") + + def test_invalid_phone_numbers(self): + """ + Test that invalid phone numbers raise ValidationError. + + Expected behavior: + - Phone numbers with letters, mixed digits/letters, whitespace, + or special characters should raise a ValidationError. + + This test verifies that invalid phone numbers are rejected by the profile model validation. + """ + invalid_phone_numbers = [ + 'abc', # Letters in the phone number + '1234gb', # Mixed digits and letters + ' 123', # Whitespace + '123!@#$%^&*' # Special characters + ] + + for number in invalid_phone_numbers: + self.profile.phone_number = number + pytest.raises(ValidationError, self.profile.full_clean) diff --git a/common/djangoapps/student/views/management.py b/common/djangoapps/student/views/management.py index f66e71a0740f..b06cac7b7e50 100644 --- a/common/djangoapps/student/views/management.py +++ b/common/djangoapps/student/views/management.py @@ -62,6 +62,7 @@ ) from openedx.core.djangolib.markup import HTML, Text from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser +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 from common.djangoapps.student.helpers import DISABLE_UNENROLL_CERT_STATES, cert_info @@ -206,12 +207,13 @@ def compose_activation_email( message_context = generate_activation_email_context(user, user_registration) message_context.update({ 'confirm_activation_link': _get_activation_confirmation_link(message_context['key'], redirect_url), + 'is_enterprise_learner': is_enterprise_learner(user), + 'is_first_purchase_discount_overridden': FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG.is_enabled(), 'route_enabled': route_enabled, 'routed_user': user.username, 'routed_user_email': user.email, 'routed_profile_name': profile_name, 'registration_flow': registration_flow, - 'is_enterprise_learner': is_enterprise_learner(user), 'show_auto_generated_username': show_auto_generated_username(user.username), }) diff --git a/common/djangoapps/third_party_auth/admin.py b/common/djangoapps/third_party_auth/admin.py index 972af1622001..284c50fcf884 100644 --- a/common/djangoapps/third_party_auth/admin.py +++ b/common/djangoapps/third_party_auth/admin.py @@ -1,15 +1,17 @@ """ Admin site configuration for third party authentication """ - +import csv from config_models.admin import KeyedConfigurationModelAdmin from django import forms -from django.contrib import admin +from django.contrib import admin, messages from django.db import transaction -from django.urls import reverse +from django.http import Http404, HttpResponseRedirect +from django.urls import path, reverse from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ +from django.views.decorators.csrf import csrf_exempt from .models import ( _PSA_OAUTH2_BACKENDS, @@ -21,7 +23,7 @@ SAMLProviderConfig, SAMLProviderData ) -from .tasks import fetch_saml_metadata +from .tasks import fetch_saml_metadata, update_saml_users_social_auth_uid class OAuth2ProviderConfigForm(forms.ModelForm): @@ -72,7 +74,7 @@ def get_list_display(self, request): """ Don't show every single field in the admin change list """ return ( 'name_with_update_link', 'enabled', 'site', 'entity_id', 'metadata_source', - 'has_data', 'mode', 'saml_configuration', 'change_date', 'changed_by', + 'has_data', 'mode', 'saml_configuration', 'change_date', 'changed_by', 'csv_uuid_update_button', ) list_display_links = None @@ -135,6 +137,65 @@ def save_model(self, request, obj, form, change): super().save_model(request, obj, form, change) fetch_saml_metadata.apply_async((), countdown=2) + def get_urls(self): + """ Extend the admin URLs to include the custom CSV upload URL. """ + urls = super().get_urls() + custom_urls = [ + path('/upload-csv/', self.admin_site.admin_view(self.upload_csv), name='upload_csv'), + + ] + return custom_urls + urls + + @csrf_exempt + def upload_csv(self, request, slug): + """ Handle CSV upload and update UserSocialAuth model. """ + if not request.user.is_staff: + raise Http404 + if request.method == 'POST': + csv_file = request.FILES.get('csv_file') + if not csv_file or not csv_file.name.endswith('.csv'): + self.message_user(request, "Please upload a valid CSV file.", level=messages.ERROR) + else: + try: + decoded_file = csv_file.read().decode('utf-8').splitlines() + reader = csv.DictReader(decoded_file) + update_saml_users_social_auth_uid(reader, slug) + self.message_user(request, "CSV file has been processed successfully.") + except Exception as e: # pylint: disable=broad-except + self.message_user(request, f"Failed to process CSV file: {e}", level=messages.ERROR) + + # Always redirect back to the SAMLProviderConfig listing page + return HttpResponseRedirect(reverse('admin:third_party_auth_samlproviderconfig_changelist')) + + def change_view(self, request, object_slug, form_url='', extra_context=None): + """ Extend the change view to include CSV upload. """ + extra_context = extra_context or {} + extra_context['show_csv_upload'] = True + return super().change_view(request, object_slug, form_url, extra_context) + + def csv_uuid_update_button(self, obj): + """ Add CSV upload button to the form. """ + if obj: + form_url = reverse('admin:upload_csv', args=[obj.slug]) + return format_html( + '
' + '' + '' + '
', + form_url + ) + return "" + + csv_uuid_update_button.short_description = 'UUID UPDATE CSV' + csv_uuid_update_button.allow_tags = True + + def get_readonly_fields(self, request, obj=None): + """ Conditionally add csv_uuid_update_button to readonly fields. """ + readonly_fields = list(super().get_readonly_fields(request, obj)) + if obj: + readonly_fields.append('csv_uuid_update_button') + return readonly_fields + admin.site.register(SAMLProviderConfig, SAMLProviderConfigAdmin) diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index fde2bb9cbc0c..8e688208af19 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -90,7 +90,9 @@ def B(*args, **kwargs): from openedx.core.djangoapps.user_api import accounts from openedx.core.djangoapps.user_api.accounts.utils import username_suffix_generator from openedx.core.djangoapps.user_authn import cookies as user_authn_cookies +from openedx.core.djangoapps.user_authn.toggles import is_auto_generated_username_enabled from openedx.core.djangoapps.user_authn.utils import is_safe_login_or_logout_redirect +from openedx.core.djangoapps.user_authn.views.utils import get_auto_generated_username from common.djangoapps.third_party_auth.utils import ( get_associated_user_by_email_response, get_user_from_email, @@ -991,12 +993,15 @@ def get_username(strategy, details, backend, user=None, *args, **kwargs): # lin else: slug_func = lambda val: val - if email_as_username and details.get('email'): - username = details['email'] - elif details.get('username'): - username = details['username'] + if is_auto_generated_username_enabled(): + username = get_auto_generated_username(details) else: - username = uuid4().hex + if email_as_username and details.get('email'): + username = details['email'] + elif details.get('username'): + username = details['username'] + else: + username = uuid4().hex input_username = username final_username = slug_func(clean_func(username[:max_length])) diff --git a/common/djangoapps/third_party_auth/tasks.py b/common/djangoapps/third_party_auth/tasks.py index d702932f4d16..5778db4b7252 100644 --- a/common/djangoapps/third_party_auth/tasks.py +++ b/common/djangoapps/third_party_auth/tasks.py @@ -7,9 +7,11 @@ import requests from celery import shared_task +from django.core.exceptions import ObjectDoesNotExist from edx_django_utils.monitoring import set_code_owner_attribute from lxml import etree from requests import exceptions +from social_django.models import UserSocialAuth from common.djangoapps.third_party_auth.models import SAMLConfiguration, SAMLProviderConfig from common.djangoapps.third_party_auth.utils import ( @@ -127,3 +129,63 @@ def fetch_saml_metadata(): # Return counts for total, skipped, attempted, updated, and failed, along with any failure messages return num_total, num_skipped, num_attempted, num_updated, len(failure_messages), failure_messages + + +@shared_task +@set_code_owner_attribute +def update_saml_users_social_auth_uid(reader, slug): + """ + Update the UserSocialAuth UID for users based on a CSV reader input. + + This function reads old and new UIDs from a CSV reader, fetches the corresponding + SAMLProviderConfig object using the provided slug, and updates the UserSocialAuth + records accordingly. + + Args: + reader (csv.DictReader): A CSV reader object that iterates over rows containing 'old-uid' and 'new-uid'. + slug (str): The slug of the SAMLProviderConfig object to be fetched. + + Returns: + None + """ + log_prefix = "UpdateSamlUsersAuthUID" + log.info(f"{log_prefix}: Updated user UID request received with slug: {slug}") + + try: + # Fetching the SAMLProviderConfig object with slug + saml_provider_config = SAMLProviderConfig.objects.current_set().get(slug=slug) + except SAMLProviderConfig.DoesNotExist: + log.error(f"{log_prefix}: SAMLProviderConfig with slug {slug} does not exist") + return + except Exception as e: # pylint: disable=broad-except + log.error(f"{log_prefix}: An error occurred while fetching SAMLProviderConfig: {str(e)}") + return + + success_count = 0 + error_count = 0 + + for row in reader: + old_uid = row.get('old-uid') + new_uid = row.get('new-uid') + + # Construct the UID using the SAML provider slug and old UID + uid = f'{saml_provider_config.slug}:{old_uid}' + + try: + user_social_auth = UserSocialAuth.objects.get(uid=uid) + user_social_auth.uid = f'{saml_provider_config.slug}:{new_uid}' + user_social_auth.save() + log.info(f"{log_prefix}: Updated UID from {old_uid} to {new_uid} for user:{user_social_auth.user.id}.") + success_count += 1 + + except ObjectDoesNotExist: + log.error(f"{log_prefix}: UserSocialAuth with UID {uid} does not exist for old UID {old_uid}") + error_count += 1 + + except Exception as e: # pylint: disable=broad-except + log.error(f"{log_prefix}: An error occurred while updating UID for old UID {old_uid}" + f" to new UID {new_uid}: {str(e)}") + error_count += 1 + + log.info(f"{log_prefix}: Process completed for SAML configuration with slug: {slug}, {success_count} records" + f" successfully processed, {error_count} records encountered errors") diff --git a/common/djangoapps/util/file.py b/common/djangoapps/util/file.py index 66fc2a6f5f35..b2892e6f42c9 100644 --- a/common/djangoapps/util/file.py +++ b/common/djangoapps/util/file.py @@ -13,6 +13,7 @@ from django.utils.translation import gettext as _ from django.utils.translation import ngettext from pytz import UTC +from storages.backends.s3boto3 import S3Boto3Storage class FileValidationException(Exception): @@ -23,7 +24,7 @@ class FileValidationException(Exception): def store_uploaded_file( - request, file_key, allowed_file_types, base_storage_filename, max_file_size, validator=None, + request, file_key, allowed_file_types, base_storage_filename, max_file_size, validator=None, is_private=False, ): """ Stores an uploaded file to django file storage. @@ -45,6 +46,8 @@ def store_uploaded_file( a `FileValidationException` if the file is not properly formatted. If any exception is thrown, the stored file will be deleted before the exception is re-raised. Note that the implementor of the validator function should take care to close the stored file if they open it for reading. + is_private (Boolean): an optional boolean which if True and the storage backend is S3, + sets the ACL for the file object to be private. Returns: Storage: the file storage object where the file can be retrieved from @@ -75,6 +78,12 @@ def store_uploaded_file( file_storage = DefaultStorage() # If a file already exists with the supplied name, file_storage will make the filename unique. stored_file_name = file_storage.save(stored_file_name, uploaded_file) + if is_private and settings.DEFAULT_FILE_STORAGE == 'storages.backends.s3boto3.S3Boto3Storage': + S3Boto3Storage().connection.meta.client.put_object_acl( + ACL='private', + Bucket=settings.AWS_STORAGE_BUCKET_NAME, + Key=stored_file_name, + ) if validator: try: diff --git a/common/djangoapps/util/tests/test_db.py b/common/djangoapps/util/tests/test_db.py index 34f563ed5b5e..4a16c2a20aa6 100644 --- a/common/djangoapps/util/tests/test_db.py +++ b/common/djangoapps/util/tests/test_db.py @@ -1,6 +1,5 @@ """Tests for util.db module.""" -import unittest from io import StringIO import ddt @@ -121,9 +120,6 @@ class MigrationTests(TestCase): Tests for migrations. """ - @unittest.skip( - "Temporary skip for ENT-9003 while the career_engagement_network_message column is renamed." - ) @override_settings(MIGRATION_MODULES={}) def test_migrations_are_in_sync(self): """ diff --git a/common/static/data/geoip/GeoLite2-Country.mmdb b/common/static/data/geoip/GeoLite2-Country.mmdb index aa826ceb48db..2e9d48109648 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 new file mode 100644 index 000000000000..2c567c6fb1f4 --- /dev/null +++ b/common/static/sass/_builtin-block-variables.scss @@ -0,0 +1,73 @@ +/* + * In pursuit of decoupling the built-in XBlocks from edx-platform's Sass build + * and ensuring comprehensive theming support in the extracted XBlocks, + * we need to expose Sass variables as CSS variables. + * + * Ticket/Issue: https://github.com/openedx/edx-platform/issues/35173 + */ +@import 'bourbon/bourbon'; +@import 'lms/theme/variables'; +@import 'lms/theme/variables-v1'; +@import 'cms/static/sass/partials/cms/theme/_variables'; +@import 'cms/static/sass/partials/cms/theme/_variables-v1'; +@import 'bootstrap/scss/variables'; +@import 'vendor/bi-app/bi-app-ltr'; +@import 'edx-pattern-library-shims/base/_variables.scss'; + +:root { + --action-primary-active-bg: $action-primary-active-bg; + --all-text-inputs: $all-text-inputs; + --base-font-size: $base-font-size; + --base-line-height: $base-line-height; + --baseline: $baseline; + --black: $black; + --black-t2: $black-t2; + --blue: $blue; + --blue-d1: $blue-d1; + --blue-d2: $blue-d2; + --blue-d4: $blue-d4; + --body-color: $body-color; + --border-color: $border-color; + --bp-screen-lg: $bp-screen-lg; + --btn-brand-focus-background: $btn-brand-focus-background; + --correct: $correct; + --danger: $danger; + --darkGrey: $darkGrey; + --error-color: $error-color; + --font-bold: $font-bold; + --font-family-sans-serif: $font-family-sans-serif; + --general-color-accent: $general-color-accent; + --gray: $gray; + --gray-300: $gray-300; + --gray-d1: $gray-d1; + --gray-l2: $gray-l2; + --gray-l3: $gray-l3; + --gray-l4: $gray-l4; + --gray-l6: $gray-l6; + --incorrect: $incorrect; + --lightGrey: $lightGrey; + --lighter-base-font-color: $lighter-base-font-color; + --link-color: $link-color; + --medium-font-size: $medium-font-size; + --partially-correct: $partially-correct; + --primary: $primary; + --shadow: $shadow; + --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; + --tmg-s2: $tmg-s2; + --transparent: $transparent; + --uxpl-gray-background: $uxpl-gray-background; + --uxpl-gray-base: $uxpl-gray-base; + --uxpl-gray-dark: $uxpl-gray-dark; + --very-light-text: $very-light-text; + --warning: $warning; + --warning-color: $warning-color; + --warning-color-accent: $warning-color-accent; + --white: $white; + --yellow: $yellow; +} diff --git a/docs/hooks/events.rst b/docs/hooks/events.rst index 7f9584c9e8e1..bccb98e56a42 100644 --- a/docs/hooks/events.rst +++ b/docs/hooks/events.rst @@ -233,17 +233,29 @@ Content Authoring Events - 2023-07-20 * - `LIBRARY_BLOCK_CREATED `_ - - org.openedx.content_authoring.content_library.created.v1 + - org.openedx.content_authoring.library_block.created.v1 - 2023-07-20 * - `LIBRARY_BLOCK_UPDATED `_ - - org.openedx.content_authoring.content_library.updated.v1 + - org.openedx.content_authoring.library_block.updated.v1 - 2023-07-20 * - `LIBRARY_BLOCK_DELETED `_ - - org.openedx.content_authoring.content_library.deleted.v1 + - org.openedx.content_authoring.library_block.deleted.v1 - 2023-07-20 - * - `CONTENT_OBJECT_TAGS_CHANGED `_ - - org.openedx.content_authoring.content.object.tags.changed.v1 - - 2024-03-31 + * - `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/lms-openapi.yaml b/docs/lms-openapi.yaml index 38c52e737e19..5e9afcc6d370 100644 --- a/docs/lms-openapi.yaml +++ b/docs/lms-openapi.yaml @@ -3461,29 +3461,6 @@ paths: in: path required: true type: string - /demographics/v1/demographics/status/: - get: - operationId: demographics_v1_demographics_status_list - summary: GET /api/user/v1/accounts/demographics/status - description: This is a Web API to determine the status of demographics related - features - parameters: [] - responses: - '200': - description: '' - tags: - - demographics - patch: - operationId: demographics_v1_demographics_status_partial_update - summary: PATCH /api/user/v1/accounts/demographics/status - description: This is a Web API to update fields that are dependent on user interaction. - parameters: [] - responses: - '200': - description: '' - tags: - - demographics - parameters: [] /discounts/course/{course_key_string}: get: operationId: discounts_course_read @@ -5300,19 +5277,6 @@ paths: required: true type: string format: uuid - /entitlements/v1/subscriptions/entitlements/revoke: - post: - operationId: entitlements_v1_subscriptions_entitlements_revoke_create - description: |- - Invokes the entitlements expiration process for the provided uuids and downgrades the - enrollments to Audit mode. - parameters: [] - responses: - '201': - description: '' - tags: - - entitlements - parameters: [] /experiments/v0/custom/REV-934/: get: operationId: experiments_v0_custom_REV-934_list @@ -6649,6 +6613,11 @@ paths: course, chapter, sequential, vertical, html, problem, video, and discussion. display_name: (str) The display name of the block. + course_progress: (dict) Contains information about how many assignments are in the course + and how many assignments the student has completed. + Included here: + * total_assignments_count: (int) Total course's assignments count. + * assignments_completed: (int) Assignments witch the student has completed. **Returns** @@ -6696,6 +6665,26 @@ paths: in: path required: true type: string + /mobile/{api_version}/course_info/{course_id}/enrollment_details: + get: + operationId: mobile_course_info_enrollment_details_list + summary: Handle the GET request + description: Returns user enrollment and course details. + parameters: [] + responses: + '200': + description: '' + tags: + - mobile + parameters: + - name: api_version + in: path + required: true + type: string + - name: course_id + in: path + required: true + type: string /mobile/{api_version}/course_info/{course_id}/handouts: get: operationId: mobile_course_info_handouts_list @@ -6861,6 +6850,10 @@ paths: An additional attribute "expiration" has been added to the response, which lists the date when access to the course will expire or null if it doesn't expire. + In v4 we added to the response primary object. Primary object contains the latest user's enrollment + or course where user has the latest progress. Primary object has been cut from user's + enrolments array and inserted into separated section with key `primary`. + **Example Request** GET /api/mobile/v1/users/{username}/course_enrollments/ @@ -6910,14 +6903,14 @@ paths: * mode: The type of certificate registration for this course (honor or certified). * url: URL to the downloadable version of the certificate, if exists. + * course_progress: Contains information about how many assignments are in the course + and how many assignments the student has completed. + * total_assignments_count: Total course's assignments count. + * assignments_completed: Assignments witch the student has completed. parameters: [] responses: '200': description: '' - schema: - type: array - items: - $ref: '#/definitions/CourseEnrollment' tags: - mobile parameters: @@ -7031,22 +7024,6 @@ paths: tags: - notifications parameters: [] - /notifications/channel/configurations/{course_key_string}: - patch: - operationId: notifications_channel_configurations_partial_update - description: Update an existing user notification preference for an entire channel - with the data in the request body. - parameters: [] - responses: - '200': - description: '' - tags: - - notifications - parameters: - - name: course_key_string - in: path - required: true - type: string /notifications/configurations/{course_key_string}: get: operationId: notifications_configurations_read @@ -7222,6 +7199,38 @@ paths: in: path required: true type: string + /notifications/preferences/update/{username}/{patch}/: + get: + operationId: notifications_preferences_update_read + description: |- + View to update user preferences from encrypted username and patch. + username and patch must be string + parameters: [] + responses: + '200': + description: '' + tags: + - notifications + post: + operationId: notifications_preferences_update_create + description: |- + View to update user preferences from encrypted username and patch. + username and patch must be string + parameters: [] + responses: + '201': + description: '' + tags: + - notifications + parameters: + - name: username + in: path + required: true + type: string + - name: patch + in: path + required: true + type: string /notifications/read/: patch: operationId: notifications_read_partial_update @@ -11731,39 +11740,6 @@ definitions: title: Course enrollments type: string readOnly: true - CourseEnrollment: - type: object - properties: - audit_access_expires: - title: Audit access expires - type: string - readOnly: true - created: - title: Created - type: string - format: date-time - readOnly: true - x-nullable: true - mode: - title: Mode - type: string - maxLength: 100 - minLength: 1 - is_active: - title: Is active - type: boolean - course: - title: Course - type: string - readOnly: true - certificate: - title: Certificate - type: string - readOnly: true - course_modes: - title: Course modes - type: string - readOnly: true Notification: required: - app_name diff --git a/docs/references/static-assets.rst b/docs/references/static-assets.rst index 2c571cd35355..b1db36b7f139 100644 --- a/docs/references/static-assets.rst +++ b/docs/references/static-assets.rst @@ -127,7 +127,7 @@ If you would like to understand these more deeply, they are defined in supported, but their underlying implementations may change without notice. .. _webpack CLI: https://webpack.js.org/api/cli/ -.. _package.json: ../package.json +.. _package.json: ../../package.json Collect assets ************** diff --git a/lms/djangoapps/bulk_email/apps.py b/lms/djangoapps/bulk_email/apps.py index 2cfb725ba85e..63a44fcfcde4 100644 --- a/lms/djangoapps/bulk_email/apps.py +++ b/lms/djangoapps/bulk_email/apps.py @@ -7,3 +7,7 @@ class BulkEmailConfig(AppConfig): Application Configuration for bulk_email. """ name = 'lms.djangoapps.bulk_email' + + def ready(self): + import lms.djangoapps.bulk_email.signals # lint-amnesty, pylint: disable=unused-import + from edx_ace.signals import ACE_MESSAGE_SENT # lint-amnesty, pylint: disable=unused-import diff --git a/lms/djangoapps/bulk_email/signals.py b/lms/djangoapps/bulk_email/signals.py index 818d222b7a34..086b3636f7ef 100644 --- a/lms/djangoapps/bulk_email/signals.py +++ b/lms/djangoapps/bulk_email/signals.py @@ -1,12 +1,13 @@ """ Signal handlers for the bulk_email app """ - - +from django.contrib.auth import get_user_model from django.dispatch import receiver +from eventtracking import tracker from common.djangoapps.student.models import CourseEnrollment from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_MAILINGS +from edx_ace.signals import ACE_MESSAGE_SENT from .models import Optout @@ -24,3 +25,35 @@ def force_optout_all(sender, **kwargs): # lint-amnesty, pylint: disable=unused- for enrollment in CourseEnrollment.objects.filter(user=user): Optout.objects.get_or_create(user=user, course_id=enrollment.course.id) + + +@receiver(ACE_MESSAGE_SENT) +def ace_email_sent_handler(sender, **kwargs): + """ + When an email is sent using ACE, this method will create an event to detect ace email success status + """ + # Fetch the message object from kwargs, defaulting to None if not present + message = kwargs.get('message', None) + + user_model = get_user_model() + try: + user_id = user_model.objects.get(email=message.recipient.email_address).id + except user_model.DoesNotExist: + user_id = None + course_email = message.context.get('course_email', None) + course_id = message.context.get('course_id') + if not course_id: + course_id = course_email.course_id if course_email else None + try: + channel = sender.__class__.__name__ + except AttributeError: + channel = 'Other' + tracker.emit( + 'edx.ace.message_sent', + { + 'message_type': message.name, + 'channel': channel, + 'course_id': course_id, + 'user_id': user_id, + } + ) diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index afad888fe0c5..0152d14ff01f 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -26,6 +26,7 @@ from django.utils.translation import gettext as _ from django.utils.translation import override as override_language from edx_django_utils.monitoring import set_code_owner_attribute +from eventtracking import tracker from markupsafe import escape from common.djangoapps.util.date_utils import get_default_time_display @@ -467,7 +468,14 @@ def _send_course_email(entry_id, email_id, to_list, global_email_context, subtas "send." ) raise exc - + tracker.emit( + 'edx.bulk_email.created', + { + 'course_id': str(course_email.course_id), + 'to_list': [user_obj.get('email', '') for user_obj in to_list], + 'total_recipients': total_recipients, + } + ) # Exclude optouts (if not a retry): # Note that we don't have to do the optout logic at all if this is a retry, # because we have presumably already performed the optout logic on the first diff --git a/lms/djangoapps/bulk_email/views.py b/lms/djangoapps/bulk_email/views.py index 528baf97b53c..7ee3ea81b19a 100644 --- a/lms/djangoapps/bulk_email/views.py +++ b/lms/djangoapps/bulk_email/views.py @@ -7,6 +7,7 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.http import Http404 +from eventtracking import tracker from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey @@ -60,4 +61,16 @@ def opt_out_email_updates(request, token, course_id): course_id, ) + event_name = 'edx.bulk_email.opt_out' + event_data = { + "username": user.username, + "user_id": user.id, + "course_id": course_id, + } + with tracker.get_tracker().context(event_name, event_data): + tracker.emit( + event_name, + event_data + ) + return render_to_response('bulk_email/unsubscribe_success.html', context) diff --git a/lms/djangoapps/certificates/README.rst b/lms/djangoapps/certificates/README.rst index cd6d2b9e5077..a15c24505aec 100644 --- a/lms/djangoapps/certificates/README.rst +++ b/lms/djangoapps/certificates/README.rst @@ -2,23 +2,20 @@ Status: Maintenance Responsibilities ================ -The Certificates app is responsible for creating and managing course run certificates, including relevant data models for invalidating certificates and managing the allowlist. +The Certificates app is responsible for creating and managing course certificates, including +certificate settings, course certificate templates, and generated learner course certificates. +The app includes relevant data models for invalidating certificates and managing the allowlist. -Direction: Move and Extract -=========================== -Certificates related functionality is scattered across a number of places and should be better consolidated. Today we have: +See Also +======== +Course Certificates related functionality is scattered across a number of places: * ``lms/djangoapps/certificates`` -* ``openedx/core/djangoapps/certificates`` +* ``openedx/core/djangoapps/credentials`` * ``cms/djangoapps/contentstore/views/certificates.py`` * Various front-end static templates in multiple locations -Ideally, we want to extract these into the `credentials service`_, which would be ultimately responsible for Course-Run and Program certificates (and possibly other credentials). Right now, the `credentials service`_ only manages Program certificates. +See also the `credentials service`_, which is the system of record for a learner's Program Certificates. .. _credentials service: https://github.com/openedx/credentials -Glossary -======== - -More Documentation -================== diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index ec790a4315c1..4439eeb5f220 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -7,10 +7,8 @@ certificates models or any other certificates modules. """ - import logging from datetime import datetime -from pytz import UTC from django.conf import settings from django.contrib.auth import get_user_model @@ -18,18 +16,18 @@ from django.db.models import Q from eventtracking import tracker from opaque_keys.edx.django.models import CourseKeyField +from opaque_keys.edx.keys import CourseKey from organizations.api import get_course_organization_id +from pytz import UTC from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.api import is_user_enrolled_in_course from common.djangoapps.student.models import CourseEnrollment from lms.djangoapps.branding import api as branding_api -from lms.djangoapps.certificates.generation_handler import ( - generate_certificate_task as _generate_certificate_task, - is_on_certificate_allowlist as _is_on_certificate_allowlist -) from lms.djangoapps.certificates.config import AUTO_CERTIFICATE_GENERATION as _AUTO_CERTIFICATE_GENERATION from lms.djangoapps.certificates.data import CertificateStatuses +from lms.djangoapps.certificates.generation_handler import generate_certificate_task as _generate_certificate_task +from lms.djangoapps.certificates.generation_handler import is_on_certificate_allowlist as _is_on_certificate_allowlist from lms.djangoapps.certificates.models import ( CertificateAllowlist, CertificateDateOverride, @@ -41,16 +39,13 @@ ExampleCertificateSet, GeneratedCertificate, ) -from lms.djangoapps.certificates.utils import ( - get_certificate_url as _get_certificate_url, - has_html_certificates_enabled as _has_html_certificates_enabled, - should_certificate_be_visible as _should_certificate_be_visible, - certificate_status as _certificate_status, - certificate_status_for_student as _certificate_status_for_student, -) +from lms.djangoapps.certificates.utils import certificate_status as _certificate_status +from lms.djangoapps.certificates.utils import certificate_status_for_student as _certificate_status_for_student +from lms.djangoapps.certificates.utils import get_certificate_url as _get_certificate_url +from lms.djangoapps.certificates.utils import has_html_certificates_enabled as _has_html_certificates_enabled +from lms.djangoapps.certificates.utils import should_certificate_be_visible as _should_certificate_be_visible from lms.djangoapps.instructor import access from lms.djangoapps.utils import _get_key -from opaque_keys.edx.keys import CourseKey from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order @@ -83,8 +78,8 @@ def _format_certificate_for_user(username, cert): "is_passing": CertificateStatuses.is_passing_status(cert.status), "is_pdf_certificate": bool(cert.download_url), "download_url": ( - cert.download_url or get_certificate_url(cert.user.id, cert.course_id, uuid=cert.verify_uuid, - user_certificate=cert) + cert.download_url + or get_certificate_url(cert.user.id, cert.course_id, uuid=cert.verify_uuid, user_certificate=cert) if cert.status == CertificateStatuses.downloadable else None ), @@ -139,10 +134,7 @@ def get_certificate_for_user(username, course_key, format_results=True): the GeneratedCertificate object itself. """ try: - cert = GeneratedCertificate.eligible_certificates.get( - user__username=username, - course_id=course_key - ) + cert = GeneratedCertificate.eligible_certificates.get(user__username=username, course_id=course_key) except GeneratedCertificate.DoesNotExist: return None @@ -178,13 +170,8 @@ def get_certificates_for_user_by_course_keys(user, course_keys): Course keys for courses for which the user does not have a certificate will be omitted. """ - certs = GeneratedCertificate.eligible_certificates.filter( - user=user, course_id__in=course_keys - ) - return { - cert.course_id: _format_certificate_for_user(user.username, cert) - for cert in certs - } + certs = GeneratedCertificate.eligible_certificates.filter(user=user, course_id__in=course_keys) + return {cert.course_id: _format_certificate_for_user(user.username, cert) for cert in certs} def get_recently_modified_certificates(course_keys=None, start_date=None, end_date=None, user_ids=None): @@ -195,30 +182,28 @@ def get_recently_modified_certificates(course_keys=None, start_date=None, end_da cert_filter_args = {} if course_keys: - cert_filter_args['course_id__in'] = course_keys + cert_filter_args["course_id__in"] = course_keys if start_date: - cert_filter_args['modified_date__gte'] = start_date + cert_filter_args["modified_date__gte"] = start_date if end_date: - cert_filter_args['modified_date__lte'] = end_date + cert_filter_args["modified_date__lte"] = end_date if user_ids: - cert_filter_args['user__id__in'] = user_ids + cert_filter_args["user__id__in"] = user_ids # Include certificates with a CertificateDateOverride modified within the # given time range. if start_date or end_date: certs_with_modified_overrides = get_certs_with_modified_overrides(course_keys, start_date, end_date, user_ids) - return GeneratedCertificate.objects.filter( - **cert_filter_args - ).union( - certs_with_modified_overrides - ).order_by( - 'modified_date' + return ( + GeneratedCertificate.objects.filter(**cert_filter_args) + .union(certs_with_modified_overrides) + .order_by("modified_date") ) - return GeneratedCertificate.objects.filter(**cert_filter_args).order_by('modified_date') + return GeneratedCertificate.objects.filter(**cert_filter_args).order_by("modified_date") def get_certs_with_modified_overrides(course_keys=None, start_date=None, end_date=None, user_ids=None): @@ -229,9 +214,9 @@ def get_certs_with_modified_overrides(course_keys=None, start_date=None, end_dat """ override_filter_args = {} if start_date: - override_filter_args['history_date__gte'] = start_date + override_filter_args["history_date__gte"] = start_date if end_date: - override_filter_args['history_date__lte'] = end_date + override_filter_args["history_date__lte"] = end_date # Get the HistoricalCertificateDateOverrides that have entries within the # given date range. We check the history table to catch deleted overrides @@ -239,16 +224,16 @@ def get_certs_with_modified_overrides(course_keys=None, start_date=None, end_dat overrides = CertificateDateOverride.history.filter(**override_filter_args) # Get the associated GeneratedCertificate ids. - override_cert_ids = overrides.values_list('generated_certificate', flat=True) + override_cert_ids = overrides.values_list("generated_certificate", flat=True) # Build the args for the GeneratedCertificate query. First, filter by all # certs identified in override_cert_ids; then by the other arguments passed, # if present. - cert_filter_args = {'pk__in': override_cert_ids} + cert_filter_args = {"pk__in": override_cert_ids} if course_keys: - cert_filter_args['course_id__in'] = course_keys + cert_filter_args["course_id__in"] = course_keys if user_ids: - cert_filter_args['user__id__in'] = user_ids + cert_filter_args["user__id__in"] = user_ids return GeneratedCertificate.objects.filter(**cert_filter_args) @@ -287,14 +272,17 @@ def certificate_downloadable_status(student, course_key): # If the certificate status is an error user should view that status is "generating". # On the back-end, need to monitor those errors and re-submit the task. + # pylint: disable=simplifiable-if-expression response_data = { - 'is_downloadable': False, - 'is_generating': True if current_status['status'] in [CertificateStatuses.generating, # pylint: disable=simplifiable-if-expression - CertificateStatuses.error] else False, - 'is_unverified': True if current_status['status'] == CertificateStatuses.unverified else False, # pylint: disable=simplifiable-if-expression - 'download_url': None, - 'uuid': None, + "is_downloadable": False, + "is_generating": ( + True if current_status["status"] in [CertificateStatuses.generating, CertificateStatuses.error] else False + ), + "is_unverified": (True if current_status["status"] == CertificateStatuses.unverified else False), + "download_url": None, + "uuid": None, } + # pylint: enable=simplifiable-if-expression course_overview = get_course_overview_or_none(course_key) @@ -306,28 +294,28 @@ def certificate_downloadable_status(student, course_key): display_behavior_is_valid = True if ( - not certificates_viewable_for_course(course_overview) and - CertificateStatuses.is_passing_status(current_status['status']) and - display_behavior_is_valid and - course_overview.certificate_available_date + not certificates_viewable_for_course(course_overview) + and CertificateStatuses.is_passing_status(current_status["status"]) + and display_behavior_is_valid + and course_overview.certificate_available_date ): - response_data['earned_but_not_available'] = True - response_data['certificate_available_date'] = course_overview.certificate_available_date + response_data["earned_but_not_available"] = 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, course_overview.has_ended(), course_overview.certificate_available_date, - course_overview.self_paced + course_overview.self_paced, ) - if current_status['status'] == CertificateStatuses.downloadable and may_view_certificate: - response_data['is_downloadable'] = True - response_data['download_url'] = current_status['download_url'] or get_certificate_url( - student.id, course_key, current_status['uuid'] + if current_status["status"] == CertificateStatuses.downloadable and may_view_certificate: + response_data["is_downloadable"] = True + response_data["download_url"] = current_status["download_url"] or get_certificate_url( + student.id, course_key, current_status["uuid"] ) - response_data['is_pdf_certificate'] = bool(current_status['download_url']) - response_data['uuid'] = current_status['uuid'] + response_data["is_pdf_certificate"] = bool(current_status["download_url"]) + response_data["uuid"] = current_status["uuid"] return response_data @@ -356,11 +344,14 @@ def set_cert_generation_enabled(course_key, is_enabled): """ CertificateGenerationCourseSetting.set_self_generation_enabled_for_course(course_key, is_enabled) - cert_event_type = 'enabled' if is_enabled else 'disabled' - event_name = '.'.join(['edx', 'certificate', 'generation', cert_event_type]) - tracker.emit(event_name, { - 'course_id': str(course_key), - }) + cert_event_type = "enabled" if is_enabled else "disabled" + event_name = ".".join(["edx", "certificate", "generation", cert_event_type]) + tracker.emit( + event_name, + { + "course_id": str(course_key), + }, + ) if is_enabled: log.info("Enabled self-generated certificates for course '%s'.", str(course_key)) else: @@ -407,8 +398,8 @@ def has_self_generated_certificates_enabled(course_key): """ return ( - CertificateGenerationConfiguration.current().enabled and - CertificateGenerationCourseSetting.is_self_generation_enabled_for_course(course_key) + CertificateGenerationConfiguration.current().enabled + and CertificateGenerationCourseSetting.is_self_generation_enabled_for_course(course_key) ) @@ -458,10 +449,10 @@ def get_active_web_certificate(course, is_preview_mode=None): """ Retrieves the active web certificate configuration for the specified course """ - certificates = getattr(course, 'certificates', {}) - configurations = certificates.get('certificates', []) + certificates = getattr(course, "certificates", {}) + configurations = certificates.get("certificates", []) for config in configurations: - if config.get('is_active') or is_preview_mode: + if config.get("is_active") or is_preview_mode: return config return None @@ -478,32 +469,19 @@ def get_certificate_template(course_key, mode, language): active_templates = CertificateTemplate.objects.filter(is_active=True) if org_id and mode: # get template by org, mode, and key - org_mode_and_key_templates = active_templates.filter( - organization_id=org_id, - mode=mode, - course_key=course_key - ) + org_mode_and_key_templates = active_templates.filter(organization_id=org_id, mode=mode, course_key=course_key) template = _get_language_specific_template_or_default(language, org_mode_and_key_templates) # since no template matched that course_key, only consider templates with empty course_key empty_course_key_templates = active_templates.filter(course_key=CourseKeyField.Empty) if not template and org_id and mode: # get template by org and mode - org_and_mode_templates = empty_course_key_templates.filter( - organization_id=org_id, - mode=mode - ) + org_and_mode_templates = empty_course_key_templates.filter(organization_id=org_id, mode=mode) template = _get_language_specific_template_or_default(language, org_and_mode_templates) if not template and org_id: # get template by only org - org_templates = empty_course_key_templates.filter( - organization_id=org_id, - mode=None - ) + org_templates = empty_course_key_templates.filter(organization_id=org_id, mode=None) template = _get_language_specific_template_or_default(language, org_templates) if not template and mode: # get template by only mode - mode_templates = empty_course_key_templates.filter( - organization_id=None, - mode=mode - ) + mode_templates = empty_course_key_templates.filter(organization_id=None, mode=mode) template = _get_language_specific_template_or_default(language, mode_templates) return template if template else None @@ -515,10 +493,10 @@ def _get_language_specific_template_or_default(language, templates): """ two_letter_language = _get_two_letter_language_code(language) - language_or_default_templates = list(templates.filter(Q(language=two_letter_language) - | Q(language=None) | Q(language=''))) - language_specific_template = _get_language_specific_template(two_letter_language, - language_or_default_templates) + language_or_default_templates = list( + templates.filter(Q(language=two_letter_language) | Q(language=None) | Q(language="")) + ) + language_specific_template = _get_language_specific_template(two_letter_language, language_or_default_templates) if language_specific_template: return language_specific_template else: @@ -537,7 +515,7 @@ def _get_all_languages_or_default_template(templates): Returns the first template that isn't language specific """ for template in templates: - if template.language == '': + if template.language == "": return template return templates[0] if templates else None @@ -550,8 +528,8 @@ def _get_two_letter_language_code(language_code): """ if language_code is None: return None - elif language_code == '': - return '' + elif language_code == "": + return "" else: return language_code[:2] @@ -560,7 +538,7 @@ def get_asset_url_by_slug(asset_slug): """ Returns certificate template asset url for given asset_slug. """ - asset_url = '' + asset_url = "" try: template_asset = CertificateTemplateAsset.objects.get(asset_slug=asset_slug) asset_url = template_asset.asset.url @@ -592,17 +570,17 @@ def get_certificate_footer_context(): # get Terms of Service and Honor Code page url terms_of_service_and_honor_code = branding_api.get_tos_and_honor_code_url() if terms_of_service_and_honor_code != branding_api.EMPTY_URL: - data.update({'company_tos_url': terms_of_service_and_honor_code}) + data.update({"company_tos_url": terms_of_service_and_honor_code}) # get Privacy Policy page url privacy_policy = branding_api.get_privacy_url() if privacy_policy != branding_api.EMPTY_URL: - data.update({'company_privacy_url': privacy_policy}) + data.update({"company_privacy_url": privacy_policy}) # get About page url about = branding_api.get_about_url() if about != branding_api.EMPTY_URL: - data.update({'company_about_url': about}) + data.update({"company_about_url": about}) return data @@ -631,7 +609,7 @@ def certificates_viewable_for_course(course): course.certificates_show_before_end, course.has_ended(), course.certificate_available_date, - course.self_paced + course.self_paced, ) @@ -650,9 +628,9 @@ def create_or_update_certificate_allowlist_entry(user, course_key, notes, enable user=user, course_id=course_key, defaults={ - 'allowlist': enabled, - 'notes': notes, - } + "allowlist": enabled, + "notes": notes, + }, ) log.info(f"Updated the allowlist of course {course_key} with student {user.id} and enabled={enabled}") @@ -675,7 +653,7 @@ def remove_allowlist_entry(user, course_key): certificate = get_certificate_for_user(user.username, course_key, False) if certificate: log.info(f"Invalidating certificate for student {user.id} in course {course_key} before allowlist removal.") - certificate.invalidate(source='allowlist_removal') + certificate.invalidate(source="allowlist_removal") log.info(f"Removing student {user.id} from the allowlist in course {course_key}.") allowlist_entry.delete() @@ -735,10 +713,10 @@ def create_certificate_invalidation_entry(certificate, user_requesting_invalidat certificate_invalidation, __ = CertificateInvalidation.objects.update_or_create( generated_certificate=certificate, defaults={ - 'active': True, - 'invalidated_by': user_requesting_invalidation, - 'notes': notes, - } + "active": True, + "invalidated_by": user_requesting_invalidation, + "notes": notes, + }, ) return certificate_invalidation @@ -772,10 +750,7 @@ def get_enrolled_allowlisted_users(course_key): - are allowlisted in this course run """ users = CourseEnrollment.objects.users_enrolled_in(course_key) - return users.filter( - certificateallowlist__course_id=course_key, - certificateallowlist__allowlist=True - ) + return users.filter(certificateallowlist__course_id=course_key, certificateallowlist__allowlist=True) def get_enrolled_allowlisted_not_passing_users(course_key): @@ -787,8 +762,7 @@ def get_enrolled_allowlisted_not_passing_users(course_key): """ users = get_enrolled_allowlisted_users(course_key) return users.exclude( - generatedcertificate__course_id=course_key, - generatedcertificate__status__in=CertificateStatuses.PASSED_STATUSES + generatedcertificate__course_id=course_key, generatedcertificate__status__in=CertificateStatuses.PASSED_STATUSES ) @@ -796,10 +770,10 @@ def certificate_info_for_user(user, course_id, grade, user_is_allowlisted, user_ """ Returns the certificate info for a user for grade report. """ - certificate_is_delivered = 'N' - certificate_type = 'N/A' + certificate_is_delivered = "N" + certificate_type = "N/A" status = _certificate_status(user_certificate) - certificate_generated = status['status'] == CertificateStatuses.downloadable + certificate_generated = status["status"] == CertificateStatuses.downloadable course_overview = get_course_overview_or_none(course_id) if not course_overview: return None @@ -808,18 +782,17 @@ def certificate_info_for_user(user, course_id, grade, user_is_allowlisted, user_ course_overview.certificates_show_before_end, course_overview.has_ended(), course_overview.certificate_available_date, - course_overview.self_paced + course_overview.self_paced, ) enrollment_mode, __ = CourseEnrollment.enrollment_mode_for_user(user, course_id) mode_is_verified = enrollment_mode in CourseMode.VERIFIED_MODES user_is_verified = grade is not None and mode_is_verified - eligible_for_certificate = 'Y' if (user_is_allowlisted or user_is_verified or certificate_generated) \ - else 'N' + eligible_for_certificate = "Y" if (user_is_allowlisted or user_is_verified or certificate_generated) else "N" if certificate_generated and can_have_certificate: - certificate_is_delivered = 'Y' - certificate_type = status['mode'] + certificate_is_delivered = "Y" + certificate_type = status["mode"] return [eligible_for_certificate, certificate_is_delivered, certificate_type] @@ -854,11 +827,11 @@ def can_show_certificate_message(course, student, course_grade, certificates_ena has_passed_or_is_allowlisted = _has_passed_or_is_allowlisted(course, student, course_grade) return ( - (auto_cert_gen_enabled or certificates_enabled_for_course) and - has_active_enrollment and - certificates_are_viewable and - has_passed_or_is_allowlisted and - (not is_beta_tester) + (auto_cert_gen_enabled or certificates_enabled_for_course) + and has_active_enrollment + and certificates_are_viewable + and has_passed_or_is_allowlisted + and (not is_beta_tester) ) @@ -876,7 +849,7 @@ def _course_uses_available_date(course): ) -def available_date_for_certificate(course, certificate): +def available_date_for_certificate(course, certificate) -> datetime: """ Returns the available date to use with a certificate @@ -935,20 +908,15 @@ def invalidate_certificate(user_id, course_key_or_id, source): """ course_key = _get_key(course_key_or_id, CourseKey) if _is_on_certificate_allowlist(user_id, course_key): - log.info(f'User {user_id} is on the allowlist for {course_key}. The certificate will not be invalidated.') + log.info(f"User {user_id} is on the allowlist for {course_key}. The certificate will not be invalidated.") return False try: - generated_certificate = GeneratedCertificate.objects.get( - user=user_id, - course_id=course_key - ) + generated_certificate = GeneratedCertificate.objects.get(user=user_id, course_id=course_key) generated_certificate.invalidate(source=source) except ObjectDoesNotExist: log.warning( - 'Invalidation failed because a certificate for user %d in course %s does not exist.', - user_id, - course_key + "Invalidation failed because a certificate for user %d in course %s does not exist.", user_id, course_key ) return False diff --git a/lms/djangoapps/certificates/tests/test_api.py b/lms/djangoapps/certificates/tests/test_api.py index c766d4250bd9..21d04a7e3da6 100644 --- a/lms/djangoapps/certificates/tests/test_api.py +++ b/lms/djangoapps/certificates/tests/test_api.py @@ -1,6 +1,5 @@ """Tests for the certificates Python API. """ - import uuid from contextlib import contextmanager from datetime import datetime, timedelta @@ -20,17 +19,10 @@ from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import CourseLocator from testfixtures import LogCapture -from xmodule.data import CertificatesDisplayBehaviors -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.course_modes.tests.factories import CourseModeFactory -from common.djangoapps.student.tests.factories import ( - CourseEnrollmentFactory, - GlobalStaffFactory, - UserFactory -) +from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, GlobalStaffFactory, UserFactory from common.djangoapps.util.testing import EventTestMixin from lms.djangoapps.certificates.api import ( auto_certificate_generation_enabled, @@ -38,8 +30,8 @@ can_be_added_to_allowlist, can_show_certificate_available_date_field, can_show_certificate_message, - certificate_status_for_student, certificate_downloadable_status, + certificate_status_for_student, clear_pii_from_certificate_records_for_user, create_certificate_invalidation_entry, create_or_update_certificate_allowlist_entry, @@ -69,41 +61,45 @@ ) from lms.djangoapps.certificates.tests.factories import ( CertificateAllowlistFactory, + CertificateInvalidationFactory, GeneratedCertificateFactory, - CertificateInvalidationFactory ) from lms.djangoapps.certificates.tests.test_generation_handler import ID_VERIFIED_METHOD, PASSING_GRADE_METHOD from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration +from xmodule.data import CertificatesDisplayBehaviors +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory -CAN_GENERATE_METHOD = 'lms.djangoapps.certificates.generation_handler._can_generate_regular_certificate' -BETA_TESTER_METHOD = 'lms.djangoapps.certificates.api.access.is_beta_tester' -CERTS_VIEWABLE_METHOD = 'lms.djangoapps.certificates.api.certificates_viewable_for_course' -PASSED_OR_ALLOWLISTED_METHOD = 'lms.djangoapps.certificates.api._has_passed_or_is_allowlisted' +CAN_GENERATE_METHOD = "lms.djangoapps.certificates.generation_handler._can_generate_regular_certificate" +BETA_TESTER_METHOD = "lms.djangoapps.certificates.api.access.is_beta_tester" +CERTS_VIEWABLE_METHOD = "lms.djangoapps.certificates.api.certificates_viewable_for_course" +PASSED_OR_ALLOWLISTED_METHOD = "lms.djangoapps.certificates.api._has_passed_or_is_allowlisted" FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() -FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True +FEATURES_WITH_CERTS_ENABLED["CERTIFICATES_HTML_VIEW"] = True class WebCertificateTestMixin: """ Mixin with helpers for testing Web Certificates. """ + def _setup_course_certificate(self): """ Creates certificate configuration for course """ certificates = [ { - 'id': 1, - 'name': 'Test Certificate Name', - 'description': 'Test Certificate Description', - 'course_title': 'tes_course_title', - 'signatories': [], - 'version': 1, - 'is_active': True + "id": 1, + "name": "Test Certificate Name", + "description": "Test Certificate Description", + "course_title": "tes_course_title", + "signatories": [], + "version": 1, + "is_active": True, } ] - self.course.certificates = {'certificates': certificates} + self.course.certificates = {"certificates": certificates} self.course.cert_html_view_enabled = True self.course.save() self.store.update_item(self.course, self.user.id) @@ -111,8 +107,9 @@ def _setup_course_certificate(self): @ddt.ddt class CertificateDownloadableStatusTests(WebCertificateTestMixin, ModuleStoreTestCase): - """Tests for the `certificate_downloadable_status` helper function. """ - ENABLED_SIGNALS = ['course_published'] + """Tests for the `certificate_downloadable_status` helper function.""" + + ENABLED_SIGNALS = ["course_published"] def setUp(self): super().setUp() @@ -120,19 +117,16 @@ def setUp(self): self.student = UserFactory() self.student_no_cert = UserFactory() self.course = CourseFactory.create( - org='edx', - number='verified', - display_name='Verified Course', + org="edx", + number="verified", + display_name="Verified Course", end=datetime.now(pytz.UTC), self_paced=False, - certificate_available_date=datetime.now(pytz.UTC) - timedelta(days=2) + certificate_available_date=datetime.now(pytz.UTC) - timedelta(days=2), ) GeneratedCertificateFactory.create( - user=self.student, - course_id=self.course.id, - status=CertificateStatuses.downloadable, - mode='verified' + user=self.student, course_id=self.course.id, status=CertificateStatuses.downloadable, mode="verified" ) self.request_factory = RequestFactory() @@ -140,41 +134,38 @@ def setUp(self): def test_cert_status_with_generating(self): cert_user = UserFactory() GeneratedCertificateFactory.create( - user=cert_user, - course_id=self.course.id, - status=CertificateStatuses.generating, - mode='verified' + user=cert_user, course_id=self.course.id, status=CertificateStatuses.generating, mode="verified" ) - assert certificate_downloadable_status(cert_user, self.course.id) ==\ - {'is_downloadable': False, - 'is_generating': True, - 'is_unverified': False, - 'download_url': None, - 'uuid': None} + assert certificate_downloadable_status(cert_user, self.course.id) == { + "is_downloadable": False, + "is_generating": True, + "is_unverified": False, + "download_url": None, + "uuid": None, + } def test_cert_status_with_error(self): cert_user = UserFactory() GeneratedCertificateFactory.create( - user=cert_user, - course_id=self.course.id, - status=CertificateStatuses.error, - mode='verified' + user=cert_user, course_id=self.course.id, status=CertificateStatuses.error, mode="verified" ) - assert certificate_downloadable_status(cert_user, self.course.id) ==\ - {'is_downloadable': False, - 'is_generating': True, - 'is_unverified': False, - 'download_url': None, - 'uuid': None} + assert certificate_downloadable_status(cert_user, self.course.id) == { + "is_downloadable": False, + "is_generating": True, + "is_unverified": False, + "download_url": None, + "uuid": None, + } def test_without_cert(self): - assert certificate_downloadable_status(self.student_no_cert, self.course.id) ==\ - {'is_downloadable': False, - 'is_generating': False, - 'is_unverified': False, - 'download_url': None, - 'uuid': None} + assert certificate_downloadable_status(self.student_no_cert, self.course.id) == { + "is_downloadable": False, + "is_generating": False, + "is_unverified": False, + "download_url": None, + "uuid": None, + } def verify_downloadable_pdf_cert(self): """ @@ -186,44 +177,46 @@ def verify_downloadable_pdf_cert(self): user=cert_user, course_id=self.course.id, status=CertificateStatuses.downloadable, - mode='verified', - download_url='www.google.com', + mode="verified", + download_url="www.google.com", ) - assert certificate_downloadable_status(cert_user, self.course.id) ==\ - {'is_downloadable': True, - 'is_generating': False, - 'is_unverified': False, - 'download_url': 'www.google.com', - 'is_pdf_certificate': True, - 'uuid': cert.verify_uuid} + assert certificate_downloadable_status(cert_user, self.course.id) == { + "is_downloadable": True, + "is_generating": False, + "is_unverified": False, + "download_url": "www.google.com", + "is_pdf_certificate": True, + "uuid": cert.verify_uuid, + } - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) def test_pdf_cert_with_html_enabled(self): self.verify_downloadable_pdf_cert() def test_pdf_cert_with_html_disabled(self): self.verify_downloadable_pdf_cert() - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) def test_with_downloadable_web_cert(self): cert_status = certificate_status_for_student(self.student, self.course.id) - assert certificate_downloadable_status(self.student, self.course.id) ==\ - {'is_downloadable': True, - 'is_generating': False, - 'is_unverified': False, - 'download_url': f'/certificates/{cert_status["uuid"]}', - 'is_pdf_certificate': False, - 'uuid': cert_status['uuid']} + assert certificate_downloadable_status(self.student, self.course.id) == { + "is_downloadable": True, + "is_generating": False, + "is_unverified": False, + "download_url": f'/certificates/{cert_status["uuid"]}', + "is_pdf_certificate": False, + "uuid": cert_status["uuid"], + } @ddt.data( (False, timedelta(days=2), False, True), (False, -timedelta(days=2), True, None), - (True, timedelta(days=2), True, None) + (True, timedelta(days=2), True, None), ) @ddt.unpack - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) - @patch.dict(settings.FEATURES, {'ENABLE_V2_CERT_DISPLAY_SETTINGS': False}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) + @patch.dict(settings.FEATURES, {"ENABLE_V2_CERT_DISPLAY_SETTINGS": False}) def test_cert_api_return_v1(self, self_paced, cert_avail_delta, cert_downloadable_status, earned_but_not_available): """ Test 'downloadable status' @@ -236,8 +229,8 @@ def test_cert_api_return_v1(self, self_paced, cert_avail_delta, cert_downloadabl self._setup_course_certificate() 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 + assert downloadable_status["is_downloadable"] == cert_downloadable_status + assert downloadable_status.get("earned_but_not_available") == earned_but_not_available @ddt.data( (True, timedelta(days=2), CertificatesDisplayBehaviors.END_WITH_DATE, True, None), @@ -249,15 +242,15 @@ def test_cert_api_return_v1(self, self_paced, cert_avail_delta, cert_downloadabl (False, timedelta(days=2), CertificatesDisplayBehaviors.END_WITH_DATE, False, True), ) @ddt.unpack - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) - @patch.dict(settings.FEATURES, {'ENABLE_V2_CERT_DISPLAY_SETTINGS': True}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) + @patch.dict(settings.FEATURES, {"ENABLE_V2_CERT_DISPLAY_SETTINGS": True}) def test_cert_api_return_v2( self, self_paced, cert_avail_delta, certificates_display_behavior, cert_downloadable_status, - earned_but_not_available + earned_but_not_available, ): """ Test 'downloadable status' @@ -271,36 +264,26 @@ def test_cert_api_return_v2( self._setup_course_certificate() 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 + assert downloadable_status["is_downloadable"] == cert_downloadable_status + assert downloadable_status.get("earned_but_not_available") == earned_but_not_available @ddt.ddt class CertificateIsInvalid(WebCertificateTestMixin, ModuleStoreTestCase): - """Tests for the `is_certificate_invalid` helper function. """ + """Tests for the `is_certificate_invalid` helper function.""" def setUp(self): super().setUp() self.student = UserFactory() - self.course = CourseFactory.create( - org='edx', - number='verified', - display_name='Verified Course' - ) - self.course_overview = CourseOverviewFactory.create( - id=self.course.id - ) + self.course = CourseFactory.create(org="edx", number="verified", display_name="Verified Course") + self.course_overview = CourseOverviewFactory.create(id=self.course.id) self.global_staff = GlobalStaffFactory() self.request_factory = RequestFactory() def test_method_with_no_certificate(self): - """ Test the case when there is no certificate for a user for a specific course. """ - course = CourseFactory.create( - org='edx', - number='honor', - display_name='Course 1' - ) + """Test the case when there is no certificate for a user for a specific course.""" + course = CourseFactory.create(org="edx", number="honor", display_name="Course 1") # Also check query count for 'is_certificate_invalid' method. with self.assertNumQueries(1): assert not is_certificate_invalidated(self.student, course.id) @@ -315,8 +298,8 @@ def test_method_with_no_certificate(self): CertificateStatuses.unavailable, ) def test_method_with_invalidated_cert(self, status): - """ Verify that if certificate is marked as invalid than method will return - True. """ + """Verify that if certificate is marked as invalid than method will return + True.""" generated_cert = self._generate_cert(status) self._invalidate_certificate(generated_cert, True) assert is_certificate_invalidated(self.student, self.course.id) @@ -331,8 +314,8 @@ def test_method_with_invalidated_cert(self, status): CertificateStatuses.unavailable, ) def test_method_with_inactive_invalidated_cert(self, status): - """ Verify that if certificate is valid but it's invalidated status is - false than method will return false. """ + """Verify that if certificate is valid but it's invalidated status is + false than method will return false.""" generated_cert = self._generate_cert(status) self._invalidate_certificate(generated_cert, False) assert not is_certificate_invalidated(self.student, self.course.id) @@ -347,42 +330,36 @@ def test_method_with_inactive_invalidated_cert(self, status): CertificateStatuses.unavailable, ) def test_method_with_all_statues(self, status): - """ Verify method return True if certificate has valid status but it is - marked as invalid in CertificateInvalidation table. """ + """Verify method return True if certificate has valid status but it is + marked as invalid in CertificateInvalidation table.""" certificate = self._generate_cert(status) CertificateInvalidationFactory.create( - generated_certificate=certificate, - invalidated_by=self.global_staff, - active=True + generated_certificate=certificate, invalidated_by=self.global_staff, active=True ) # Also check query count for 'is_certificate_invalid' method. with self.assertNumQueries(2): assert is_certificate_invalidated(self.student, self.course.id) def _invalidate_certificate(self, certificate, active): - """ Dry method to mark certificate as invalid. """ + """Dry method to mark certificate as invalid.""" CertificateInvalidationFactory.create( - generated_certificate=certificate, - invalidated_by=self.global_staff, - active=active + generated_certificate=certificate, invalidated_by=self.global_staff, active=active ) # Invalidate user certificate certificate.invalidate() assert not certificate.is_valid() def _generate_cert(self, status): - """ Dry method to generate certificate. """ + """Dry method to generate certificate.""" return GeneratedCertificateFactory.create( - user=self.student, - course_id=self.course.id, - status=status, - mode='verified' + user=self.student, course_id=self.course.id, status=status, mode="verified" ) class CertificateGetTests(SharedModuleStoreTestCase): - """Tests for the `test_get_certificate_for_user` helper function. """ + """Tests for the `test_get_certificate_for_user` helper function.""" + now = timezone.now() @classmethod @@ -394,31 +371,25 @@ def setUpClass(cls): cls.student = UserFactory() cls.student_no_cert = UserFactory() cls.uuid = uuid.uuid4().hex - cls.nonexistent_course_id = CourseKey.from_string('course-v1:some+fake+course') + cls.nonexistent_course_id = CourseKey.from_string("course-v1:some+fake+course") cls.web_cert_course = CourseFactory.create( - org='edx', - number='verified_1', - display_name='Verified Course 1', - cert_html_view_enabled=True + org="edx", number="verified_1", display_name="Verified Course 1", cert_html_view_enabled=True ) cls.pdf_cert_course = CourseFactory.create( - org='edx', - number='verified_2', - display_name='Verified Course 2', - cert_html_view_enabled=False + org="edx", number="verified_2", display_name="Verified Course 2", cert_html_view_enabled=False ) cls.no_cert_course = CourseFactory.create( - org='edx', - number='verified_3', - display_name='Verified Course 3', + org="edx", + number="verified_3", + display_name="Verified Course 3", ) # certificate for the first course GeneratedCertificateFactory.create( user=cls.student, course_id=cls.web_cert_course.id, status=CertificateStatuses.downloadable, - mode='verified', - download_url='www.google.com', + mode="verified", + download_url="www.google.com", grade="0.88", verify_uuid=cls.uuid, ) @@ -427,16 +398,14 @@ def setUpClass(cls): user=cls.student, course_id=cls.pdf_cert_course.id, status=CertificateStatuses.downloadable, - mode='honor', - download_url='www.gmail.com', + mode="honor", + download_url="www.gmail.com", grade="0.99", verify_uuid=cls.uuid, ) # certificate for a course that will be deleted GeneratedCertificateFactory.create( - user=cls.student, - course_id=cls.nonexistent_course_id, - status=CertificateStatuses.downloadable + user=cls.student, course_id=cls.nonexistent_course_id, status=CertificateStatuses.downloadable ) @classmethod @@ -450,14 +419,14 @@ def test_get_certificate_for_user(self): """ cert = get_certificate_for_user(self.student.username, self.web_cert_course.id) - assert cert['username'] == self.student.username - assert cert['course_key'] == self.web_cert_course.id - assert cert['created'] == self.now - assert cert['type'] == CourseMode.VERIFIED - assert cert['status'] == CertificateStatuses.downloadable - assert cert['grade'] == '0.88' - assert cert['is_passing'] is True - assert cert['download_url'] == 'www.google.com' + assert cert["username"] == self.student.username + assert cert["course_key"] == self.web_cert_course.id + assert cert["created"] == self.now + assert cert["type"] == CourseMode.VERIFIED + assert cert["status"] == CertificateStatuses.downloadable + assert cert["grade"] == "0.88" + assert cert["is_passing"] is True + assert cert["download_url"] == "www.google.com" def test_get_certificate_for_user_id(self): """ @@ -469,7 +438,7 @@ def test_get_certificate_for_user_id(self): assert cert.course_id == self.web_cert_course.id assert cert.mode == CourseMode.VERIFIED assert cert.status == CertificateStatuses.downloadable - assert cert.grade == '0.88' + assert cert.grade == "0.88" def test_get_certificates_for_user(self): """ @@ -477,22 +446,22 @@ def test_get_certificates_for_user(self): """ certs = get_certificates_for_user(self.student.username) assert len(certs) == 2 - assert certs[0]['username'] == self.student.username - assert certs[1]['username'] == self.student.username - assert certs[0]['course_key'] == self.web_cert_course.id - assert certs[1]['course_key'] == self.pdf_cert_course.id - assert certs[0]['created'] == self.now - assert certs[1]['created'] == self.now - assert certs[0]['type'] == CourseMode.VERIFIED - assert certs[1]['type'] == CourseMode.HONOR - assert certs[0]['status'] == CertificateStatuses.downloadable - assert certs[1]['status'] == CertificateStatuses.downloadable - assert certs[0]['is_passing'] is True - assert certs[1]['is_passing'] is True - assert certs[0]['grade'] == '0.88' - assert certs[1]['grade'] == '0.99' - assert certs[0]['download_url'] == 'www.google.com' - assert certs[1]['download_url'] == 'www.gmail.com' + assert certs[0]["username"] == self.student.username + assert certs[1]["username"] == self.student.username + assert certs[0]["course_key"] == self.web_cert_course.id + assert certs[1]["course_key"] == self.pdf_cert_course.id + assert certs[0]["created"] == self.now + assert certs[1]["created"] == self.now + assert certs[0]["type"] == CourseMode.VERIFIED + assert certs[1]["type"] == CourseMode.HONOR + assert certs[0]["status"] == CertificateStatuses.downloadable + assert certs[1]["status"] == CertificateStatuses.downloadable + assert certs[0]["is_passing"] is True + assert certs[1]["is_passing"] is True + assert certs[0]["grade"] == "0.88" + assert certs[1]["grade"] == "0.99" + assert certs[0]["download_url"] == "www.google.com" + assert certs[1]["download_url"] == "www.gmail.com" def test_get_certificates_for_user_by_course_keys(self): """ @@ -505,9 +474,9 @@ def test_get_certificates_for_user_by_course_keys(self): ) assert set(certs.keys()) == {self.web_cert_course.id} cert = certs[self.web_cert_course.id] - assert cert['username'] == self.student.username - assert cert['course_key'] == self.web_cert_course.id - assert cert['download_url'] == 'www.google.com' + assert cert["username"] == self.student.username + assert cert["course_key"] == self.web_cert_course.id + assert cert["download_url"] == "www.google.com" def test_no_certificate_for_user(self): """ @@ -521,45 +490,27 @@ def test_no_certificates_for_user(self): """ assert not get_certificates_for_user(self.student_no_cert.username) - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) def test_get_web_certificate_url(self): """ Test the get_certificate_url with a web cert course """ - expected_url = reverse( - 'certificates:render_cert_by_uuid', - kwargs=dict(certificate_uuid=self.uuid) - ) - cert_url = get_certificate_url( - user_id=self.student.id, - course_id=self.web_cert_course.id, - uuid=self.uuid - ) + expected_url = reverse("certificates:render_cert_by_uuid", kwargs=dict(certificate_uuid=self.uuid)) + cert_url = get_certificate_url(user_id=self.student.id, course_id=self.web_cert_course.id, uuid=self.uuid) assert expected_url == cert_url - expected_url = reverse( - 'certificates:render_cert_by_uuid', - kwargs=dict(certificate_uuid=self.uuid) - ) + expected_url = reverse("certificates:render_cert_by_uuid", kwargs=dict(certificate_uuid=self.uuid)) - cert_url = get_certificate_url( - user_id=self.student.id, - course_id=self.web_cert_course.id, - uuid=self.uuid - ) + cert_url = get_certificate_url(user_id=self.student.id, course_id=self.web_cert_course.id, uuid=self.uuid) assert expected_url == cert_url - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) def test_get_pdf_certificate_url(self): """ Test the get_certificate_url with a pdf cert course """ - cert_url = get_certificate_url( - user_id=self.student.id, - course_id=self.pdf_cert_course.id, - uuid=self.uuid - ) - assert 'www.gmail.com' == cert_url + cert_url = get_certificate_url(user_id=self.student.id, course_id=self.pdf_cert_course.id, uuid=self.uuid) + assert "www.gmail.com" == cert_url def test_get_certificate_with_deleted_course(self): """ @@ -570,7 +521,7 @@ def test_get_certificate_with_deleted_course(self): @ddt.ddt class GenerateUserCertificatesTest(ModuleStoreTestCase): - """Tests for generating certificates for students. """ + """Tests for generating certificates for students.""" def setUp(self): super().setUp() @@ -585,15 +536,15 @@ def setUp(self): mode=CourseMode.VERIFIED, ) - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': False}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": False}) def test_cert_url_empty_with_invalid_certificate(self): """ Test certificate url is empty if html view is not enabled and certificate is not yet generated """ url = get_certificate_url(self.user.id, self.course_run_key) - assert url == '' + assert url == "" - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) def test_generation(self): """ Test that a cert is successfully generated @@ -609,7 +560,7 @@ def test_generation(self): assert cert.status == CertificateStatuses.downloadable assert cert.mode == CourseMode.VERIFIED - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) @ddt.data(True, False) def test_generation_unverified(self, enable_idv_requirement): """ @@ -630,16 +581,13 @@ def test_generation_unverified(self, enable_idv_requirement): else: assert cert.status == CertificateStatuses.downloadable - @patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) + @patch.dict(settings.FEATURES, {"CERTIFICATES_HTML_VIEW": True}) def test_generation_notpassing(self): """ Test that a cert is successfully generated with a status of notpassing """ GeneratedCertificateFactory( - user=self.user, - course_id=self.course_run_key, - status=CertificateStatuses.unavailable, - mode=CourseMode.AUDIT + user=self.user, course_id=self.course_run_key, status=CertificateStatuses.unavailable, mode=CourseMode.AUDIT ) with mock.patch(PASSING_GRADE_METHOD, return_value=False): @@ -653,12 +601,12 @@ def test_generation_notpassing(self): @ddt.ddt class CertificateGenerationEnabledTest(EventTestMixin, TestCase): - """Test enabling/disabling self-generated certificates for a course. """ + """Test enabling/disabling self-generated certificates for a course.""" - COURSE_KEY = CourseLocator(org='test', course='test', run='test') + COURSE_KEY = CourseLocator(org="test", course="test", run="test") def setUp(self): # pylint: disable=arguments-differ - super().setUp('lms.djangoapps.certificates.api.tracker') + super().setUp("lms.djangoapps.certificates.api.tracker") # Since model-based configuration is cached, we need # to clear the cache before each test. @@ -670,7 +618,7 @@ def setUp(self): # pylint: disable=arguments-differ (False, True, False), (True, None, False), (True, False, False), - (True, True, True) + (True, True, True), ) @ddt.unpack def test_cert_generation_enabled(self, is_feature_enabled, is_course_enabled, expect_enabled): @@ -679,8 +627,8 @@ def test_cert_generation_enabled(self, is_feature_enabled, is_course_enabled, ex if is_course_enabled is not None: set_cert_generation_enabled(self.COURSE_KEY, is_course_enabled) - cert_event_type = 'enabled' if is_course_enabled else 'disabled' - event_name = '.'.join(['edx', 'certificate', 'generation', cert_event_type]) + cert_event_type = "enabled" if is_course_enabled else "disabled" + event_name = ".".join(["edx", "certificate", "generation", cert_event_type]) self.assert_event_emitted( event_name, course_id=str(self.COURSE_KEY), @@ -709,27 +657,27 @@ def test_setting_is_course_specific(self): self._assert_enabled_for_course(self.COURSE_KEY, True) # Should be disabled for another course - other_course = CourseLocator(org='other', course='other', run='other') + other_course = CourseLocator(org="other", course="other", run="other") self._assert_enabled_for_course(other_course, False) def _assert_enabled_for_course(self, course_key, expect_enabled): - """Check that self-generated certificates are enabled or disabled for the course. """ + """Check that self-generated certificates are enabled or disabled for the course.""" actual_enabled = has_self_generated_certificates_enabled(course_key) assert expect_enabled == actual_enabled @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) class CertificatesBrandingTest(ModuleStoreTestCase): - """Test certificates branding. """ + """Test certificates branding.""" - COURSE_KEY = CourseLocator(org='test', course='test', run='test') + COURSE_KEY = CourseLocator(org="test", course="test", run="test") configuration = { - 'logo_image_url': 'test_site/images/header-logo.png', - 'SITE_NAME': 'test_site.localhost', - 'urls': { - 'ABOUT': 'test-site/about', - 'PRIVACY': 'test-site/privacy', - 'TOS_AND_HONOR': 'test-site/tos-and-honor', + "logo_image_url": "test_site/images/header-logo.png", + "SITE_NAME": "test_site.localhost", + "urls": { + "ABOUT": "test-site/about", + "PRIVACY": "test-site/privacy", + "TOS_AND_HONOR": "test-site/tos-and-honor", }, } @@ -744,13 +692,10 @@ def test_certificate_header_data(self): data = get_certificate_header_context(is_secure=True) # Make sure there are not unexpected keys in dict returned by 'get_certificate_header_context' - self.assertCountEqual( - list(data.keys()), - ['logo_src', 'logo_url'] - ) - assert self.configuration['logo_image_url'] in data['logo_src'] + self.assertCountEqual(list(data.keys()), ["logo_src", "logo_url"]) + assert self.configuration["logo_image_url"] in data["logo_src"] - assert self.configuration['SITE_NAME'] in data['logo_url'] + assert self.configuration["SITE_NAME"] in data["logo_url"] @with_site_configuration(configuration=configuration) def test_certificate_footer_data(self): @@ -763,19 +708,17 @@ def test_certificate_footer_data(self): data = get_certificate_footer_context() # Make sure there are not unexpected keys in dict returned by 'get_certificate_footer_context' - self.assertCountEqual( - list(data.keys()), - ['company_about_url', 'company_privacy_url', 'company_tos_url'] - ) - assert self.configuration['urls']['ABOUT'] in data['company_about_url'] - assert self.configuration['urls']['PRIVACY'] in data['company_privacy_url'] - assert self.configuration['urls']['TOS_AND_HONOR'] in data['company_tos_url'] + self.assertCountEqual(list(data.keys()), ["company_about_url", "company_privacy_url", "company_tos_url"]) + assert self.configuration["urls"]["ABOUT"] in data["company_about_url"] + assert self.configuration["urls"]["PRIVACY"] in data["company_privacy_url"] + assert self.configuration["urls"]["TOS_AND_HONOR"] in data["company_tos_url"] class CertificateAllowlistTests(ModuleStoreTestCase): """ Tests for allowlist functionality. """ + def setUp(self): super().setUp() @@ -823,10 +766,7 @@ def test_remove_allowlist_entry_with_certificate(self): """ CertificateAllowlistFactory.create(course_id=self.course_run_key, user=self.user) GeneratedCertificateFactory.create( - user=self.user, - course_id=self.course_run_key, - status=CertificateStatuses.downloadable, - mode='verified' + user=self.user, course_id=self.course_run_key, status=CertificateStatuses.downloadable, mode="verified" ) assert is_on_allowlist(self.user, self.course_run_key) @@ -862,7 +802,7 @@ def test_get_allowlist_entry_dne(self): """ expected_messages = [ f"Attempting to retrieve an allowlist entry for student {self.user.id} in course {self.course_run_key}.", - f"No allowlist entry found for student {self.user.id} in course {self.course_run_key}." + f"No allowlist entry found for student {self.user.id} in course {self.course_run_key}.", ] with LogCapture() as log: @@ -919,15 +859,10 @@ def test_can_be_added_to_allowlist_certificate_invalidated(self): invalidation list. """ certificate = GeneratedCertificateFactory.create( - user=self.user, - course_id=self.course_run_key, - status=CertificateStatuses.unavailable, - mode='verified' + user=self.user, course_id=self.course_run_key, status=CertificateStatuses.unavailable, mode="verified" ) CertificateInvalidationFactory.create( - generated_certificate=certificate, - invalidated_by=self.global_staff, - active=True + generated_certificate=certificate, invalidated_by=self.global_staff, active=True ) assert not can_be_added_to_allowlist(self.user, self.course_run_key) @@ -1005,7 +940,7 @@ def test_add_and_update(self): Test add and update of the allowlist """ u1 = UserFactory() - notes = 'blah' + notes = "blah" # Check before adding user entry = get_allowlist_entry(u1, self.course_run_key) @@ -1017,7 +952,7 @@ def test_add_and_update(self): assert entry.notes == notes # Update user - new_notes = 'really useful info' + new_notes = "really useful info" create_or_update_certificate_allowlist_entry(u1, self.course_run_key, new_notes) entry = get_allowlist_entry(u1, self.course_run_key) assert entry.notes == new_notes @@ -1027,7 +962,7 @@ def test_remove(self): Test removal from the allowlist """ u1 = UserFactory() - notes = 'I had a thought....' + notes = "I had a thought...." # Add user create_or_update_certificate_allowlist_entry(u1, self.course_run_key, notes) @@ -1044,6 +979,7 @@ class CertificateInvalidationTests(ModuleStoreTestCase): """ Tests for the certificate invalidation functionality. """ + def setUp(self): super().setUp() @@ -1065,10 +1001,7 @@ def test_create_certificate_invalidation_entry(self): invalidation entries. This is functionality the Instructor Dashboard django app relies on. """ certificate = GeneratedCertificateFactory.create( - user=self.user, - course_id=self.course_run_key, - status=CertificateStatuses.unavailable, - mode='verified' + user=self.user, course_id=self.course_run_key, status=CertificateStatuses.unavailable, mode="verified" ) result = create_certificate_invalidation_entry(certificate, self.global_staff, "Test!") @@ -1082,16 +1015,11 @@ def test_get_certificate_invalidation_entry(self): Test to verify that we can retrieve a certificate invalidation entry for a learner. """ certificate = GeneratedCertificateFactory.create( - user=self.user, - course_id=self.course_run_key, - status=CertificateStatuses.unavailable, - mode='verified' + user=self.user, course_id=self.course_run_key, status=CertificateStatuses.unavailable, mode="verified" ) invalidation = CertificateInvalidationFactory.create( - generated_certificate=certificate, - invalidated_by=self.global_staff, - active=True + generated_certificate=certificate, invalidated_by=self.global_staff, active=True ) retrieved_invalidation = get_certificate_invalidation_entry(certificate) @@ -1105,10 +1033,7 @@ def test_get_certificate_invalidation_entry_dne(self): Test to verify behavior when a certificate invalidation entry does not exist. """ certificate = GeneratedCertificateFactory.create( - user=self.user, - course_id=self.course_run_key, - status=CertificateStatuses.unavailable, - mode='verified' + user=self.user, course_id=self.course_run_key, status=CertificateStatuses.unavailable, mode="verified" ) expected_messages = [ @@ -1130,6 +1055,7 @@ class MockGeneratedCertificate: We can't import GeneratedCertificate from LMS here, so we roll our own minimal Certificate model for testing. """ + def __init__(self, user=None, course_id=None, mode=None, status=None): self.user = user self.course_id = course_id @@ -1165,24 +1091,22 @@ class CertificatesApiTestCase(TestCase): """ API tests """ + def setUp(self): super().setUp() self.course = CourseOverviewFactory.create( start=datetime(2017, 1, 1, tzinfo=pytz.UTC), end=datetime(2017, 1, 31, tzinfo=pytz.UTC), - certificate_available_date=None + certificate_available_date=None, ) self.user = UserFactory.create() self.enrollment = CourseEnrollmentFactory( user=self.user, course_id=self.course.id, is_active=True, - mode='audit', - ) - self.certificate = MockGeneratedCertificate( - user=self.user, - course_id=self.course.id + mode="audit", ) + self.certificate = MockGeneratedCertificate(user=self.user, course_id=self.course.id) @ddt.data(True, False) def test_auto_certificate_generation_enabled(self, feature_enabled): @@ -1196,9 +1120,7 @@ def test_auto_certificate_generation_enabled(self, feature_enabled): (False, False, False), # feature not enabled and instructor-paced should return False ) @ddt.unpack - def test_can_show_certificate_available_date_field( - self, feature_enabled, is_self_paced, expected_value - ): + def test_can_show_certificate_available_date_field(self, feature_enabled, is_self_paced, expected_value): self.course.self_paced = is_self_paced with configure_waffle_namespace(feature_enabled): assert expected_value == can_show_certificate_available_date_field(self.course) @@ -1210,9 +1132,7 @@ def test_can_show_certificate_available_date_field( (False, False, False), # feature not enabled and instructor-paced should return False ) @ddt.unpack - def test_available_vs_display_date( - self, feature_enabled, is_self_paced, uses_avail_date - ): + def test_available_vs_display_date(self, feature_enabled, is_self_paced, uses_avail_date): self.course.self_paced = is_self_paced with configure_waffle_namespace(feature_enabled): @@ -1245,6 +1165,7 @@ class CertificatesMessagingTestCase(ModuleStoreTestCase): """ API tests for certificate messaging """ + def setUp(self): super().setUp() self.course = CourseOverviewFactory.create() @@ -1275,6 +1196,7 @@ class CertificatesLearnerRetirementFunctionality(ModuleStoreTestCase): API tests for utility functions used as part of the learner retirement pipeline to remove PII from certificate records. """ + def setUp(self): super().setUp() self.user = UserFactory() diff --git a/lms/djangoapps/commerce/tests/__init__.py b/lms/djangoapps/commerce/tests/__init__.py index f96852a74cc5..e2acd14f32e7 100644 --- a/lms/djangoapps/commerce/tests/__init__.py +++ b/lms/djangoapps/commerce/tests/__init__.py @@ -134,39 +134,6 @@ def setUp(self): self.user = UserFactory() self.base_url = get_ecommerce_api_base_url() - @httpretty.activate - def test_tracking_context(self): - """ - Ensure the tracking context is set up in the api client correctly and automatically. - """ - with freeze_time('2015-7-2'): - # fake an E-Commerce API request. - httpretty.register_uri( - httpretty.POST, - f"{settings.ECOMMERCE_API_URL.strip('/')}/baskets/1/", - status=200, body='{}', - adding_headers={'Content-Type': JSON} - ) - - mock_tracker = mock.Mock() - mock_tracker.resolve_context = mock.Mock(return_value={'ip': '127.0.0.1'}) - with mock.patch('openedx.core.djangoapps.commerce.utils.tracker.get_tracker', return_value=mock_tracker): - api_url = urljoin(f"{self.base_url}/", "baskets/1/") - get_ecommerce_api_client(self.user).post(api_url) - - # Verify the JWT includes the tracking context for the user - actual_header = httpretty.last_request().headers['Authorization'] - - claims = { - 'tracking_context': { - 'lms_user_id': self.user.id, - 'lms_ip': '127.0.0.1', - } - } - expected_jwt = create_jwt_for_user(self.user, additional_claims=claims, scopes=self.SCOPES) - expected_header = f'JWT {expected_jwt}' - assert actual_header == expected_header - @httpretty.activate def test_client_unicode(self): """ diff --git a/lms/djangoapps/course_api/blocks/tests/test_views.py b/lms/djangoapps/course_api/blocks/tests/test_views.py index c1f673652096..f577762ad70f 100644 --- a/lms/djangoapps/course_api/blocks/tests/test_views.py +++ b/lms/djangoapps/course_api/blocks/tests/test_views.py @@ -3,7 +3,7 @@ """ from datetime import datetime from unittest import mock -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock from urllib.parse import urlencode, urlunparse import ddt @@ -209,8 +209,9 @@ def test_not_authenticated_public_course_with_all_blocks(self): self.query_params['all_blocks'] = True self.verify_response(403) + @mock.patch('lms.djangoapps.courseware.courses.get_course_assignments', return_value=[]) @mock.patch("lms.djangoapps.course_api.blocks.forms.permissions.is_course_public", Mock(return_value=True)) - def test_not_authenticated_public_course_with_blank_username(self): + def test_not_authenticated_public_course_with_blank_username(self, get_course_assignment_mock: MagicMock) -> None: """ Verify behaviour when accessing course blocks of a public course for anonymous user anonymously. """ @@ -368,7 +369,8 @@ def test_extra_field_when_not_requested(self): block_data['type'] == 'course' ) - def test_data_researcher_access(self): + @mock.patch('lms.djangoapps.courseware.courses.get_course_assignments', return_value=[]) + def test_data_researcher_access(self, get_course_assignment_mock: MagicMock) -> None: """ Test if data researcher has access to the api endpoint """ 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 be344dcb0d9b..0f8227e3201a 100644 --- a/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py +++ b/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py @@ -75,6 +75,7 @@ def send_ace_message(goal): 'email': user.email, 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), 'course_name': course_name, + 'course_id': str(goal.course_key), 'days_per_week': goal.days_per_week, 'course_url': course_home_url, 'goals_unsubscribe_url': goals_unsubscribe_url, diff --git a/lms/djangoapps/course_home_api/course_metadata/serializers.py b/lms/djangoapps/course_home_api/course_metadata/serializers.py index 7683a9089453..29b92fc7b004 100644 --- a/lms/djangoapps/course_home_api/course_metadata/serializers.py +++ b/lms/djangoapps/course_home_api/course_metadata/serializers.py @@ -43,6 +43,7 @@ class CourseHomeMetadataSerializer(VerifiedModeSerializer): """ celebrations = serializers.DictField() course_access = serializers.DictField() + studio_access = serializers.BooleanField() course_id = serializers.CharField() is_enrolled = serializers.BooleanField() is_self_paced = serializers.BooleanField() diff --git a/lms/djangoapps/course_home_api/course_metadata/views.py b/lms/djangoapps/course_home_api/course_metadata/views.py index 248f90389d40..02c30ff62e91 100644 --- a/lms/djangoapps/course_home_api/course_metadata/views.py +++ b/lms/djangoapps/course_home_api/course_metadata/views.py @@ -20,7 +20,7 @@ from lms.djangoapps.course_api.api import course_detail from lms.djangoapps.course_goals.models import UserActivity from lms.djangoapps.course_home_api.course_metadata.serializers import CourseHomeMetadataSerializer -from lms.djangoapps.courseware.access import has_access +from lms.djangoapps.courseware.access import has_access, has_cms_access from lms.djangoapps.courseware.context_processor import user_timezone_locale_prefs from lms.djangoapps.courseware.courses import check_course_access from lms.djangoapps.courseware.masquerade import setup_masquerade @@ -124,6 +124,7 @@ def get(self, request, *args, **kwargs): data = { 'course_id': course.id, 'username': username, + 'studio_access': has_cms_access(request.user, course_key), 'is_staff': has_access(request.user, 'staff', course_key).has_access, 'original_user_is_staff': original_user_is_staff, 'number': course.display_number_with_default, 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 4f40d9a80192..76928846f080 100644 --- a/lms/djangoapps/course_home_api/outline/tests/test_view.py +++ b/lms/djangoapps/course_home_api/outline/tests/test_view.py @@ -11,6 +11,7 @@ import json # lint-amnesty, pylint: disable=wrong-import-order from completion.models import BlockCompletion from django.conf import settings # lint-amnesty, pylint: disable=wrong-import-order +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 @@ -33,7 +34,10 @@ DISPLAY_COURSE_SOCK_FLAG, ENABLE_COURSE_GOALS ) -from openedx.features.discounts.applicability import DISCOUNT_APPLICABILITY_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 # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order @@ -179,17 +183,32 @@ def test_welcome_message(self, welcome_message_is_dismissed): welcome_message_html = self.client.get(self.url).data['welcome_message_html'] assert welcome_message_html == (None if welcome_message_is_dismissed else '

Welcome

') - def test_offer(self): + @ddt.data( + (False, 'EDXWELCOME', 15), + (True, 'NOTEDXWELCOME', 30), + ) + @ddt.unpack + def test_offer(self, is_fpd_override_waffle_flag_on, fpd_code, fpd_percentage): + """ + Test that the offer data contains the correct code for the first purchase discount, + which can be overriden via a waffle flag from the default EDXWELCOME. + """ CourseEnrollment.enroll(self.user, self.course.id) response = self.client.get(self.url) assert response.data['offer'] is None - with override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True): - response = self.client.get(self.url) - - # Just a quick spot check that the dictionary looks like what we expect - assert response.data['offer']['code'] == 'EDXWELCOME' + with override_settings(FIRST_PURCHASE_DISCOUNT_OVERRIDE_CODE='NOTEDXWELCOME'): + with override_settings(FIRST_PURCHASE_DISCOUNT_OVERRIDE_PERCENTAGE=fpd_percentage): + with override_waffle_flag(DISCOUNT_APPLICABILITY_FLAG, active=True): + with override_waffle_flag( + FIRST_PURCHASE_DISCOUNT_OVERRIDE_FLAG, active=is_fpd_override_waffle_flag_on + ): + response = self.client.get(self.url) + + # Just a quick spot check that the dictionary looks like what we expect + assert response.data['offer']['code'] == fpd_code + assert response.data['offer']['percentage'] == fpd_percentage def test_access_expiration(self): enrollment = CourseEnrollment.enroll(self.user, self.course.id, CourseMode.VERIFIED) diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 74f1d74f837f..436cb3514a54 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -53,7 +53,8 @@ GlobalStaff, OrgInstructorRole, OrgStaffRole, - SupportStaffRole + SupportStaffRole, + CourseLimitedStaffRole, ) from common.djangoapps.util import milestones_helpers as milestones_helpers # lint-amnesty, pylint: disable=useless-import-alias from common.djangoapps.util.milestones_helpers import ( @@ -97,6 +98,31 @@ def has_ccx_coach_role(user, course_key): return False +def has_cms_access(user, course_key): + """ + Check if user has access to the CMS. When requesting from the LMS, a user with the + limited staff access role needs access to the CMS APIs, but not the CMS site. This + function accounts for this edge case when determining if a user has access to the CMS + site. + + Arguments: + user (User): the user whose course access we are checking. + course_key: Key to course. + + Returns: + bool: whether user has access to the CMS site. + """ + has_course_author_access = auth.has_course_author_access(user, course_key) + is_limited_staff = auth.user_has_role( + user, CourseLimitedStaffRole(course_key) + ) and not GlobalStaff().has_user(user) + + if is_limited_staff and has_course_author_access: + return False + + return has_course_author_access + + @function_trace('has_access') def has_access(user, action, obj, course_key=None): """ diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 2fc727623541..0ff6a11a627b 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -12,6 +12,7 @@ from crum import get_current_request from dateutil.parser import parse as parse_date from django.conf import settings +from django.core.cache import cache from django.http import Http404, QueryDict from django.urls import reverse from django.utils.translation import gettext as _ @@ -35,6 +36,7 @@ ) from lms.djangoapps.courseware.access_utils import check_authentication, check_data_sharing_consent, check_enrollment, \ check_correct_active_enterprise_customer, is_priority_access_error +from lms.djangoapps.courseware.context_processor import get_user_timezone_or_last_seen_timezone_or_utc from lms.djangoapps.courseware.courseware_access_exception import CoursewareAccessException from lms.djangoapps.courseware.date_summary import ( CertificateAvailableDate, @@ -50,7 +52,9 @@ from lms.djangoapps.courseware.masquerade import check_content_start_date_for_masquerade_user from lms.djangoapps.courseware.model_data import FieldDataCache from lms.djangoapps.courseware.block_render import get_block +from lms.djangoapps.grades.api import CourseGradeFactory from lms.djangoapps.survey.utils import SurveyRequiredAccessError, check_survey_required_and_unanswered +from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.enrollments.api import get_course_enrollment_details from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers @@ -587,7 +591,7 @@ def get_course_blocks_completion_summary(course_key, user): @request_cached() -def get_course_assignments(course_key, user, include_access=False): # lint-amnesty, pylint: disable=too-many-statements +def get_course_assignments(course_key, user, include_access=False, include_without_due=False,): # lint-amnesty, pylint: disable=too-many-statements """ Returns a list of assignment (at the subsection/sequential level) due dates for the given course. @@ -607,7 +611,8 @@ def get_course_assignments(course_key, user, include_access=False): # lint-amne for subsection_key in block_data.get_children(section_key): due = block_data.get_xblock_field(subsection_key, 'due') graded = block_data.get_xblock_field(subsection_key, 'graded', False) - if due and graded: + + if (due or include_without_due) and graded: first_component_block_id = get_first_component_of_block(subsection_key, block_data) contains_gated_content = include_access and block_data.get_xblock_field( subsection_key, 'contains_gated_content', False) @@ -624,7 +629,11 @@ def get_course_assignments(course_key, user, include_access=False): # lint-amne else: complete = False - past_due = not complete and due < now + if due: + past_due = not complete and due < now + else: + past_due = False + due = None assignments.append(_Assignment( subsection_key, title, url, due, contains_gated_content, complete, past_due, assignment_type, None, first_component_block_id @@ -764,6 +773,39 @@ def _ora_assessment_to_assignment( ) +def get_assignments_grades(user, course_id, cache_timeout): + """ + Calculate the progress of the assignment for the user in the course. + + Arguments: + user (User): Django User object. + course_id (CourseLocator): The course key. + cache_timeout (int): Cache timeout in seconds + Returns: + list (ReadSubsectionGrade, ZeroSubsectionGrade): The list with assignments grades. + """ + is_staff = bool(has_access(user, 'staff', course_id)) + + try: + course = get_course_with_access(user, 'load', course_id) + cache_key = f'course_block_structure_{str(course_id)}_{str(course.course_version)}_{user.id}' + 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) + + course_grade = CourseGradeFactory().read(user, collected_block_structure=collected_block_structure) + + # recalculate course grade from visible grades (stored grade was calculated over all grades, visible or not) + course_grade.update(visible_grades_only=True, has_staff_access=is_staff) + subsection_grades = list(course_grade.subsection_grades.values()) + except Exception as err: # pylint: disable=broad-except + log.warning(f'Could not get grades for the course: {course_id}, error: {err}') + return [] + + return subsection_grades + + def get_first_component_of_block(block_key, block_data): """ This function returns the first leaf block of a section(block_key) @@ -1019,3 +1061,64 @@ def get_course_chapter_ids(course_key): log.exception('Failed to retrieve course from modulestore.') return [] return [str(chapter_key) for chapter_key in chapter_keys if chapter_key.block_type == 'chapter'] + + +def get_past_and_future_course_assignments(request, user, course): + """ + Returns the future assignment data and past assignments data for given user and course. + + Arguments: + request (Request): The HTTP GET request. + user (User): The user for whom the assignments are received. + course (Course): Course object for whom the assignments are received. + Returns: + tuple (list, list): Tuple of `past_assignments` list and `next_assignments` list. + `next_assignments` list contains only uncompleted assignments. + """ + assignments = get_course_assignment_date_blocks(course, user, request, include_past_dates=True) + past_assignments = [] + future_assignments = [] + + timezone = get_user_timezone_or_last_seen_timezone_or_utc(user) + for assignment in sorted(assignments, key=lambda x: x.date): + if assignment.date < datetime.now(timezone): + past_assignments.append(assignment) + else: + if not assignment.complete: + future_assignments.append(assignment) + + if future_assignments: + future_assignment_date = future_assignments[0].date.date() + next_assignments = [ + assignment for assignment in future_assignments if assignment.date.date() == future_assignment_date + ] + else: + next_assignments = [] + + return next_assignments, past_assignments + + +def get_assignments_completions(course_key, user): + """ + Calculate the progress of the user in the course by assignments. + + Arguments: + course_key (CourseLocator): The Course for which course progress is requested. + user (User): The user for whom course progress is requested. + Returns: + dict (dict): Dictionary contains information about total assignments count + in the given course and how many assignments the user has completed. + """ + course_assignments = get_course_assignments(course_key, user, include_without_due=True) + + total_assignments_count = 0 + assignments_completed = 0 + + if course_assignments: + total_assignments_count = len(course_assignments) + assignments_completed = len([assignment for assignment in course_assignments if assignment.complete]) + + return { + 'total_assignments_count': total_assignments_count, + 'assignments_completed': assignments_completed, + } diff --git a/lms/djangoapps/courseware/tests/test_about.py b/lms/djangoapps/courseware/tests/test_about.py index d53d620d3e34..bd0c1854ab76 100644 --- a/lms/djangoapps/courseware/tests/test_about.py +++ b/lms/djangoapps/courseware/tests/test_about.py @@ -156,7 +156,10 @@ def test_pre_requisite_course(self): assert resp.status_code == 200 pre_requisite_courses = get_prerequisite_courses_display(course) pre_requisite_course_about_url = reverse('about_course', args=[str(pre_requisite_courses[0]['key'])]) - assert '{}'.format(pre_requisite_course_about_url, pre_requisite_courses[0]['display']) in resp.content.decode(resp.charset).strip('\n') # pylint: disable=line-too-long + assert ( + f'You must successfully complete ' + f'{pre_requisite_courses[0]["display"]} before you begin this course.' + ) in resp.content.decode(resp.charset).strip('\n') @patch.dict(settings.FEATURES, {'ENABLE_PREREQUISITE_COURSES': True}) def test_about_page_unfulfilled_prereqs(self): @@ -190,7 +193,10 @@ def test_about_page_unfulfilled_prereqs(self): assert resp.status_code == 200 pre_requisite_courses = get_prerequisite_courses_display(course) pre_requisite_course_about_url = reverse('about_course', args=[str(pre_requisite_courses[0]['key'])]) - assert '{}'.format(pre_requisite_course_about_url, pre_requisite_courses[0]['display']) in resp.content.decode(resp.charset).strip('\n') # pylint: disable=line-too-long + assert ( + f'You must successfully complete ' + f'{pre_requisite_courses[0]["display"]} before you begin this course.' + ) in resp.content.decode(resp.charset).strip('\n') url = reverse('about_course', args=[str(pre_requisite_course.id)]) resp = self.client.get(url) diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 7df0a1648ae4..95af0cf75f06 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -33,10 +33,11 @@ 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 lms.djangoapps.static_template_view.views import render_500 from markupsafe import escape from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey -from openedx_filters.learning.filters import CourseAboutRenderStarted +from openedx_filters.learning.filters import CourseAboutRenderStarted, RenderXBlockStarted from requests.exceptions import ConnectionError, Timeout # pylint: disable=redefined-builtin from pytz import UTC from rest_framework import status @@ -1532,7 +1533,7 @@ def _check_sequence_exam_access(request, location): @xframe_options_exempt @transaction.non_atomic_requests @ensure_csrf_cookie -def render_xblock(request, usage_key_string, check_if_enrolled=True, disable_staff_debug_info=False): +def render_xblock(request, usage_key_string, check_if_enrolled=True, disable_staff_debug_info=False): # pylint: disable=too-many-statements """ Returns an HttpResponse with HTML content for the xBlock with the given usage_key. The returned HTML is a chromeless rendering of the xBlock (excluding content of the containing courseware). @@ -1641,11 +1642,7 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True, disable_sta if not _check_sequence_exam_access(request, seq_block.location): return HttpResponseForbidden("Access to exam content is restricted") - fragment = block.render(requested_view, context=student_view_context) - optimization_flags = get_optimization_flags_for_content(block, fragment) - context = { - 'fragment': fragment, 'course': course, 'block': block, 'disable_accordion': True, @@ -1666,10 +1663,33 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True, disable_sta 'is_learning_mfe': is_learning_mfe, 'is_mobile_app': is_mobile_app, 'render_course_wide_assets': True, + } + + try: + # .. filter_implemented_name: RenderXBlockStarted + # .. filter_type: org.openedx.learning.xblock.render.started.v1 + context, student_view_context = RenderXBlockStarted.run_filter( + context=context, student_view_context=student_view_context + ) + except RenderXBlockStarted.PreventXBlockBlockRender as exc: + log.info("Halted rendering block %s. Reason: %s", usage_key_string, exc.message) + return render_500(request) + except RenderXBlockStarted.RenderCustomResponse as exc: + log.info("Rendering custom exception for block %s. Reason: %s", usage_key_string, exc.message) + context.update({ + 'fragment': Fragment(exc.response) + }) + return render_to_response('courseware/courseware-chromeless.html', context, request=request) + fragment = block.render(requested_view, context=student_view_context) + optimization_flags = get_optimization_flags_for_content(block, fragment) + + context.update({ + 'fragment': fragment, **optimization_flags, - } - return render_to_response('courseware/courseware-chromeless.html', context) + }) + + return render_to_response('courseware/courseware-chromeless.html', context, request=request) def get_optimization_flags_for_content(block, fragment): diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests.py b/lms/djangoapps/discussion/django_comment_client/base/tests.py index f8242efa0c9c..62af24f0ee37 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests.py @@ -1458,6 +1458,8 @@ def _test_unicode_data(self, text, mock_request): @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(ForumsEnableMixin, UrlResetMixin, SharedModuleStoreTestCase, MockRequestSetupMixin): # Most of the test points use the same ddt data. # args: user, commentable_id, status_code diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py index f72271f3a60c..25abcf80d486 100644 --- a/lms/djangoapps/discussion/rest_api/discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py @@ -3,7 +3,10 @@ """ import re +from bs4 import BeautifulSoup from django.conf import settings +from django.utils.text import Truncator + from lms.djangoapps.discussion.django_comment_client.permissions import get_team from openedx_events.learning.data import UserNotificationData, CourseNotificationData from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED, COURSE_NOTIFICATION_REQUESTED @@ -27,13 +30,24 @@ class DiscussionNotificationSender: Class to send notifications to users who are subscribed to the thread. """ - def __init__(self, thread, course, creator, parent_id=None): + def __init__(self, thread, course, creator, parent_id=None, comment_id=None): self.thread = thread self.course = course self.creator = creator self.parent_id = parent_id + self.comment_id = comment_id self.parent_response = None + self.comment = None self._get_parent_response() + self._get_comment() + + def _get_comment(self): + """ + Get comment object + """ + if not self.comment_id: + return + self.comment = Comment(id=self.comment_id).retrieve() def _send_notification(self, user_ids, notification_type, extra_context=None): """ @@ -99,7 +113,10 @@ def send_new_response_notification(self): there is a response to the main thread. """ if not self.parent_id and self.creator.id != int(self.thread.user_id): - self._send_notification([self.thread.user_id], "new_response") + context = { + 'email_content': clean_thread_html_body(self.comment.body), + } + self._send_notification([self.thread.user_id], "new_response", extra_context=context) def _response_and_thread_has_same_creator(self) -> bool: """ @@ -118,9 +135,10 @@ def send_new_comment_notification(self): self.parent_response and self.creator.id != int(self.thread.user_id) ): + author_name = f"{self.parent_response.username}'s" # use your if author of response is same as author of post. # use 'their' if comment author is also response author. - author_name = ( + author_pronoun = ( # Translators: Replier commented on "your" response to your post _("your") if self._response_and_thread_has_same_creator() @@ -129,10 +147,13 @@ def send_new_comment_notification(self): _("their") 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), } self._send_notification([self.thread.user_id], "new_comment", extra_context=context) @@ -146,7 +167,14 @@ def send_new_comment_on_response_notification(self): self.creator.id != int(self.parent_response.user_id) and not self._response_and_thread_has_same_creator() ): - self._send_notification([self.parent_response.user_id], "new_comment_on_response") + context = { + "email_content": clean_thread_html_body(self.comment.body), + } + self._send_notification( + [self.parent_response.user_id], + "new_comment_on_response", + extra_context=context + ) def _check_if_subscriber_is_not_thread_or_content_creator(self, subscriber_id) -> bool: """ @@ -187,12 +215,29 @@ def send_response_on_followed_post_notification(self): # Remove duplicate users from the list of users to send notification users = list(set(users)) if not self.parent_id: - self._send_notification(users, "response_on_followed_post") + self._send_notification( + users, + "response_on_followed_post", + extra_context={ + "email_content": clean_thread_html_body(self.comment.body), + }) else: + author_name = f"{self.parent_response.username}'s" + # use 'their' if comment author is also response author. + author_pronoun = ( + # Translators: Replier commented on "their" response in a post you're following + _("their") + if self._response_and_comment_has_same_creator() + else f"{self.parent_response.username}'s" + ) self._send_notification( users, "comment_on_followed_post", - extra_context={"author_name": self.parent_response.username} + extra_context={ + "author_name": str(author_name), + "author_pronoun": str(author_pronoun), + "email_content": clean_thread_html_body(self.comment.body), + } ) def _create_cohort_course_audience(self): @@ -241,13 +286,19 @@ def send_response_endorsed_on_thread_notification(self): response on his thread has been endorsed """ if self.creator.id != int(self.thread.user_id): - self._send_notification([self.thread.user_id], "response_endorsed_on_thread") + context = { + "email_content": clean_thread_html_body(self.comment.body) + } + self._send_notification([self.thread.user_id], "response_endorsed_on_thread", extra_context=context) def send_response_endorsed_notification(self): """ Sends a notification to the author of the response """ - self._send_notification([self.creator.id], "response_endorsed") + context = { + "email_content": clean_thread_html_body(self.comment.body) + } + self._send_notification([self.creator.id], "response_endorsed", extra_context=context) def send_new_thread_created_notification(self): """ @@ -275,7 +326,8 @@ def send_new_thread_created_notification(self): ] context = { 'username': self.creator.username, - 'post_title': self.thread.title + 'post_title': self.thread.title, + "email_content": clean_thread_html_body(self.thread.body), } self._send_course_wide_notification(notification_type, audience_filters, context) @@ -300,7 +352,7 @@ def send_reported_content_notification(self): content_type = thread_types[self.thread.type][getattr(self.thread, 'depth', 0)] context = { - 'username': self.creator.username, + 'username': self.thread.username, 'content_type': content_type, 'content': thread_body } @@ -325,3 +377,26 @@ def is_discussion_cohorted(course_key_str): def remove_html_tags(text): clean = re.compile('<.*?>') return re.sub(clean, '', text) + + +def clean_thread_html_body(html_body): + """ + Get post body with tags removed and limited to 500 characters + """ + html_body = BeautifulSoup(Truncator(html_body).chars(500, html=True), 'html.parser') + + tags_to_remove = [ + "a", "link", # Link Tags + "img", "picture", "source", # Image Tags + "video", "track", # Video Tags + "audio", # Audio Tags + "embed", "object", "iframe", # Embedded Content + "script" + ] + + # Remove the specified tags while keeping their content + for tag in tags_to_remove: + for match in html_body.find_all(tag): + match.unwrap() + + return str(html_body) diff --git a/lms/djangoapps/discussion/rest_api/permissions.py b/lms/djangoapps/discussion/rest_api/permissions.py index f8a5f6288c51..cb6ff4ea9673 100644 --- a/lms/djangoapps/discussion/rest_api/permissions.py +++ b/lms/djangoapps/discussion/rest_api/permissions.py @@ -90,6 +90,7 @@ def get_editable_fields(cc_content: Union[Thread, Comment], context: Dict) -> Se # For closed thread: # no edits, except 'abuse_flagged' and 'read' are allowed for thread # no edits, except 'abuse_flagged' is allowed for comment + is_thread = cc_content["type"] == "thread" is_comment = cc_content["type"] == "comment" has_moderation_privilege = context["has_moderation_privilege"] @@ -120,7 +121,7 @@ def get_editable_fields(cc_content: Union[Thread, Comment], context: Dict) -> Se is_author = _is_author(cc_content, context) editable_fields.update({ - "voted": True, + "voted": has_moderation_privilege or not is_author or is_staff_or_admin, "raw_body": has_moderation_privilege or is_author, "edit_reason_code": has_moderation_privilege and not is_author, "following": is_thread, diff --git a/lms/djangoapps/discussion/rest_api/tasks.py b/lms/djangoapps/discussion/rest_api/tasks.py index 628d93d15bc1..cbf438988975 100644 --- a/lms/djangoapps/discussion/rest_api/tasks.py +++ b/lms/djangoapps/discussion/rest_api/tasks.py @@ -5,12 +5,12 @@ 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 lms.djangoapps.courseware.courses import get_course_with_access +from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender 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, ENABLE_COURSEWIDE_NOTIFICATIONS -from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender - +from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS User = get_user_model() @@ -22,7 +22,7 @@ def send_thread_created_notification(thread_id, course_key_str, user_id): Send notification when a new thread is created """ course_key = CourseKey.from_string(course_key_str) - if not (ENABLE_NOTIFICATIONS.is_enabled(course_key) and ENABLE_COURSEWIDE_NOTIFICATIONS.is_enabled(course_key)): + if not ENABLE_NOTIFICATIONS.is_enabled(course_key): return thread = Thread(id=thread_id).retrieve() user = User.objects.get(id=user_id) @@ -33,7 +33,7 @@ def send_thread_created_notification(thread_id, course_key_str, user_id): @shared_task @set_code_owner_attribute -def send_response_notifications(thread_id, course_key_str, user_id, parent_id=None): +def send_response_notifications(thread_id, course_key_str, user_id, comment_id, parent_id=None): """ Send notifications to users who are subscribed to the thread. """ @@ -43,7 +43,7 @@ def send_response_notifications(thread_id, course_key_str, user_id, parent_id=No thread = Thread(id=thread_id).retrieve() user = User.objects.get(id=user_id) course = get_course_with_access(user, 'load', course_key, check_if_enrolled=True) - notification_sender = DiscussionNotificationSender(thread, course, user, parent_id) + notification_sender = DiscussionNotificationSender(thread, course, user, parent_id, comment_id) notification_sender.send_new_comment_notification() notification_sender.send_new_response_notification() notification_sender.send_new_comment_on_response_notification() @@ -60,15 +60,16 @@ def send_response_endorsed_notifications(thread_id, response_id, course_key_str, if not ENABLE_NOTIFICATIONS.is_enabled(course_key): return thread = Thread(id=thread_id).retrieve() - creator = User.objects.get(id=endorsed_by) - course = get_course_with_access(creator, 'load', course_key, check_if_enrolled=True) response = Comment(id=response_id).retrieve() - notification_sender = DiscussionNotificationSender(thread, course, creator) + creator = User.objects.get(id=response.user_id) + endorser = User.objects.get(id=endorsed_by) + course = get_course_with_access(creator, 'load', course_key, check_if_enrolled=True) + notification_sender = DiscussionNotificationSender(thread, course, creator, comment_id=response_id) # skip sending notification to author of thread if they are the same as the author of the response if response.user_id != thread.user_id: # sends notification to author of thread notification_sender.send_response_endorsed_on_thread_notification() # sends notification to author of response - if int(response.user_id) != creator.id: + if int(response.user_id) != endorser.id: notification_sender.creator = User.objects.get(id=response.user_id) notification_sender.send_response_endorsed_notification() diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index 5e34b688d15f..9a9041fd5fa4 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -2128,19 +2128,6 @@ def test_following(self): assert cs_request.method == 'POST' assert parsed_body(cs_request) == {'source_type': ['thread'], 'source_id': ['test_id']} - def test_voted(self): - self.register_post_thread_response({"id": "test_id", "username": self.user.username}) - self.register_thread_votes_response("test_id") - data = self.minimal_data.copy() - data["voted"] = "True" - with self.assert_signal_sent(api, 'thread_voted', sender=None, user=self.user, exclude_args=('post',)): - result = create_thread(self.request, data) - assert result['voted'] is True - cs_request = httpretty.last_request() - assert urlparse(cs_request.path).path == '/api/v1/threads/test_id/votes' # lint-amnesty, pylint: disable=no-member - assert cs_request.method == 'PUT' - assert parsed_body(cs_request) == {'user_id': [str(self.user.id)], 'value': ['up']} - def test_abuse_flagged(self): self.register_post_thread_response({"id": "test_id", "username": self.user.username}) self.register_thread_flag_response("test_id") @@ -2278,7 +2265,7 @@ def test_success(self, parent_id, mock_emit): "voted": False, "vote_count": 0, "children": [], - "editable_fields": ["abuse_flagged", "anonymous", "raw_body", "voted"], + "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], "child_count": 0, "can_delete": True, "anonymous": False, @@ -2354,7 +2341,7 @@ def test_success_in_black_out_with_user_access(self, parent_id, mock_emit): "abuse_flagged", "anonymous", "raw_body", - "voted", + "voted" ] if parent_id: data["parent_id"] = parent_id @@ -2485,19 +2472,6 @@ def test_endorsed(self, role_name, is_thread_author, thread_type): except ValidationError: assert expected_error - def test_voted(self): - self.register_post_comment_response({"id": "test_comment", "username": self.user.username}, "test_thread") - self.register_comment_votes_response("test_comment") - data = self.minimal_data.copy() - data["voted"] = "True" - with self.assert_signal_sent(api, 'comment_voted', sender=None, user=self.user, exclude_args=('post',)): - result = create_comment(self.request, data) - assert result['voted'] is True - cs_request = httpretty.last_request() - assert urlparse(cs_request.path).path == '/api/v1/comments/test_comment/votes' # lint-amnesty, pylint: disable=no-member - assert cs_request.method == 'PUT' - assert parsed_body(cs_request) == {'user_id': [str(self.user.id)], 'value': ['up']} - 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") @@ -2642,6 +2616,17 @@ def register_thread(self, overrides=None): 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 + def test_empty(self): """Check that an empty update does not make any modifying requests.""" # Ensure that the default following value of False is not applied implicitly @@ -2813,12 +2798,15 @@ def test_voted(self, current_vote_status, new_vote_status, mock_emit): 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(self.user, upvoted_ids=["test_thread"]) + 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(self.request, "test_thread", data) + 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" @@ -2832,7 +2820,7 @@ def test_voted(self, current_vote_status, new_vote_status, mock_emit): 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(self.user.id)]} + expected_request_data = {"user_id": [str(user1.id)]} if new_vote_status: expected_request_data["value"] = ["up"] assert actual_request_data == expected_request_data @@ -2858,21 +2846,22 @@ def test_vote_count(self, current_vote_status, first_vote, second_vote): """ #setup starting_vote_count = 0 + user, request = self.create_user_with_request() if current_vote_status: - self.register_get_user_response(self.user, upvoted_ids=["test_thread"]) + 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(self.request, "test_thread", data) + 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(self.request, "test_thread", data) + 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])) @@ -2888,22 +2877,19 @@ def test_vote_count_two_users( Tests vote_count increases and decreases correctly from different users """ #setup - user2 = UserFactory.create() - self.register_get_user_response(user2) - request2 = RequestFactory().get("/test_path") - request2.user = user2 - CourseEnrollmentFactory.create(user=user2, course_id=self.course.id) + 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(self.user, upvoted_ids=["test_thread"]) + 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, self.request), + [(current_user1_vote, user1_vote, request1), (current_user2_vote, user2_vote, request2)]: self.register_thread_votes_response("test_thread") @@ -3202,6 +3188,17 @@ def register_comment(self, overrides=None, thread_overrides=None, course=None): 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 + def test_empty(self): """Check that an empty update does not make any modifying requests.""" self.register_comment() @@ -3235,7 +3232,7 @@ def test_basic(self, parent_id): "voted": False, "vote_count": 0, "children": [], - "editable_fields": ["abuse_flagged", "anonymous", "raw_body", "voted"], + "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], "child_count": 0, "can_delete": True, "last_edit": None, @@ -3394,13 +3391,14 @@ def test_voted(self, current_vote_status, new_vote_status, mock_emit): 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(self.user, upvoted_ids=["test_comment"]) + 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(self.request, "test_comment", data) + 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 @@ -3415,7 +3413,7 @@ def test_voted(self, current_vote_status, new_vote_status, mock_emit): 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(self.user.id)]} + expected_request_data = {"user_id": [str(user1.id)]} if new_vote_status: expected_request_data["value"] = ["up"] assert actual_request_data == expected_request_data @@ -3442,21 +3440,22 @@ def test_vote_count(self, current_vote_status, first_vote, second_vote): """ #setup starting_vote_count = 0 + user1, request1 = self.create_user_with_request() if current_vote_status: - self.register_get_user_response(self.user, upvoted_ids=["test_comment"]) + 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(self.request, "test_comment", data) + 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(self.request, "test_comment", data) + 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])) @@ -3471,22 +3470,19 @@ def test_vote_count_two_users( """ Tests vote_count increases and decreases correctly from different users """ - user2 = UserFactory.create() - self.register_get_user_response(user2) - request2 = RequestFactory().get("/test_path") - request2.user = user2 - CourseEnrollmentFactory.create(user=user2, course_id=self.course.id) + 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(self.user, upvoted_ids=["test_comment"]) + 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, self.request), + [(current_user1_vote, user1_vote, request1), (current_user2_vote, user2_vote, request2)]: self.register_comment_votes_response("test_comment") 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 adc8235d43c6..f1a71fd1239e 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py @@ -1,15 +1,14 @@ """ Unit tests for the DiscussionNotificationSender class """ - +import re import unittest from unittest.mock import MagicMock, patch import pytest -from edx_toggles.toggles.testutils import override_waffle_flag -from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender -from lms.djangoapps.discussion.toggles import ENABLE_REPORTED_CONTENT_NOTIFICATIONS +from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender, \ + clean_thread_html_body @patch('lms.djangoapps.discussion.rest_api.discussions_notifications.DiscussionNotificationSender' @@ -22,7 +21,6 @@ class TestDiscussionNotificationSender(unittest.TestCase): Tests for the DiscussionNotificationSender class """ - @override_waffle_flag(ENABLE_REPORTED_CONTENT_NOTIFICATIONS, True) def setUp(self): self.thread = MagicMock() self.course = MagicMock() @@ -47,7 +45,7 @@ def _assert_send_notification_called_with(self, mock_send_notification, expected self.assertEqual(notification_type, "content_reported") self.assertEqual(context, { - 'username': 'test_user', + 'username': self.thread.username, 'content_type': expected_content_type, 'content': 'Thread body' }) @@ -91,3 +89,82 @@ def test_send_reported_content_notification_for_thread(self, mock_send_notificat self.notification_sender.send_reported_content_notification() self._assert_send_notification_called_with(mock_send_notification, 'thread') + + +class TestCleanThreadHtmlBody(unittest.TestCase): + """ + Tests for the clean_thread_html_body function + """ + + def test_html_tags_removal(self): + """ + Test that the clean_thread_html_body function removes unwanted HTML tags + """ + html_body = """ +

This is a link to a page.

+

Here is an image: image

+

Embedded video:

+

Script test:

+

Some other content that should remain.

+ """ + expected_output = ("

This is a link to a page.

" + "

Here is an image:

" + "

Embedded video:

" + "

Script test: alert('hello');

" + "

Some other content that should remain.

") + + result = clean_thread_html_body(html_body) + + def normalize_html(text): + """ + Normalize the output by removing extra whitespace, newlines, and spaces between tags + """ + text = re.sub(r'\s+', ' ', text).strip() # Replace any sequence of whitespace with a single space + text = re.sub(r'>\s+<', '><', text) # Remove spaces between HTML tags + return text + + normalized_result = normalize_html(result) + normalized_expected_output = normalize_html(expected_output) + + self.assertEqual(normalized_result, normalized_expected_output) + + def test_truncate_html_body(self): + """ + Test that the clean_thread_html_body function truncates the HTML body to 500 characters + """ + html_body = """ +

This is a long text that should be truncated to 500 characters.

+ """ * 20 # Repeat to exceed 500 characters + + result = clean_thread_html_body(html_body) + self.assertGreaterEqual(500, len(result)) + + def test_no_tags_to_remove(self): + """ + Test that the clean_thread_html_body function does not remove any tags if there are no unwanted tags + """ + html_body = "

This paragraph has no tags to remove.

" + expected_output = "

This paragraph has no tags to remove.

" + + result = clean_thread_html_body(html_body) + self.assertEqual(result, expected_output) + + def test_empty_html_body(self): + """ + Test that the clean_thread_html_body function returns an empty string if the input is an empty string + """ + html_body = "" + expected_output = "" + + result = clean_thread_html_body(html_body) + self.assertEqual(result, expected_output) + + def test_only_script_tag(self): + """ + Test that the clean_thread_html_body function removes the script tag and its content + """ + html_body = "" + expected_output = "alert('Hello');" + + result = clean_thread_html_body(html_body) + self.assertEqual(result.strip(), expected_output) diff --git a/lms/djangoapps/discussion/rest_api/tests/test_permissions.py b/lms/djangoapps/discussion/rest_api/tests/test_permissions.py index 756229150e30..405726e2125b 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_permissions.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_permissions.py @@ -66,10 +66,10 @@ def test_thread( actual = get_initializable_thread_fields(context) expected = { "abuse_flagged", "copy_link", "course_id", "following", "raw_body", - "read", "title", "topic_id", "type", "voted" + "read", "title", "topic_id", "type" } if is_privileged: - expected |= {"closed", "pinned", "close_reason_code"} + expected |= {"closed", "pinned", "close_reason_code", "voted"} if is_privileged and is_cohorted: expected |= {"group_id"} if allow_anonymous: @@ -88,8 +88,10 @@ def test_comment(self, is_thread_author, thread_type, is_privileged): ) actual = get_initializable_comment_fields(context) expected = { - "anonymous", "abuse_flagged", "parent_id", "raw_body", "thread_id", "voted" + "anonymous", "abuse_flagged", "parent_id", "raw_body", "thread_id" } + if is_privileged: + expected |= {"voted"} if (is_thread_author and thread_type == "question") or is_privileged: expected |= {"endorsed"} assert actual == expected @@ -119,11 +121,13 @@ def test_thread( is_staff_or_admin=is_staff_or_admin, ) actual = get_editable_fields(thread, context) - expected = {"abuse_flagged", "copy_link", "following", "read", "voted"} + expected = {"abuse_flagged", "copy_link", "following", "read"} if has_moderation_privilege: expected |= {"closed", "close_reason_code"} if has_moderation_privilege or is_staff_or_admin: expected |= {"pinned"} + if has_moderation_privilege or not is_author or is_staff_or_admin: + expected |= {"voted"} if has_moderation_privilege and not is_author: expected |= {"edit_reason_code"} if is_author or has_moderation_privilege: @@ -162,7 +166,9 @@ def test_comment( has_moderation_privilege=has_moderation_privilege, ) actual = get_editable_fields(comment, context) - expected = {"abuse_flagged", "voted"} + expected = {"abuse_flagged"} + if has_moderation_privilege or not is_author: + expected |= {"voted"} if has_moderation_privilege and not is_author: expected |= {"edit_reason_code"} if is_author or has_moderation_privilege: diff --git a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py index 5d958370c16a..8103eb692791 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_serializers.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_serializers.py @@ -143,6 +143,7 @@ def test_voted(self): @ddt.ddt class ThreadSerializerSerializationTest(SerializerTestMixin, SharedModuleStoreTestCase): """Tests for ThreadSerializer serialization.""" + def make_cs_content(self, overrides): """ Create a thread with the given overrides, plus some useful test data. @@ -279,6 +280,7 @@ def test_closed_by_label_field(self, role, visible): can_delete = role != FORUM_ROLE_STUDENT editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"] if role == "author": + editable_fields.remove("voted") editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type']) elif role == FORUM_ROLE_MODERATOR: editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned', @@ -335,7 +337,9 @@ def test_edit_by_label_field(self, role, visible): editable_fields = ["abuse_flagged", "copy_link", "following", "read", "voted"] if role == "author": + editable_fields.remove("voted") editable_fields.extend(['anonymous', 'raw_body', 'title', 'topic_id', 'type']) + elif role == FORUM_ROLE_MODERATOR: editable_fields.extend(['close_reason_code', 'closed', 'edit_reason_code', 'pinned', 'raw_body', 'title', 'topic_id', 'type']) @@ -375,6 +379,7 @@ def test_get_preview_body(self): @ddt.ddt class CommentSerializerTest(SerializerTestMixin, SharedModuleStoreTestCase): """Tests for CommentSerializer.""" + def setUp(self): super().setUp() self.endorser = UserFactory.create() @@ -610,7 +615,7 @@ def test_create_minimal(self): self.register_post_thread_response({"id": "test_id", "username": self.user.username}) saved = self.save_and_reserialize(self.minimal_data) assert urlparse(httpretty.last_request().path).path ==\ - '/api/v1/test_topic/threads' # lint-amnesty, pylint: disable=no-member + '/api/v1/test_topic/threads' # lint-amnesty, pylint: disable=no-member assert parsed_body(httpretty.last_request()) == { 'course_id': [str(self.course.id)], 'commentable_id': ['test_topic'], diff --git a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py index 29e63524b2b2..ddfc120a8e4b 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py @@ -8,15 +8,16 @@ import httpretty from django.conf import settings from edx_toggles.toggles.testutils import override_waffle_flag -from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED, COURSE_NOTIFICATION_REQUESTED +from openedx_events.learning.signals import COURSE_NOTIFICATION_REQUESTED, USER_NOTIFICATION_REQUESTED from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import StaffFactory, UserFactory from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory from lms.djangoapps.discussion.rest_api.tasks import ( + send_response_endorsed_notifications, send_response_notifications, - send_thread_created_notification, - send_response_endorsed_notifications) + send_thread_created_notification +) from lms.djangoapps.discussion.rest_api.tests.utils import ThreadMock, make_minimal_cs_thread from openedx.core.djangoapps.course_groups.models import CohortMembership, CourseCohortsSettings from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory @@ -28,7 +29,7 @@ FORUM_ROLE_STUDENT, CourseDiscussionSettings ) -from openedx.core.djangoapps.notifications.config.waffle import ENABLE_COURSEWIDE_NOTIFICATIONS, ENABLE_NOTIFICATIONS +from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -47,7 +48,6 @@ def _get_mfe_url(course_id, post_id): @httpretty.activate @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) -@override_waffle_flag(ENABLE_COURSEWIDE_NOTIFICATIONS, active=True) class TestNewThreadCreatedNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCase): """ Test cases related to new_discussion_post and new_question_post notification types @@ -273,6 +273,17 @@ def setUp(self): }) self._register_subscriptions_endpoint() + self.comment = ThreadMock(thread_id=4, creator=self.user_2, title='test comment', body='comment body') + self.register_get_comment_response( + { + 'id': self.comment.id, + 'thread_id': self.thread.id, + 'parent_id': None, + 'user_id': self.comment.user_id, + 'body': self.comment.body, + } + ) + def test_basic(self): """ Left empty intentionally. This test case is inherited from DiscussionAPIViewTestMixin @@ -292,7 +303,13 @@ def test_send_notification_to_thread_creator(self): # Post the form or do what it takes to send the signal - send_response_notifications(self.thread.id, str(self.course.id), self.user_2.id, parent_id=None) + send_response_notifications( + self.thread.id, + str(self.course.id), + self.user_2.id, + self.comment.id, + parent_id=None + ) self.assertEqual(handler.call_count, 2) args = handler.call_args_list[0][1]['notification_data'] self.assertEqual([int(user_id) for user_id in args.user_ids], [self.user_1.id]) @@ -300,6 +317,7 @@ def test_send_notification_to_thread_creator(self): expected_context = { 'replier_name': self.user_2.username, 'post_title': 'test thread', + 'email_content': self.comment.body, 'course_name': self.course.display_name, 'sender_id': self.user_2.id } @@ -325,7 +343,13 @@ def test_send_notification_to_parent_threads(self): 'user_id': self.thread_2.user_id }) - send_response_notifications(self.thread.id, str(self.course.id), self.user_3.id, parent_id=self.thread_2.id) + send_response_notifications( + self.thread.id, + str(self.course.id), + self.user_3.id, + self.comment.id, + parent_id=self.thread_2.id + ) # check if 2 call are made to the handler i.e. one for the response creator and one for the thread creator self.assertEqual(handler.call_count, 2) @@ -337,7 +361,9 @@ def test_send_notification_to_parent_threads(self): expected_context = { 'replier_name': self.user_3.username, 'post_title': self.thread.title, + 'email_content': self.comment.body, 'author_name': 'dummy\'s', + 'author_pronoun': 'dummy\'s', 'course_name': self.course.display_name, 'sender_id': self.user_3.id } @@ -354,6 +380,7 @@ def test_send_notification_to_parent_threads(self): expected_context = { 'replier_name': self.user_3.username, 'post_title': self.thread.title, + 'email_content': self.comment.body, 'course_name': self.course.display_name, 'sender_id': self.user_3.id } @@ -371,7 +398,13 @@ def test_no_signal_on_creators_own_thread(self): """ handler = mock.Mock() USER_NOTIFICATION_REQUESTED.connect(handler) - send_response_notifications(self.thread.id, str(self.course.id), self.user_1.id, parent_id=None) + + send_response_notifications( + self.thread.id, + str(self.course.id), + self.user_1.id, + self.comment.id, parent_id=None + ) self.assertEqual(handler.call_count, 1) def test_comment_creators_own_response(self): @@ -388,7 +421,13 @@ def test_comment_creators_own_response(self): 'user_id': self.thread_3.user_id }) - send_response_notifications(self.thread.id, str(self.course.id), self.user_3.id, parent_id=self.thread_2.id) + send_response_notifications( + self.thread.id, + str(self.course.id), + self.user_3.id, + parent_id=self.thread_2.id, + comment_id=self.comment.id + ) # check if 1 call is made to the handler i.e. for the thread creator self.assertEqual(handler.call_count, 2) @@ -399,9 +438,11 @@ def test_comment_creators_own_response(self): expected_context = { 'replier_name': self.user_3.username, 'post_title': self.thread.title, - 'author_name': 'your', + 'author_name': 'dummy\'s', + 'author_pronoun': 'your', 'course_name': self.course.display_name, 'sender_id': self.user_3.id, + 'email_content': self.comment.body } self.assertDictEqual(args_comment.context, expected_context) self.assertEqual( @@ -427,7 +468,13 @@ def test_send_notification_to_followers(self, parent_id, notification_type): USER_NOTIFICATION_REQUESTED.connect(handler) # Post the form or do what it takes to send the signal - notification_sender = DiscussionNotificationSender(self.thread, self.course, self.user_2, parent_id=parent_id) + notification_sender = DiscussionNotificationSender( + self.thread, + self.course, + self.user_2, + parent_id=parent_id, + comment_id=self.comment.id + ) notification_sender.send_response_on_followed_post_notification() self.assertEqual(handler.call_count, 1) args = handler.call_args[1]['notification_data'] @@ -437,11 +484,13 @@ def test_send_notification_to_followers(self, parent_id, notification_type): expected_context = { 'replier_name': self.user_2.username, 'post_title': 'test thread', + 'email_content': self.comment.body, 'course_name': self.course.display_name, 'sender_id': self.user_2.id, } if parent_id: - expected_context['author_name'] = 'dummy' + expected_context['author_name'] = 'dummy\'s' + expected_context['author_pronoun'] = 'dummy\'s' self.assertDictEqual(args.context, expected_context) self.assertEqual( args.content_url, @@ -513,6 +562,7 @@ def test_new_comment_notification(self): thread = ThreadMock(thread_id=1, creator=self.user_1, title='test thread') response = ThreadMock(thread_id=2, creator=self.user_2, title='test response') + comment = ThreadMock(thread_id=3, creator=self.user_2, title='test comment', body='comment body') self.register_get_thread_response({ 'id': thread.id, 'course_id': str(self.course.id), @@ -527,11 +577,20 @@ def test_new_comment_notification(self): 'thread_id': thread.id, 'user_id': response.user_id }) + self.register_get_comment_response({ + 'id': comment.id, + 'parent_id': response.id, + 'user_id': comment.user_id, + 'body': comment.body + }) self.register_get_subscriptions(1, {}) - send_response_notifications(thread.id, str(self.course.id), self.user_2.id, parent_id=response.id) + send_response_notifications(thread.id, str(self.course.id), self.user_2.id, parent_id=response.id, + comment_id=comment.id) handler.assert_called_once() context = handler.call_args[1]['notification_data'].context - self.assertEqual(context['author_name'], 'their') + self.assertEqual(context['author_name'], 'dummy\'s') + self.assertEqual(context['author_pronoun'], 'their') + self.assertEqual(context['email_content'], comment.body) @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) @@ -600,10 +659,11 @@ def test_response_endorsed_notifications(self): self.assertEqual(notification_data.notification_type, 'response_endorsed_on_thread') expected_context = { - 'replier_name': self.user_3.username, + 'replier_name': self.user_2.username, 'post_title': 'test thread', 'course_name': self.course.display_name, - 'sender_id': int(self.user_3.id), + 'sender_id': int(self.user_2.id), + 'email_content': 'dummy' } self.assertDictEqual(notification_data.context, expected_context) self.assertEqual(notification_data.content_url, _get_mfe_url(self.course.id, thread.id)) @@ -621,6 +681,7 @@ def test_response_endorsed_notifications(self): 'post_title': 'test thread', 'course_name': self.course.display_name, 'sender_id': int(response.user_id), + 'email_content': 'dummy' } 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 d7bf6c0e139e..283117000712 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -1456,7 +1456,7 @@ def test_basic(self): 'preview_body': 'Edited body', 'editable_fields': [ 'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', 'read', - 'title', 'topic_id', 'type', 'voted' + 'title', 'topic_id', 'type' ], 'created_at': 'Test Created Date', 'updated_at': 'Test Updated Date', @@ -1540,7 +1540,7 @@ def test_patch_read_owner_user(self): 'read': True, 'editable_fields': [ 'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', 'read', - 'title', 'topic_id', 'type', 'voted' + 'title', 'topic_id', 'type' ], 'response_count': 2 }) @@ -1647,7 +1647,7 @@ def setUp(self): "key": "editable_fields", "value": [ 'abuse_flagged', 'anonymous', 'copy_link', 'following', 'raw_body', - 'read', 'title', 'topic_id', 'type', 'voted' + 'read', 'title', 'topic_id', 'type' ] }, {"key": "endorsed_comment_list_url", "value": None}, @@ -2444,7 +2444,7 @@ def test_basic(self): "voted": False, "vote_count": 0, "children": [], - "editable_fields": ["abuse_flagged", "anonymous", "raw_body", "voted"], + "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], "child_count": 0, "can_delete": True, "anonymous": False, @@ -2572,7 +2572,7 @@ def test_basic(self): assert response_data == self.expected_response_data({ 'raw_body': 'Edited body', 'rendered_body': '

Edited body

', - 'editable_fields': ['abuse_flagged', 'anonymous', 'raw_body', 'voted'], + 'editable_fields': ['abuse_flagged', 'anonymous', 'raw_body'], 'created_at': 'Test Created Date', 'updated_at': 'Test Updated Date' }) @@ -2743,7 +2743,7 @@ def test_basic(self): "vote_count": 0, "abuse_flagged": False, "abuse_flagged_any_user": None, - "editable_fields": ["abuse_flagged", "anonymous", "raw_body", "voted"], + "editable_fields": ["abuse_flagged", "anonymous", "raw_body"], "child_count": 0, "can_delete": True, "anonymous": False, diff --git a/lms/djangoapps/discussion/rest_api/tests/utils.py b/lms/djangoapps/discussion/rest_api/tests/utils.py index 39dddcf33f3f..27e34705f5df 100644 --- a/lms/djangoapps/discussion/rest_api/tests/utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/utils.py @@ -88,6 +88,7 @@ def callback(request, _uri, headers): class CommentsServiceMockMixin: """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""" assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' @@ -489,7 +490,6 @@ def expected_thread_data(self, overrides=None): "title", "topic_id", "type", - "voted", ], "course_id": str(self.course.id), "topic_id": "test_topic", @@ -675,12 +675,13 @@ class ThreadMock(object): A mock thread object """ - def __init__(self, thread_id, creator, title, parent_id=None): + def __init__(self, thread_id, creator, title, parent_id=None, body=''): 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 def url_with_id(self, params): return f"http://example.com/{params['id']}" diff --git a/lms/djangoapps/discussion/signals/handlers.py b/lms/djangoapps/discussion/signals/handlers.py index 8c4991cd0289..2aa7d36456c4 100644 --- a/lms/djangoapps/discussion/signals/handlers.py +++ b/lms/djangoapps/discussion/signals/handlers.py @@ -10,19 +10,17 @@ from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import LibraryLocator -from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender -from lms.djangoapps.discussion.toggles import ENABLE_REPORTED_CONTENT_NOTIFICATIONS -from xmodule.modulestore.django import SignalHandler, modulestore - from lms.djangoapps.discussion import tasks +from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender from lms.djangoapps.discussion.rest_api.tasks import ( + send_response_endorsed_notifications, send_response_notifications, - send_thread_created_notification, - send_response_endorsed_notifications + send_thread_created_notification ) from openedx.core.djangoapps.django_comment_common import signals from openedx.core.djangoapps.site_configuration.models import SiteConfiguration from openedx.core.djangoapps.theming.helpers import get_current_site +from xmodule.modulestore.django import SignalHandler, modulestore log = logging.getLogger(__name__) @@ -101,8 +99,6 @@ def send_reported_content_notification(sender, user, post, **kwargs): Sends notification for reported content. """ course_key = CourseKey.from_string(post.course_id) - if not ENABLE_REPORTED_CONTENT_NOTIFICATIONS.is_enabled(course_key): - return course = modulestore().get_course(course_key) DiscussionNotificationSender(post, course, user).send_reported_content_notification() @@ -180,8 +176,9 @@ def create_comment_created_notification(*args, **kwargs): comment = kwargs['post'] thread_id = comment.attributes['thread_id'] parent_id = comment.attributes['parent_id'] + comment_id = comment.attributes['id'] course_key_str = comment.attributes['course_id'] - send_response_notifications.apply_async(args=[thread_id, course_key_str, user.id, parent_id]) + send_response_notifications.apply_async(args=[thread_id, course_key_str, user.id, comment_id, parent_id]) @receiver(signals.comment_endorsed) diff --git a/lms/djangoapps/discussion/toggles.py b/lms/djangoapps/discussion/toggles.py index 29918c45a983..a1c292a4734f 100644 --- a/lms/djangoapps/discussion/toggles.py +++ b/lms/djangoapps/discussion/toggles.py @@ -12,15 +12,3 @@ # .. 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__) - -# .. toggle_name: discussions.enable_reported_content_notifications -# .. toggle_implementation: CourseWaffleFlag -# .. toggle_default: False -# .. toggle_description: Waffle flag to enable reported content notifications. -# .. toggle_use_cases: temporary, open_edx -# .. toggle_creation_date: 18-Jan-2024 -# .. toggle_target_removal_date: 18-Feb-2024 -ENABLE_REPORTED_CONTENT_NOTIFICATIONS = CourseWaffleFlag( - f'{WAFFLE_FLAG_NAMESPACE}.enable_reported_content_notifications', - __name__ -) diff --git a/lms/djangoapps/grades/exceptions.py b/lms/djangoapps/grades/exceptions.py index d615f1f64d5c..db2793efaa15 100644 --- a/lms/djangoapps/grades/exceptions.py +++ b/lms/djangoapps/grades/exceptions.py @@ -3,9 +3,9 @@ """ -class DatabaseNotReadyError(IOError): +class ScoreNotFoundError(IOError): """ - Subclass of IOError to indicate the database has not yet committed - the data we're trying to find. + Subclass of IOError to indicate the staff has not yet graded the problem or + the database has not yet committed the data we're trying to find. """ pass # lint-amnesty, pylint: disable=unnecessary-pass diff --git a/lms/djangoapps/grades/grade_utils.py b/lms/djangoapps/grades/grade_utils.py index 0344cf6c20d1..05d2058f37ba 100644 --- a/lms/djangoapps/grades/grade_utils.py +++ b/lms/djangoapps/grades/grade_utils.py @@ -7,6 +7,7 @@ from datetime import timedelta from django.utils import timezone +from django.conf import settings from openedx.core.djangoapps.content.course_overviews.models import CourseOverview @@ -22,7 +23,7 @@ def are_grades_frozen(course_key): if ENFORCE_FREEZE_GRADE_AFTER_COURSE_END.is_enabled(course_key): course = CourseOverview.get_from_id(course_key) if course.end: - freeze_grade_date = course.end + timedelta(30) + freeze_grade_date = course.end + timedelta(settings.GRADEBOOK_FREEZE_DAYS) now = timezone.now() return now > freeze_grade_date return False diff --git a/lms/djangoapps/grades/tasks.py b/lms/djangoapps/grades/tasks.py index 3b504e61ebe8..9ec237274b4f 100644 --- a/lms/djangoapps/grades/tasks.py +++ b/lms/djangoapps/grades/tasks.py @@ -33,7 +33,7 @@ from .config.waffle import DISABLE_REGRADE_ON_POLICY_CHANGE from .constants import ScoreDatabaseTableEnum from .course_grade_factory import CourseGradeFactory -from .exceptions import DatabaseNotReadyError +from .exceptions import ScoreNotFoundError from .grade_utils import are_grades_frozen from .signals.signals import SUBSECTION_SCORE_CHANGED from .subsection_grade_factory import SubsectionGradeFactory @@ -45,7 +45,7 @@ KNOWN_RETRY_ERRORS = ( # Errors we expect occasionally, should be resolved on retry DatabaseError, ValidationError, - DatabaseNotReadyError, + ScoreNotFoundError, UsageKeyNotInBlockStructure, ) RECALCULATE_GRADE_DELAY_SECONDS = 2 # to prevent excessive _has_db_updated failures. See TNL-6424. @@ -239,7 +239,7 @@ def _recalculate_subsection_grade(self, **kwargs): has_database_updated = _has_db_updated_with_new_score(self, scored_block_usage_key, **kwargs) if not has_database_updated: - raise DatabaseNotReadyError + raise ScoreNotFoundError _update_subsection_grades( course_key, diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py index 300543def6c2..896d0deadcd9 100644 --- a/lms/djangoapps/instructor/enrollment.py +++ b/lms/djangoapps/instructor/enrollment.py @@ -34,6 +34,7 @@ get_event_transaction_id, set_event_transaction_type ) +from lms.djangoapps.branding.api import get_logo_url_for_email from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.grades.api import constants as grades_constants from lms.djangoapps.grades.api import disconnect_submissions_signal_receiver @@ -489,6 +490,7 @@ def get_email_params(course, auto_enroll, secure=True, course_key=None, display_ 'contact_mailing_address': contact_mailing_address, 'platform_name': platform_name, 'site_configuration_values': configuration_helpers.get_current_site_configuration_values(), + 'logo_url': get_logo_url_for_email(), } return email_params diff --git a/lms/djangoapps/instructor/permissions.py b/lms/djangoapps/instructor/permissions.py index e1a1cbf466f6..24e0079fcce3 100644 --- a/lms/djangoapps/instructor/permissions.py +++ b/lms/djangoapps/instructor/permissions.py @@ -1,11 +1,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.permissions import BasePermission from lms.djangoapps.courseware.rules import HasAccessRule, HasRolesRule +from openedx.core.lib.courses import get_course_by_id ALLOW_STUDENT_TO_BYPASS_ENTRANCE_EXAM = 'instructor.allow_student_to_bypass_entrance_exam' ASSIGN_TO_COHORTS = 'instructor.assign_to_cohorts' @@ -72,3 +74,11 @@ ) | HasAccessRule('staff') | HasAccessRule('instructor') perms[VIEW_ENROLLMENTS] = HasAccessRule('staff') perms[VIEW_FORUM_MEMBERS] = HasAccessRule('staff') + + +class InstructorPermission(BasePermission): + """Generic permissions""" + def has_permission(self, request, view): + 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) diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index f5d8b0408950..6e0a2545f530 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -47,6 +47,7 @@ UNENROLLED_TO_ALLOWEDTOENROLL, UNENROLLED_TO_ENROLLED, UNENROLLED_TO_UNENROLLED, + CourseAccessRole, CourseEnrollment, CourseEnrollmentAllowed, ManualEnrollmentAudit, @@ -60,12 +61,14 @@ CourseFinanceAdminRole, CourseInstructorRole, ) -from common.djangoapps.student.tests.factories import BetaTesterFactory -from common.djangoapps.student.tests.factories import CourseEnrollmentFactory -from common.djangoapps.student.tests.factories import GlobalStaffFactory -from common.djangoapps.student.tests.factories import InstructorFactory -from common.djangoapps.student.tests.factories import StaffFactory -from common.djangoapps.student.tests.factories import UserFactory +from common.djangoapps.student.tests.factories import ( + BetaTesterFactory, + 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 ( @@ -94,6 +97,9 @@ 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.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.user_api.preferences.api import delete_user_preference @@ -642,6 +648,25 @@ def setUp(self): last_name='Student' ) + def test_api_without_login(self): + """ + verify in case of no authentication it returns 401. + """ + self.client.logout() + uploaded_file = SimpleUploadedFile("temp.jpg", io.BytesIO(b"some initial binary data: \x00\x01").read()) + response = self.client.post(self.url, {'students_list': uploaded_file}) + assert response.status_code == 401 + + def test_api_without_permission(self): + """ + verify in case of no authentication it returns 403. + """ + # removed role from course for instructor + CourseInstructorRole(self.course.id).remove_users(self.instructor) + uploaded_file = SimpleUploadedFile("temp.jpg", io.BytesIO(b"some initial binary data: \x00\x01").read()) + response = self.client.post(self.url, {'students_list': uploaded_file}) + assert response.status_code == 403 + @patch('lms.djangoapps.instructor.views.api.log.info') @ddt.data( b"test_student@example.com,test_student_1,tester1,USA", # Typical use case. @@ -2945,7 +2970,37 @@ def test_get_student_progress_url(self): response = self.client.post(url, data) assert response.status_code == 200 res_json = json.loads(response.content.decode('utf-8')) - assert 'progress_url' in res_json + expected_data = { + 'course_id': str(self.course.id), + 'progress_url': f'/courses/{self.course.id}/progress/{self.students[0].id}/' + } + + for key, value in expected_data.items(): + self.assertIn(key, res_json) + self.assertEqual(res_json[key], value) + + def test_get_student_progress_url_response_headers(self): + """ + Test that the progress_url endpoint returns the correct headers. + """ + url = reverse('get_student_progress_url', kwargs={'course_id': str(self.course.id)}) + data = {'unique_student_identifier': self.students[0].email} + response = self.client.post(url, data) + assert response.status_code == 200 + + expected_headers = { + 'Allow': 'POST, OPTIONS', # drf view brings this key. + 'Cache-Control': 'no-cache, no-store, must-revalidate', + 'Content-Language': 'en', + 'Content-Length': str(len(response.content.decode('utf-8'))), + 'Content-Type': 'application/json', + 'Vary': 'Cookie, Accept-Language, origin', + 'X-Frame-Options': 'DENY' + } + + for key, value in expected_headers.items(): + self.assertIn(key, response.headers) + self.assertEqual(response.headers[key], value) def test_get_student_progress_url_from_uname(self): """ Test that progress_url is in the successful response. """ @@ -2955,6 +3010,14 @@ def test_get_student_progress_url_from_uname(self): assert response.status_code == 200 res_json = json.loads(response.content.decode('utf-8')) assert 'progress_url' in res_json + expected_data = { + 'course_id': str(self.course.id), + 'progress_url': f'/courses/{self.course.id}/progress/{self.students[0].id}/' + } + + for key, value in expected_data.items(): + self.assertIn(key, res_json) + self.assertEqual(res_json[key], value) def test_get_student_progress_url_noparams(self): """ Test that the endpoint 404's without the required query params. """ @@ -2968,6 +3031,17 @@ def test_get_student_progress_url_nostudent(self): response = self.client.post(url) assert response.status_code == 400 + def test_get_student_progress_url_without_permissions(self): + """ Test that progress_url 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_student_progress_url', kwargs={'course_id': str(self.course.id)}) + data = {'unique_student_identifier': self.students[0].email} + response = self.client.post(url, data) + assert response.status_code == 403 + class TestInstructorAPIRegradeTask(SharedModuleStoreTestCase, LoginEnrollmentTestCase): """ @@ -3447,6 +3521,14 @@ def test_send_email_but_not_logged_in(self): self.client.logout() url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, self.full_test_message) + assert response.status_code == 401 + + def test_send_email_logged_in_but_no_perms(self): + self.client.logout() + user = UserFactory() + self.client.login(username=user.username, password=self.TEST_PASSWORD) + url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) + response = self.client.post(url, self.full_test_message) assert response.status_code == 403 def test_send_email_but_not_staff(self): @@ -3567,6 +3649,7 @@ def test_send_email_with_lapsed_date_expect_error(self): url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) with LogCapture() as log: + response = self.client.post(url, self.full_test_message) assert response.status_code == 400 @@ -4077,6 +4160,21 @@ def test_change_due_date(self): # This operation regenerates the cache, so we can use cached results from edx-when. assert get_date_for_block(self.course, self.week1, self.user1, use_cached=True) == due_date + def test_change_due_date_with_reason(self): + url = reverse('change_due_date', kwargs={'course_id': str(self.course.id)}) + due_date = datetime.datetime(2013, 12, 30, tzinfo=UTC) + response = self.client.post(url, { + 'student': self.user1.username, + 'url': str(self.week1.location), + 'due_datetime': '12/30/2013 00:00', + 'reason': 'Testing reason.' # this is optional field. + }) + assert response.status_code == 200, response.content + + assert get_extended_due(self.course, self.week1, self.user1) == due_date + # This operation regenerates the cache, so we can use cached results from edx-when. + assert get_date_for_block(self.course, self.week1, self.user1, use_cached=True) == due_date + def test_change_to_invalid_due_date(self): url = reverse('change_due_date', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, { @@ -4598,3 +4696,116 @@ def test_get_certificate_for_user_no_certificate(self): f"The student {self.user} does not have certificate for the course {self.course.id.course}. Kindly " "verify student username/email and the selected course are correct and try again." ) + + +@patch.dict(settings.FEATURES, {'ALLOW_AUTOMATED_SIGNUPS': True}) +class TestOauthInstructorAPILevelsAccess(SharedModuleStoreTestCase, LoginEnrollmentTestCase): + """ + Test endpoints using Oauth2 authentication. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course = CourseFactory.create( + entrance_exam_id='i4x://{}/{}/chapter/Entrance_exam'.format('test_org', 'test_course') + ) + + def setUp(self): + super().setUp() + + self.other_user = UserFactory() + 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) + + self.headers = { + 'HTTP_AUTHORIZATION': 'JWT ' + jwt_token + } + + # endpoints contains all urls with body and role. + self.endpoints = [ + ('list_course_role_members', {'rolename': 'staff'}, 'instructor'), + ('register_and_enroll_students', {}, 'staff'), + ('get_student_progress_url', {'course_id': str(self.course.id), + 'unique_student_identifier': self.other_user.email + }, 'staff' + ), + ('list_entrance_exam_instructor_tasks', {'unique_student_identifier': self.other_user.email}, 'staff'), + ('list_email_content', {}, 'staff'), + ('show_student_extensions', {'student': self.other_user.email}, 'staff'), + ('list_email_content', {}, 'staff'), + ('list_report_downloads', { + "send-to": ["myself"], + "subject": "This is subject", + "message": "message" + }, 'data_researcher') + ] + + self.fake_jwt = ('wyJUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJjaGFuZ2UtbWUiLCJleHAiOjE3MjU4OTA2NzIsImdyY' + 'W50X3R5cGUiOiJwYXNzd29yZCIsImlhdCI6MTcyNTg4NzA3MiwiaXNzIjoiaHR0cDovLzEyNy4wLjAuMTo4MDAwL29h' + 'XNlcl9pZCI6MX0' + '.ec8neWp1YAuF40ye4oeK40obaapUvjfNPUQCycrsajwvcu58KcuLc96sf0JKmMMMn7DH9N98hg8W38iwbhKif1kLsCKr' + 'tStl1u2XGvFkyMov8TtespbHit5LYRZpJwrhC1h50ru2buYj3isWrAElGPIDyAj0FAvSJnvJhWSMDtIwB2gxZI1DqOm' + 'M6mzT7JbOU4QH2PNZrb2EZ11F6k9I-HrHnLQymr4s0vyjMlcBWllW3y19futNCgsFFRMXI4Z9zIbspsy5bq_Skub' + 'dBpnl0P9x8vUJCAbFnJABAVPtF7F7nNsROQMKsZtQxaUUwdcYZi5qKL2GcgGfO0eTL4IbJA') + + def assert_all_end_points(self, endpoint, body, role, add_role, use_jwt=True): + """ + Util method for verifying different end-points. + """ + if add_role: + role, _ = CourseAccessRole.objects.get_or_create( + course_id=self.course.id, + user=self.other_user, + role=role, + org=self.course.id.org + ) + + if use_jwt: + headers = self.headers + else: + headers = { + 'HTTP_AUTHORIZATION': 'JWT ' + self.fake_jwt # this is fake jwt. + } + + url = reverse(endpoint, kwargs={'course_id': str(self.course.id)}) + response = self.client.post( + url, + data=body, + **headers + ) + return response + + def run_endpoint_tests(self, expected_status, add_role, use_jwt): + """ + Util method for running different end-points. + """ + for endpoint, body, role in self.endpoints: + with self.subTest(endpoint=endpoint, role=role, body=body): + response = self.assert_all_end_points(endpoint, body, role, add_role, use_jwt) + # JWT authentication works but it has no permissions. + assert response.status_code == expected_status, f"Failed for endpoint: {endpoint}" + + def test_end_points_with_oauth_without_jwt(self): + """ + Verify the endpoint using invalid JWT returns 401. + """ + self.run_endpoint_tests(expected_status=401, add_role=False, use_jwt=False) + + def test_end_points_with_oauth_without_permissions(self): + """ + Verify the endpoint using JWT authentication. But has no permissions. + """ + self.run_endpoint_tests(expected_status=403, add_role=False, use_jwt=True) + + def test_end_points_with_oauth_with_permissions(self): + """ + Verify the endpoint using JWT authentication with permissions. + """ + self.run_endpoint_tests(expected_status=200, add_role=True, use_jwt=True) diff --git a/lms/djangoapps/instructor/tests/test_enrollment.py b/lms/djangoapps/instructor/tests/test_enrollment.py index 59ccfac6caa1..741f57ef6d2b 100644 --- a/lms/djangoapps/instructor/tests/test_enrollment.py +++ b/lms/djangoapps/instructor/tests/test_enrollment.py @@ -23,6 +23,7 @@ from common.djangoapps.student.models import CourseEnrollment, CourseEnrollmentAllowed, anonymous_id_for_user from common.djangoapps.student.roles import CourseCcxCoachRole from common.djangoapps.student.tests.factories import AdminFactory, UserFactory +from lms.djangoapps.branding.api import get_logo_url_for_email from lms.djangoapps.ccx.tests.factories import CcxFactory from lms.djangoapps.course_blocks.api import get_course_blocks from lms.djangoapps.courseware.models import StudentModule @@ -940,6 +941,7 @@ def setUpClass(cls): ) cls.course_about_url = cls.course_url + 'about' cls.registration_url = f'https://{site}/register' + cls.logo_url = get_logo_url_for_email() def test_normal_params(self): # For a normal site, what do we expect to get for the URLs? @@ -950,6 +952,7 @@ def test_normal_params(self): assert result['course_about_url'] == self.course_about_url assert result['registration_url'] == self.registration_url assert result['course_url'] == self.course_url + assert result['logo_url'] == self.logo_url def test_marketing_params(self): # For a site with a marketing front end, what do we expect to get for the URLs? @@ -962,6 +965,19 @@ def test_marketing_params(self): assert result['course_about_url'] is None assert result['registration_url'] == self.registration_url assert result['course_url'] == self.course_url + assert result['logo_url'] == self.logo_url + + @patch('lms.djangoapps.instructor.enrollment.get_logo_url_for_email', return_value='https://www.logo.png') + def test_logo_url_params(self, mock_get_logo_url_for_email): + # Verify that the logo_url is correctly set in the email params + result = get_email_params(self.course, False) + + assert result['auto_enroll'] is False + assert result['course_about_url'] == self.course_about_url + assert result['registration_url'] == self.registration_url + assert result['course_url'] == self.course_url + mock_get_logo_url_for_email.assert_called_once() + assert result['logo_url'] == 'https://www.logo.png' @ddt.ddt diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 6d7dfe17a9d7..d42e7173b0bf 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -37,6 +37,7 @@ from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey from openedx.core.djangoapps.course_groups.cohorts import get_cohort_by_name +from rest_framework.exceptions import MethodNotAllowed from rest_framework import serializers, status # lint-amnesty, pylint: disable=wrong-import-order from rest_framework.permissions import IsAdminUser, IsAuthenticated # lint-amnesty, pylint: disable=wrong-import-order from rest_framework.response import Response # lint-amnesty, pylint: disable=wrong-import-order @@ -105,6 +106,10 @@ from lms.djangoapps.instructor_task.api_helper import AlreadyRunningError, QueueConnectionError from lms.djangoapps.instructor_task.data import InstructorTaskTypes from lms.djangoapps.instructor_task.models import ReportStore +from lms.djangoapps.instructor.views.serializer import ( + AccessSerializer, BlockDueDateSerializer, RoleNameSerializer, ShowStudentExtensionSerializer, UserSerializer, + SendEmailSerializer, StudentAttemptsSerializer +) 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 @@ -122,6 +127,7 @@ from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes 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 ( dump_block_extensions, @@ -281,299 +287,305 @@ def wrapped(request, course_id): return wrapped -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_ENROLL) -def register_and_enroll_students(request, course_id): # pylint: disable=too-many-statements +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class RegisterAndEnrollStudents(APIView): """ Create new account and Enroll students in this course. - Passing a csv file that contains a list of students. - Order in csv should be the following email = 0; username = 1; name = 2; country = 3. - If there are more than 4 columns in the csv: cohort = 4, course mode = 5. - Requires staff access. - - -If the email address and username already exists and the user is enrolled in the course, - do nothing (including no email gets sent out) - - -If the email address already exists, but the username is different, - match on the email address only and continue to enroll the user in the course using the email address - as the matching criteria. Note the change of username as a warning message (but not a failure). - Send a standard enrollment email which is the same as the existing manual enrollment - - -If the username already exists (but not the email), assume it is a different user and fail - to create the new account. - The failure will be messaged in a response in the browser. """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_ENROLL - if not configuration_helpers.get_value( - 'ALLOW_AUTOMATED_SIGNUPS', - settings.FEATURES.get('ALLOW_AUTOMATED_SIGNUPS', False), - ): - return HttpResponseForbidden() + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): # pylint: disable=too-many-statements + """ + Create new account and Enroll students in this course. + Passing a csv file that contains a list of students. + Order in csv should be the following email = 0; username = 1; name = 2; country = 3. + If there are more than 4 columns in the csv: cohort = 4, course mode = 5. + Requires staff access. + + -If the email address and username already exists and the user is enrolled in the course, + do nothing (including no email gets sent out) + + -If the email address already exists, but the username is different, + match on the email address only and continue to enroll the user in the course using the email address + as the matching criteria. Note the change of username as a warning message (but not a failure). + Send a standard enrollment email which is the same as the existing manual enrollment + + -If the username already exists (but not the email), assume it is a different user and fail + to create the new account. + The failure will be messaged in a response in the browser. + """ + if not configuration_helpers.get_value( + 'ALLOW_AUTOMATED_SIGNUPS', + settings.FEATURES.get('ALLOW_AUTOMATED_SIGNUPS', False), + ): + return HttpResponseForbidden() - course_id = CourseKey.from_string(course_id) - warnings = [] - row_errors = [] - general_errors = [] + course_id = CourseKey.from_string(course_id) + warnings = [] + row_errors = [] + general_errors = [] - # email-students is a checkbox input type; will be present in POST if checked, absent otherwise - notify_by_email = 'email-students' in request.POST + # email-students is a checkbox input type; will be present in POST if checked, absent otherwise + notify_by_email = 'email-students' in request.POST - # for white labels we use 'shopping cart' which uses CourseMode.HONOR as - # course mode for creating course enrollments. - if CourseMode.is_white_label(course_id): - default_course_mode = CourseMode.HONOR - else: - default_course_mode = None + # for white labels we use 'shopping cart' which uses CourseMode.HONOR as + # course mode for creating course enrollments. + if CourseMode.is_white_label(course_id): + default_course_mode = CourseMode.HONOR + else: + default_course_mode = None - # Allow bulk enrollments in all non-expired course modes including "credit" (which is non-selectable) - valid_course_modes = set(map(lambda x: x.slug, CourseMode.modes_for_course( - course_id=course_id, - only_selectable=False, - include_expired=False, - ))) + # Allow bulk enrollments in all non-expired course modes including "credit" (which is non-selectable) + valid_course_modes = set(map(lambda x: x.slug, CourseMode.modes_for_course( + course_id=course_id, + only_selectable=False, + include_expired=False, + ))) - if 'students_list' in request.FILES: # lint-amnesty, pylint: disable=too-many-nested-blocks - students = [] + if 'students_list' in request.FILES: # lint-amnesty, pylint: disable=too-many-nested-blocks + students = [] - 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())) - course = get_course_by_id(course_id) - else: + 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())) + course = get_course_by_id(course_id) + else: + general_errors.append({ + 'username': '', 'email': '', + 'response': _( + '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({ - 'username': '', 'email': '', - 'response': _( - 'Make sure that the file you upload is in CSV format with no extraneous characters or rows.') + 'username': '', 'email': '', 'response': _('Could not read uploaded file.') }) + finally: + upload_file.close() + + generated_passwords = [] + # To skip fetching cohorts from the DB while iterating on students, + # {: CourseUserGroup} + cohorts_cache = {} + already_warned_not_cohorted = False + extra_fields_is_enabled = configuration_helpers.get_value( + 'ENABLE_AUTOMATED_SIGNUPS_EXTRA_FIELDS', + settings.FEATURES.get('ENABLE_AUTOMATED_SIGNUPS_EXTRA_FIELDS', False), + ) - except Exception: # pylint: disable=broad-except - general_errors.append({ - 'username': '', 'email': '', 'response': _('Could not read uploaded file.') - }) - finally: - upload_file.close() - - generated_passwords = [] - # To skip fetching cohorts from the DB while iterating on students, - # {: CourseUserGroup} - cohorts_cache = {} - already_warned_not_cohorted = False - extra_fields_is_enabled = configuration_helpers.get_value( - 'ENABLE_AUTOMATED_SIGNUPS_EXTRA_FIELDS', - settings.FEATURES.get('ENABLE_AUTOMATED_SIGNUPS_EXTRA_FIELDS', False), - ) - - # Iterate each student in the uploaded csv file. - for row_num, student in enumerate(students, 1): + # Iterate each student in the uploaded csv file. + for row_num, student in enumerate(students, 1): - # Verify that we have the expected number of columns in every row - # but allow for blank lines. - if not student: - continue + # Verify that we have the expected number of columns in every row + # but allow for blank lines. + if not student: + continue - if extra_fields_is_enabled: - is_valid_csv = 4 <= len(student) <= 6 - error = _('Data in row #{row_num} must have between four and six columns: ' - 'email, username, full name, country, cohort, and course mode. ' - 'The last two columns are optional.').format(row_num=row_num) - else: - is_valid_csv = len(student) == 4 - error = _('Data in row #{row_num} must have exactly four columns: ' - 'email, username, full name, and country.').format(row_num=row_num) + if extra_fields_is_enabled: + is_valid_csv = 4 <= len(student) <= 6 + error = _('Data in row #{row_num} must have between four and six columns: ' + 'email, username, full name, country, cohort, and course mode. ' + 'The last two columns are optional.').format(row_num=row_num) + else: + is_valid_csv = len(student) == 4 + error = _('Data in row #{row_num} must have exactly four columns: ' + 'email, username, full name, and country.').format(row_num=row_num) + + if not is_valid_csv: + general_errors.append({ + 'username': '', + 'email': '', + 'response': error + }) + continue - if not is_valid_csv: - general_errors.append({ - 'username': '', - 'email': '', - 'response': error - }) - continue + # Extract each column, handle optional columns if they exist. + email, username, name, country, *optional_cols = student + if optional_cols: + optional_cols.append(default_course_mode) + cohort_name, course_mode, *_tail = optional_cols + else: + cohort_name = None + course_mode = None - # Extract each column, handle optional columns if they exist. - email, username, name, country, *optional_cols = student - if optional_cols: - optional_cols.append(default_course_mode) - cohort_name, course_mode, *_tail = optional_cols - else: - cohort_name = None - course_mode = None + # Validate cohort name, and get the cohort object. Skip if course + # is not cohorted. + cohort = None - # Validate cohort name, and get the cohort object. Skip if course - # is not cohorted. - cohort = None + if cohort_name and not already_warned_not_cohorted: + if not is_course_cohorted(course_id): + row_errors.append({ + 'username': username, + 'email': email, + 'response': _('Course is not cohorted but cohort provided. ' + 'Ignoring cohort assignment for all users.') + }) + already_warned_not_cohorted = True + elif cohort_name in cohorts_cache: + cohort = cohorts_cache[cohort_name] + else: + # Don't attempt to create cohort or assign student if cohort + # does not exist. + try: + cohort = get_cohort_by_name(course_id, cohort_name) + except CourseUserGroup.DoesNotExist: + row_errors.append({ + 'username': username, + 'email': email, + 'response': _('Cohort name not found: {cohort}. ' + 'Ignoring cohort assignment for ' + 'all users.').format(cohort=cohort_name) + }) + cohorts_cache[cohort_name] = cohort + + # Validate course mode. + if not course_mode: + course_mode = default_course_mode + + if (course_mode is not None + and course_mode not in valid_course_modes): + # If `default is None` and the user is already enrolled, + # `CourseEnrollment.change_mode()` will not update the mode, + # hence two error messages. + if default_course_mode is None: + err_msg = _( + 'Invalid course mode: {mode}. Falling back to the ' + 'default mode, or keeping the current mode in case the ' + 'user is already enrolled.' + ).format(mode=course_mode) + else: + err_msg = _( + 'Invalid course mode: {mode}. Failling back to ' + '{default_mode}, or resetting to {default_mode} in case ' + 'the user is already enrolled.' + ).format(mode=course_mode, default_mode=default_course_mode) + row_errors.append({ + 'username': username, + 'email': email, + 'response': err_msg, + }) + course_mode = default_course_mode - if cohort_name and not already_warned_not_cohorted: - if not is_course_cohorted(course_id): + email_params = get_email_params(course, True, secure=request.is_secure()) + try: + validate_email(email) # Raises ValidationError if invalid + except ValidationError: row_errors.append({ 'username': username, 'email': email, - 'response': _('Course is not cohorted but cohort provided. ' - 'Ignoring cohort assignment for all users.') + 'response': _('Invalid email {email_address}.').format(email_address=email) }) - already_warned_not_cohorted = True - elif cohort_name in cohorts_cache: - cohort = cohorts_cache[cohort_name] else: - # Don't attempt to create cohort or assign student if cohort - # does not exist. - try: - cohort = get_cohort_by_name(course_id, cohort_name) - except CourseUserGroup.DoesNotExist: + if User.objects.filter(email=email).exists(): + # Email address already exists. assume it is the correct user + # and just register the user in the course and send an enrollment email. + user = User.objects.get(email=email) + + # see if it is an exact match with email and username + # if it's not an exact match then just display a warning message, but continue onwards + if not User.objects.filter(email=email, username=username).exists(): + warning_message = _( + 'An account with email {email} exists but the provided username {username} ' + 'is different. Enrolling anyway with {email}.' + ).format(email=email, username=username) + + warnings.append({ + 'username': username, 'email': email, 'response': warning_message + }) + log.warning('email %s already exist', email) + else: + log.info( + "user already exists with username '%s' and email '%s'", + username, + email + ) + + # enroll a user if it is not already enrolled. + if not is_user_enrolled_in_course(user, course_id): + # Enroll user to the course and add manual enrollment audit trail + create_manual_course_enrollment( + user=user, + course_id=course_id, + mode=course_mode, + enrolled_by=request.user, + reason='Enrolling via csv upload', + state_transition=UNENROLLED_TO_ENROLLED, + ) + enroll_email(course_id=course_id, + student_email=email, + auto_enroll=True, + email_students=notify_by_email, + email_params=email_params) + else: + # update the course mode if already enrolled + existing_enrollment = CourseEnrollment.get_enrollment(user, course_id) + if existing_enrollment.mode != course_mode: + existing_enrollment.change_mode(mode=course_mode) + if cohort: + try: + add_user_to_cohort(cohort, user) + except ValueError: + # user already in this cohort; ignore + pass + elif is_email_retired(email): + # We are either attempting to enroll a retired user or create a new user with an email which is + # already associated with a retired account. Simply block these attempts. row_errors.append({ 'username': username, 'email': email, - 'response': _('Cohort name not found: {cohort}. ' - 'Ignoring cohort assignment for ' - 'all users.').format(cohort=cohort_name) + 'response': _('Invalid email {email_address}.').format(email_address=email), }) - cohorts_cache[cohort_name] = cohort - - # Validate course mode. - if not course_mode: - course_mode = default_course_mode - - if (course_mode is not None - and course_mode not in valid_course_modes): - # If `default is None` and the user is already enrolled, - # `CourseEnrollment.change_mode()` will not update the mode, - # hence two error messages. - if default_course_mode is None: - err_msg = _( - 'Invalid course mode: {mode}. Falling back to the ' - 'default mode, or keeping the current mode in case the ' - 'user is already enrolled.' - ).format(mode=course_mode) - else: - err_msg = _( - 'Invalid course mode: {mode}. Failling back to ' - '{default_mode}, or resetting to {default_mode} in case ' - 'the user is already enrolled.' - ).format(mode=course_mode, default_mode=default_course_mode) - row_errors.append({ - 'username': username, - 'email': email, - 'response': err_msg, - }) - course_mode = default_course_mode - - email_params = get_email_params(course, True, secure=request.is_secure()) - try: - validate_email(email) # Raises ValidationError if invalid - except ValidationError: - row_errors.append({ - 'username': username, - 'email': email, - 'response': _('Invalid email {email_address}.').format(email_address=email) - }) - else: - if User.objects.filter(email=email).exists(): - # Email address already exists. assume it is the correct user - # and just register the user in the course and send an enrollment email. - user = User.objects.get(email=email) - - # see if it is an exact match with email and username - # if it's not an exact match then just display a warning message, but continue onwards - if not User.objects.filter(email=email, username=username).exists(): - warning_message = _( - 'An account with email {email} exists but the provided username {username} ' - 'is different. Enrolling anyway with {email}.' - ).format(email=email, username=username) - - warnings.append({ - 'username': username, 'email': email, 'response': warning_message - }) - log.warning('email %s already exist', email) + log.warning('Email address %s is associated with a retired user, so course enrollment was ' + # lint-amnesty, pylint: disable=logging-not-lazy + 'blocked.', email) else: - log.info( - "user already exists with username '%s' and email '%s'", + # This email does not yet exist, so we need to create a new account + # If username already exists in the database, then create_and_enroll_user + # will raise an IntegrityError exception. + password = generate_unique_password(generated_passwords) + errors = create_and_enroll_user( + email, username, - email + name, + country, + password, + course_id, + course_mode, + request.user, + email_params, + email_user=notify_by_email, ) + row_errors.extend(errors) + if cohort: + try: + add_user_to_cohort(cohort, email) + except ValueError: + # user already in this cohort; ignore + # NOTE: Checking this here may be unnecessary if we can prove that a + # new user will never be + # automatically assigned to a cohort from the above. + pass + except ValidationError: + row_errors.append({ + 'username': username, + 'email': email, + 'response': _('Invalid email {email_address}.').format(email_address=email), + }) - # enroll a user if it is not already enrolled. - if not is_user_enrolled_in_course(user, course_id): - # Enroll user to the course and add manual enrollment audit trail - create_manual_course_enrollment( - user=user, - course_id=course_id, - mode=course_mode, - enrolled_by=request.user, - reason='Enrolling via csv upload', - state_transition=UNENROLLED_TO_ENROLLED, - ) - enroll_email(course_id=course_id, - student_email=email, - auto_enroll=True, - email_students=notify_by_email, - email_params=email_params) - else: - # update the course mode if already enrolled - existing_enrollment = CourseEnrollment.get_enrollment(user, course_id) - if existing_enrollment.mode != course_mode: - existing_enrollment.change_mode(mode=course_mode) - if cohort: - try: - add_user_to_cohort(cohort, user) - except ValueError: - # user already in this cohort; ignore - pass - elif is_email_retired(email): - # We are either attempting to enroll a retired user or create a new user with an email which is - # already associated with a retired account. Simply block these attempts. - row_errors.append({ - 'username': username, - 'email': email, - 'response': _('Invalid email {email_address}.').format(email_address=email), - }) - log.warning('Email address %s is associated with a retired user, so course enrollment was ' + # lint-amnesty, pylint: disable=logging-not-lazy - 'blocked.', email) - else: - # This email does not yet exist, so we need to create a new account - # If username already exists in the database, then create_and_enroll_user - # will raise an IntegrityError exception. - password = generate_unique_password(generated_passwords) - errors = create_and_enroll_user( - email, - username, - name, - country, - password, - course_id, - course_mode, - request.user, - email_params, - email_user=notify_by_email, - ) - row_errors.extend(errors) - if cohort: - try: - add_user_to_cohort(cohort, email) - except ValueError: - # user already in this cohort; ignore - # NOTE: Checking this here may be unnecessary if we can prove that a new user will never be - # automatically assigned to a cohort from the above. - pass - except ValidationError: - row_errors.append({ - 'username': username, - 'email': email, - 'response': _('Invalid email {email_address}.').format(email_address=email), - }) - - else: - general_errors.append({ - 'username': '', 'email': '', 'response': _('File is not attached.') - }) + else: + general_errors.append({ + 'username': '', 'email': '', 'response': _('File is not attached.') + }) - results = { - 'row_errors': row_errors, - 'general_errors': general_errors, - 'warnings': warnings - } - return JsonResponse(results) + results = { + 'row_errors': row_errors, + 'general_errors': general_errors, + 'warnings': warnings + } + return JsonResponse(results) def generate_random_string(length): @@ -979,17 +991,8 @@ def bulk_beta_modify_access(request, course_id): 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_COURSE_ACCESS) -@require_post_params( - unique_student_identifier="email or username of user to change access", - rolename="'instructor', 'staff', 'beta', or 'ccx_coach'", - action="'allow' or 'revoke'" -) -@common_exceptions_400 -def modify_access(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ModifyAccess(APIView): """ Modify staff/instructor access of other user. Requires instructor access. @@ -1001,77 +1004,83 @@ def modify_access(request, course_id): rolename is one of ['instructor', 'staff', 'beta', 'ccx_coach'] action is one of ['allow', 'revoke'] """ - course_id = CourseKey.from_string(course_id) - course = get_course_with_access( - request.user, 'instructor', course_id, depth=None - ) - unique_student_identifier = request.POST.get('unique_student_identifier') - try: - user = get_student_from_identifier(unique_student_identifier) - except User.DoesNotExist: - response_payload = { - 'unique_student_identifier': unique_student_identifier, - 'userDoesNotExist': True, - } - return JsonResponse(response_payload) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.EDIT_COURSE_ACCESS + serializer_class = AccessSerializer - # Check that user is active, because add_users - # in common/djangoapps/student/roles.py fails - # silently when we try to add an inactive user. - if not user.is_active: - response_payload = { - 'unique_student_identifier': user.username, - 'inactiveUser': True, - } - return JsonResponse(response_payload) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Modify staff/instructor access of other user. + Requires instructor access. + """ + course_id = CourseKey.from_string(course_id) + course = get_course_with_access( + request.user, 'instructor', course_id, depth=None + ) - rolename = request.POST.get('rolename') - action = request.POST.get('action') + serializer_data = AccessSerializer(data=request.data) + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) - if rolename not in ROLES: - error = strip_tags(f"unknown rolename '{rolename}'") - log.error(error) - return HttpResponseBadRequest(error) + user = serializer_data.validated_data.get('unique_student_identifier') + if not user: + response_payload = { + 'unique_student_identifier': request.data.get('unique_student_identifier'), + 'userDoesNotExist': True, + } + return JsonResponse(response_payload) + + if not user.is_active: + response_payload = { + 'unique_student_identifier': user.username, + 'inactiveUser': True, + } + return JsonResponse(response_payload) + + rolename = serializer_data.data['rolename'] + action = serializer_data.data['action'] + + if rolename not in ROLES: + error = strip_tags(f"unknown rolename '{rolename}'") + log.error(error) + return HttpResponseBadRequest(error) + + # disallow instructors from removing their own instructor access. + if rolename == 'instructor' and user == request.user and action != 'allow': + response_payload = { + 'unique_student_identifier': user.username, + 'rolename': rolename, + 'action': action, + 'removingSelfAsInstructor': True, + } + return JsonResponse(response_payload) + + if action == 'allow': + allow_access(course, user, rolename) + if not is_user_enrolled_in_course(user, course_id): + CourseEnrollment.enroll(user, course_id) + elif action == 'revoke': + revoke_access(course, user, rolename) + else: + return HttpResponseBadRequest(strip_tags( + f"unrecognized action u'{action}'" + )) - # disallow instructors from removing their own instructor access. - if rolename == 'instructor' and user == request.user and action != 'allow': response_payload = { 'unique_student_identifier': user.username, 'rolename': rolename, 'action': action, - 'removingSelfAsInstructor': True, + 'success': 'yes', } return JsonResponse(response_payload) - if action == 'allow': - allow_access(course, user, rolename) - if not is_user_enrolled_in_course(user, course_id): - CourseEnrollment.enroll(user, course_id) - elif action == 'revoke': - revoke_access(course, user, rolename) - else: - return HttpResponseBadRequest(strip_tags( - f"unrecognized action u'{action}'" - )) - - response_payload = { - 'unique_student_identifier': user.username, - 'rolename': rolename, - 'action': action, - 'success': 'yes', - } - 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_COURSE_ACCESS) -@require_post_params(rolename="'instructor', 'staff', or 'beta'") -def list_course_role_members(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ListCourseRoleMembersView(APIView): """ - List instructors and staff. - Requires instructor access. + View to list instructors and staff for a specific course. + Requires the user to have instructor access. rolename is one of ['instructor', 'staff', 'beta', 'ccx_coach'] @@ -1087,33 +1096,41 @@ def list_course_role_members(request, course_id): ] } """ - course_id = CourseKey.from_string(course_id) - course = get_course_with_access( - request.user, 'instructor', course_id, depth=None - ) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.EDIT_COURSE_ACCESS - rolename = request.POST.get('rolename') + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Handles POST request to list instructors and staff. - if rolename not in ROLES: - return HttpResponseBadRequest() + Args: + request (HttpRequest): The request object containing user data. + course_id (str): The ID of the course to list instructors and staff for. - def extract_user_info(user): - """ convert user into dicts for json view """ + Returns: + Response: A Response object containing the list of instructors and staff or an error message. - return { - 'username': user.username, - 'email': user.email, - 'first_name': user.first_name, - 'last_name': user.last_name, + Raises: + Http404: If the course does not exist. + """ + course_id = CourseKey.from_string(course_id) + course = get_course_with_access( + request.user, 'instructor', course_id, depth=None + ) + role_serializer = RoleNameSerializer(data=request.data) + role_serializer.is_valid(raise_exception=True) + rolename = role_serializer.data['rolename'] + + users = list_with_level(course.id, rolename) + serializer = UserSerializer(users, many=True) + + response_payload = { + 'course_id': str(course_id), + rolename: serializer.data, } - response_payload = { - 'course_id': str(course_id), - rolename: list(map(extract_user_info, list_with_level( - course.id, rolename - ))), - } - return JsonResponse(response_payload) + return Response(response_payload, status=status.HTTP_200_OK) class ProblemResponseReportPostParamsSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -1495,28 +1512,38 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red 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.CAN_RESEARCH) -@common_exceptions_400 -def get_students_who_may_enroll(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 GetStudentsWhoMayEnroll(DeveloperErrorViewMixin, APIView): """ Initiate generation of a CSV file containing information about - students who may enroll in a course. + """ + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_RESEARCH - Responds with JSON - {"status": "... status message ..."} + @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 may enroll in a course. - """ - course_key = CourseKey.from_string(course_id) - query_features = ['email'] - report_type = _('enrollment') - task_api.submit_calculate_may_enroll_csv(request, course_key, query_features) - success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + Responds with JSON + {"status": "... status message ..."} + """ + course_key = CourseKey.from_string(course_id) + query_features = ['email'] + report_type = _('enrollment') + try: + task_api.submit_calculate_may_enroll_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}) + return JsonResponse({"status": success_status}) + + def get(self, request, *args, **kwargs): + raise MethodNotAllowed('GET') def _cohorts_csv_validator(file_storage, file_to_validate): @@ -1603,7 +1630,8 @@ def post(self, request, course_key_string): 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 + validator=_cohorts_csv_validator, + is_private=True ) task_api.submit_cohort_students(request, course_key, file_name) except (FileValidationException, ValueError) as e: @@ -1647,18 +1675,31 @@ def get_proctored_exam_results(request, course_id): return JsonResponse({"status": success_status}) -@transaction.non_atomic_requests -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_RESEARCH) -def get_anon_ids(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 GetAnonIds(APIView): """ - Respond with 2-column CSV output of user-id, anonymized-user-id + Respond with 2-column CSV output of user-id, anonymized-user-id. + This API processes the incoming request to generate a CSV file containing + two columns: `user-id` and `anonymized-user-id`. The CSV is returned as a + response to the client. """ - report_type = _('Anonymized User IDs') - success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) - task_api.generate_anonymous_ids(request, course_id) - return JsonResponse({"status": success_status}) + 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): + """ + Handle POST request to generate a CSV output. + + Returns: + Response: A CSV file with two columns: `user-id` and `anonymized-user-id`. + """ + report_type = _('Anonymized User IDs') + success_status = SUCCESS_MESSAGE_TEMPLATE.format(report_type=report_type) + task_api.generate_anonymous_ids(request, course_id) + return JsonResponse({"status": success_status}) @require_POST @@ -1717,15 +1758,35 @@ def get_student_enrollment_status(request, course_id): return JsonResponse(response_payload) -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.ENROLLMENT_REPORT) -@require_post_params( - unique_student_identifier="email or username of student for whom to get progress url" -) -@common_exceptions_400 -def get_student_progress_url(request, course_id): +class StudentProgressUrlSerializer(serializers.Serializer): + """Serializer for course renders""" + unique_student_identifier = serializers.CharField(write_only=True) + course_id = CourseKeyField(required=False) + progress_url = serializers.SerializerMethodField() + + def get_progress_url(self, obj): # pylint: disable=unused-argument + """ + Return the progress URL for the student. + Args: + obj (dict): The dictionary containing data for the serializer. + Returns: + str: The URL for the progress of the student in the course. + """ + user = get_student_from_identifier(obj.get('unique_student_identifier')) + course_id = obj.get('course_id') # Adjust based on your data structure + + if course_home_mfe_progress_tab_is_active(course_id): + progress_url = get_learning_mfe_home_url(course_id, url_fragment='progress') + if user is not None: + progress_url += '/{}/'.format(user.id) + else: + progress_url = reverse('student_progress', kwargs={'course_id': str(course_id), 'student_id': user.id}) + + return progress_url + + +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class StudentProgressUrl(APIView): """ Get the progress url of a student. Limited to staff access. @@ -1735,40 +1796,45 @@ def get_student_progress_url(request, course_id): 'progress_url': '/../...' } """ - course_id = CourseKey.from_string(course_id) - user = get_student_from_identifier(request.POST.get('unique_student_identifier')) - - if course_home_mfe_progress_tab_is_active(course_id): - progress_url = get_learning_mfe_home_url(course_id, url_fragment='progress') - if user is not None: - progress_url += '/{}/'.format(user.id) - else: - progress_url = reverse('student_progress', kwargs={'course_id': str(course_id), 'student_id': user.id}) + authentication_classes = ( + JwtAuthentication, + BearerAuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + serializer_class = StudentProgressUrlSerializer + permission_name = permissions.ENROLLMENT_REPORT - response_payload = { - 'course_id': str(course_id), - 'progress_url': progress_url, - } - return JsonResponse(response_payload) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """Post method for validating incoming data and generating progress URL""" + data = { + 'course_id': course_id, + 'unique_student_identifier': request.data.get('unique_student_identifier') + } + serializer = self.serializer_class(data=data) + serializer.is_valid(raise_exception=True) + return Response(serializer.data) -@transaction.non_atomic_requests -@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( - problem_to_reset="problem urlname to reset" -) -@common_exceptions_400 -def reset_student_attempts(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 ResetStudentAttempts(DeveloperErrorViewMixin, APIView): """ - Resets a students attempts counter or starts a task to reset all students attempts counters. Optionally deletes student state for a problem. Limited to staff access. Some sub-methods limited to instructor access. + """ + http_method_names = ['post'] + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.GIVE_STUDENT_EXTENSION + serializer_class = StudentAttemptsSerializer - Takes some of the following query parameters + @method_decorator(ensure_csrf_cookie) + @transaction.non_atomic_requests + def post(self, request, course_id): + """ + Takes some 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 @@ -1778,65 +1844,74 @@ def reset_student_attempts(request, course_id): - delete_module is a boolean requires instructor access mutually exclusive with all_students - """ - course_id = CourseKey.from_string(course_id) - course = get_course_with_access( - request.user, 'staff', course_id, depth=None - ) - all_students = _get_boolean_param(request, 'all_students') - - if all_students and not has_access(request.user, 'instructor', course): - return HttpResponseForbidden("Requires instructor access.") + """ + course_id = CourseKey.from_string(course_id) + serializer_data = self.serializer_class(data=request.data) - 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) - delete_module = _get_boolean_param(request, 'delete_module') + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) - # parameter combinations - if all_students and student: - return HttpResponseBadRequest( - "all_students and unique_student_identifier are mutually exclusive." - ) - if all_students and delete_module: - return HttpResponseBadRequest( - "all_students and delete_module are mutually exclusive." + course = get_course_with_access( + request.user, 'staff', course_id, depth=None ) - try: - module_state_key = UsageKey.from_string(problem_to_reset).map_into_course(course_id) - except InvalidKeyError: - return HttpResponseBadRequest() + all_students = serializer_data.validated_data.get('all_students') - response_payload = {} - response_payload['problem_to_reset'] = problem_to_reset + if all_students and not has_access(request.user, 'instructor', course): + return HttpResponseForbidden("Requires instructor access.") - if student: - try: - enrollment.reset_student_attempts( - course_id, - student, - module_state_key, - requesting_user=request.user, - delete_module=delete_module + problem_to_reset = strip_if_string(serializer_data.validated_data.get('problem_to_reset')) + student_identifier = request.POST.get('unique_student_identifier', None) + student = serializer_data.validated_data.get('unique_student_identifier') + delete_module = serializer_data.validated_data.get('delete_module') + + # parameter combinations + if all_students and student: + return HttpResponseBadRequest( + "all_students and unique_student_identifier are mutually exclusive." + ) + if all_students and delete_module: + return HttpResponseBadRequest( + "all_students and delete_module are mutually exclusive." ) - except StudentModule.DoesNotExist: - return HttpResponseBadRequest(_("Module does not exist.")) - except sub_api.SubmissionError: - # Trust the submissions API to log the error - error_msg = _("An error occurred while deleting the score.") - return HttpResponse(error_msg, status=500) - response_payload['student'] = student_identifier - elif all_students: - task_api.submit_reset_problem_attempts_for_all_students(request, module_state_key) - response_payload['task'] = TASK_SUBMISSION_OK - response_payload['student'] = 'All Students' - else: - return HttpResponseBadRequest() - return JsonResponse(response_payload) + try: + module_state_key = UsageKey.from_string(problem_to_reset).map_into_course(course_id) + except InvalidKeyError: + return HttpResponseBadRequest() + + response_payload = {} + response_payload['problem_to_reset'] = problem_to_reset + + if student: + try: + enrollment.reset_student_attempts( + course_id, + student, + module_state_key, + requesting_user=request.user, + delete_module=delete_module + ) + except StudentModule.DoesNotExist: + return HttpResponseBadRequest(_("Module does not exist.")) + except sub_api.SubmissionError: + # Trust the submissions API to log the error + error_msg = _("An error occurred while deleting the score.") + return HttpResponse(error_msg, status=500) + response_payload['student'] = student_identifier + + elif all_students: + try: + task_api.submit_reset_problem_attempts_for_all_students(request, module_state_key) + response_payload['task'] = TASK_SUBMISSION_OK + response_payload['student'] = 'All Students' + except Exception: # pylint: disable=broad-except + error_msg = _("An error occurred while attempting to reset for all students.") + return HttpResponse(error_msg, status=500) + else: + return HttpResponseBadRequest() + + return JsonResponse(response_payload) @transaction.non_atomic_requests @@ -1873,8 +1948,10 @@ def reset_student_attempts_for_entrance_exam(request, course_id): 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 = _get_boolean_param(request, 'all_students') delete_module = _get_boolean_param(request, 'delete_module') @@ -2134,23 +2211,35 @@ def list_background_email_tasks(request, course_id): return JsonResponse(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_email_content(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ListEmailContent(APIView): """ List the content of bulk emails sent """ - course_id = CourseKey.from_string(course_id) - task_type = InstructorTaskTypes.BULK_COURSE_EMAIL - # First get tasks list of bulk emails sent - emails = task_api.get_instructor_task_history(course_id, task_type=task_type) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.EMAIL - response_payload = { - 'emails': list(map(extract_email_features, emails)), - } - return JsonResponse(response_payload) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + List the content of bulk emails sent for a specific course. + + Args: + request (HttpRequest): The HTTP request object. + course_id (str): The ID of the course for which to list the bulk emails. + + Returns: + HttpResponse: A response object containing the list of bulk email contents. + """ + course_id = CourseKey.from_string(course_id) + task_type = InstructorTaskTypes.BULK_COURSE_EMAIL + # First get tasks list of bulk emails sent + emails = task_api.get_instructor_task_history(course_id, task_type=task_type) + + response_payload = { + 'emails': list(map(extract_email_features, emails)), + } + return JsonResponse(response_payload) class InstructorTaskSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -2340,46 +2429,51 @@ def _list_instructor_tasks(request, course_id): return JsonResponse(response_payload) -@require_POST -@ensure_csrf_cookie -@cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.SHOW_TASKS) -def list_entrance_exam_instructor_tasks(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ListEntranceExamInstructorTasks(APIView): """ List entrance exam related instructor tasks. - - Takes either of the following query parameters - - unique_student_identifier is an email or username - - all_students is a boolean """ - course_id = CourseKey.from_string(course_id) - course = get_course_by_id(course_id) - student = request.POST.get('unique_student_identifier', None) - if student is not None: - student = get_student_from_identifier(student) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.SHOW_TASKS - 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.")) - if student: - # Specifying for a single student's entrance exam history - tasks = task_api.get_entrance_exam_instructor_task_history( - course_id, - entrance_exam_key, - student - ) - else: - # Specifying for all student's entrance exam history - tasks = task_api.get_entrance_exam_instructor_task_history( - course_id, - entrance_exam_key - ) + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + List entrance exam related instructor tasks. - response_payload = { - 'tasks': list(map(extract_task_features, tasks)), - } - return JsonResponse(response_payload) + Takes either of the following query parameters + - unique_student_identifier is an email or username + - all_students is a boolean + """ + course_id = CourseKey.from_string(course_id) + course = get_course_by_id(course_id) + student = request.POST.get('unique_student_identifier', None) + if student is not None: + student = get_student_from_identifier(student) + + 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.")) + if student: + # Specifying for a single student's entrance exam history + tasks = task_api.get_entrance_exam_instructor_task_history( + course_id, + entrance_exam_key, + student + ) + else: + # Specifying for all student's entrance exam history + tasks = task_api.get_entrance_exam_instructor_task_history( + course_id, + entrance_exam_key + ) + + response_payload = { + 'tasks': list(map(extract_task_features, tasks)), + } + return JsonResponse(response_payload) class ReportDownloadSerializer(serializers.Serializer): # pylint: disable=abstract-method @@ -2459,16 +2553,22 @@ def get(self, request, course_id): return _list_report_downloads(request=request, course_id=course_id) -@require_POST -@ensure_csrf_cookie -def list_report_downloads(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ListReportDownloads(APIView): + """ List grade CSV files that are available for download for this course. Takes the following query parameters: - (optional) report_name - name of the report """ - return _list_report_downloads(request=request, course_id=course_id) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.CAN_RESEARCH + + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + + return _list_report_downloads(request=request, course_id=course_id) @cache_control(no_cache=True, no_store=True, must_revalidate=True) @@ -2682,81 +2782,96 @@ def extract_user_info(user): 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.EMAIL) -@require_post_params(send_to="sending to whom", subject="subject line", message="message text") -@common_exceptions_400 -def send_email(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 SendEmail(DeveloperErrorViewMixin, APIView): """ Send an email to self, staff, cohorts, or everyone involved in a course. - Query Parameters: - - 'send_to' specifies what group the email should be sent to - Options are defined by the CourseEmail model in - lms/djangoapps/bulk_email/models.py - - 'subject' specifies email's subject - - 'message' specifies email's content """ - course_id = CourseKey.from_string(course_id) - course_overview = CourseOverview.get_from_id(course_id) + http_method_names = ['post'] + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.EMAIL + serializer_class = SendEmailSerializer - if not is_bulk_email_feature_enabled(course_id): - log.warning(f"Email is not enabled for course {course_id}") - return HttpResponseForbidden("Email is not enabled for this course.") + @method_decorator(ensure_csrf_cookie) + @method_decorator(transaction.non_atomic_requests) + def post(self, request, course_id): + """ + Query Parameters: + - 'send_to' specifies what group the email should be sent to + Options are defined by the CourseEmail model in + lms/djangoapps/bulk_email/models.py + - 'subject' specifies email's subject + - 'message' specifies email's content + """ + course_id = CourseKey.from_string(course_id) + course_overview = CourseOverview.get_from_id(course_id) - targets = json.loads(request.POST.get("send_to")) - subject = request.POST.get("subject") - message = request.POST.get("message") - # optional, this is a date and time in the form of an ISO8601 string - schedule = request.POST.get("schedule", "") + if not is_bulk_email_feature_enabled(course_id): + log.warning(f"Email is not enabled for course {course_id}") + return HttpResponseForbidden("Email is not enabled for this course.") - schedule_dt = None - if schedule: - try: - # convert the schedule from a string to a datetime, then check if its a valid future date and time, dateutil - # will throw a ValueError if the schedule is no good. - schedule_dt = dateutil.parser.parse(schedule).replace(tzinfo=pytz.utc) - if schedule_dt < datetime.datetime.now(pytz.utc): - raise ValueError("the requested schedule is in the past") - except ValueError as value_error: - error_message = ( - f"Error occurred creating a scheduled bulk email task. Schedule provided: '{schedule}'. Error: " - f"{value_error}" - ) - log.error(error_message) - return HttpResponseBadRequest(error_message) + serializer_data = self.serializer_class(data=request.data) + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) - # Retrieve the customized email "from address" and email template from site configuration for the course/partner. If - # there is no site configuration enabled for the current site then we use system defaults for both. - from_addr = _get_branded_email_from_address(course_overview) - template_name = _get_branded_email_template(course_overview) + # Skipping serializer validation to avoid potential disruptions. + # The API handles numerous input variations, and changes here could introduce breaking issues. - # Create the CourseEmail object. This is saved immediately so that any transaction that has been pending up to this - # point will also be committed. - try: - email = create_course_email( - course_id, - request.user, - targets, - subject, - message, - template_name=template_name, - from_addr=from_addr, - ) - except ValueError as err: - return HttpResponseBadRequest(repr(err)) + targets = json.loads(request.POST.get("send_to")) - # Submit the task, so that the correct InstructorTask object gets created (for monitoring purposes) - task_api.submit_bulk_course_email(request, course_id, email.id, schedule_dt) + subject = serializer_data.validated_data.get("subject") + message = serializer_data.validated_data.get("message") + # optional, this is a date and time in the form of an ISO8601 string + schedule = serializer_data.validated_data.get("schedule", "") - response_payload = { - 'course_id': str(course_id), - 'success': True, - } + schedule_dt = None + if schedule: + try: + # convert the schedule from a string to a datetime, then check if its a + # valid future date and time, dateutil + # will throw a ValueError if the schedule is no good. + schedule_dt = dateutil.parser.parse(schedule).replace(tzinfo=pytz.utc) + if schedule_dt < datetime.datetime.now(pytz.utc): + raise ValueError("the requested schedule is in the past") + except ValueError as value_error: + error_message = ( + f"Error occurred creating a scheduled bulk email task. Schedule provided: '{schedule}'. Error: " + f"{value_error}" + ) + log.error(error_message) + return HttpResponseBadRequest(error_message) - return JsonResponse(response_payload) + # Retrieve the customized email "from address" and email template from site configuration for the c + # ourse/partner. + # If there is no site configuration enabled for the current site then we use system defaults for both. + from_addr = _get_branded_email_from_address(course_overview) + template_name = _get_branded_email_template(course_overview) + + # Create the CourseEmail object. This is saved immediately so that any transaction that has been + # pending up to this point will also be committed. + try: + email = create_course_email( + course_id, + request.user, + targets, + subject, + message, + template_name=template_name, + from_addr=from_addr, + ) + except ValueError as err: + return HttpResponseBadRequest(repr(err)) + + # Submit the task, so that the correct InstructorTask object gets created (for monitoring purposes) + task_api.submit_bulk_course_email(request, course_id, email.id, schedule_dt) + + response_payload = { + 'course_id': str(course_id), + 'success': True, + } + + return JsonResponse(response_payload) @require_POST @@ -2852,28 +2967,50 @@ def _display_unit(unit): return str(unit.location) -@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('student', 'url', 'due_datetime') -def change_due_date(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ChangeDueDate(APIView): """ Grants a due date extension to a student for a particular unit. """ - course = get_course_by_id(CourseKey.from_string(course_id)) - student = require_student_from_identifier(request.POST.get('student')) - unit = find_unit(course, request.POST.get('url')) - due_date = parse_datetime(request.POST.get('due_datetime')) - reason = strip_tags(request.POST.get('reason', '')) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + permission_name = permissions.GIVE_STUDENT_EXTENSION + serializer_class = BlockDueDateSerializer + + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Grants a due date extension to a student for a particular unit. - set_due_date_extension(course, unit, student, due_date, request.user, reason=reason) + params: + url (str): The URL related to the block that needs the due date update. + due_datetime (str): The new due date and time for the block. + student (str): The email or username of the student whose access is being modified. + """ + serializer_data = self.serializer_class(data=request.data) + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) + + student = serializer_data.validated_data.get('student') + if not student: + response_payload = { + 'error': f'Could not find student matching identifier: {request.data.get("student")}' + } + return JsonResponse(response_payload) - return JsonResponse(_( - 'Successfully changed due date for student {0} for {1} ' - 'to {2}').format(student.profile.name, _display_unit(unit), - due_date.strftime('%Y-%m-%d %H:%M'))) + 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 JsonResponse(_( + 'Successfully changed due date for student {0} for {1} ' + 'to {2}').format(student.profile.name, _display_unit(unit), + due_date.strftime('%Y-%m-%d %H:%M'))) @handle_dashboard_error @@ -2924,20 +3061,50 @@ def show_unit_extensions(request, course_id): return JsonResponse(dump_block_extensions(course, unit)) -@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('student') -def show_student_extensions(request, course_id): +@method_decorator(cache_control(no_cache=True, no_store=True, must_revalidate=True), name='dispatch') +class ShowStudentExtensions(APIView): """ Shows all of the due date extensions granted to a particular student in a particular course. """ - student = require_student_from_identifier(request.POST.get('student')) - course = get_course_by_id(CourseKey.from_string(course_id)) - return JsonResponse(dump_student_extensions(course, student)) + permission_classes = (IsAuthenticated, permissions.InstructorPermission) + serializer_class = ShowStudentExtensionSerializer + permission_name = permissions.GIVE_STUDENT_EXTENSION + + @method_decorator(ensure_csrf_cookie) + def post(self, request, course_id): + """ + Handles POST requests to retrieve due date extensions for a specific student + within a specified course. + + Parameters: + - `request`: The HTTP request object containing user-submitted data. + - `course_id`: The ID of the course for which the extensions are being queried. + + Data expected in the request: + - `student`: A required field containing the identifier of the student for whom + the due date extensions are being retrieved. This data is extracted from the + request body. + + Returns: + - A JSON response containing the details of the due date extensions granted to + the specified student in the specified course. + """ + data = { + 'student': request.data.get('student') + } + serializer_data = self.serializer_class(data=data) + + if not serializer_data.is_valid(): + return HttpResponseBadRequest(reason=serializer_data.errors) + + student = serializer_data.validated_data.get('student') + if not student: + response_payload = f'Could not find student matching identifier: {request.data.get("student")}' + return JsonResponse({'error': response_payload}, status=400) + + course = get_course_by_id(CourseKey.from_string(course_id)) + return Response(dump_student_extensions(course, student)) def _split_input_list(str_list): diff --git a/lms/djangoapps/instructor/views/api_urls.py b/lms/djangoapps/instructor/views/api_urls.py index 0b4a88d1b7c6..0cb80238f7c2 100644 --- a/lms/djangoapps/instructor/views/api_urls.py +++ b/lms/djangoapps/instructor/views/api_urls.py @@ -1,3 +1,4 @@ + """ Instructor API endpoint urls. """ @@ -21,44 +22,44 @@ urlpatterns = [ path('students_update_enrollment', api.students_update_enrollment, name='students_update_enrollment'), - path('register_and_enroll_students', api.register_and_enroll_students, name='register_and_enroll_students'), - path('list_course_role_members', api.list_course_role_members, name='list_course_role_members'), - path('modify_access', api.modify_access, name='modify_access'), + 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('get_grading_config', api.get_grading_config, name='get_grading_config'), re_path(r'^get_students_features(?P/csv)?$', api.get_students_features, name='get_students_features'), path('get_issued_certificates/', api.get_issued_certificates, name='get_issued_certificates'), - path('get_students_who_may_enroll', api.get_students_who_may_enroll, name='get_students_who_may_enroll'), - path('get_anon_ids', api.get_anon_ids, name='get_anon_ids'), + path('get_students_who_may_enroll', api.GetStudentsWhoMayEnroll.as_view(), name='get_students_who_may_enroll'), + path('get_anon_ids', api.GetAnonIds.as_view(), name='get_anon_ids'), path('get_student_enrollment_status', api.get_student_enrollment_status, name="get_student_enrollment_status"), - path('get_student_progress_url', api.get_student_progress_url, name='get_student_progress_url'), - path('reset_student_attempts', api.reset_student_attempts, name='reset_student_attempts'), + 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('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('list_entrance_exam_instructor_tasks', api.list_entrance_exam_instructor_tasks, + path('list_entrance_exam_instructor_tasks', api.ListEntranceExamInstructorTasks.as_view(), name='list_entrance_exam_instructor_tasks'), path('mark_student_can_skip_entrance_exam', api.mark_student_can_skip_entrance_exam, name='mark_student_can_skip_entrance_exam'), path('list_instructor_tasks', api.list_instructor_tasks, name='list_instructor_tasks'), path('list_background_email_tasks', api.list_background_email_tasks, name='list_background_email_tasks'), - path('list_email_content', api.list_email_content, name='list_email_content'), + 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('send_email', api.send_email, name='send_email'), - path('change_due_date', api.change_due_date, name='change_due_date'), + 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.reset_due_date, name='reset_due_date'), path('show_unit_extensions', api.show_unit_extensions, name='show_unit_extensions'), - path('show_student_extensions', api.show_student_extensions, name='show_student_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'), # Grade downloads... - path('list_report_downloads', api.list_report_downloads, name='list_report_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'), diff --git a/lms/djangoapps/instructor/views/serializer.py b/lms/djangoapps/instructor/views/serializer.py new file mode 100644 index 000000000000..793acc9c6137 --- /dev/null +++ b/lms/djangoapps/instructor/views/serializer.py @@ -0,0 +1,180 @@ +""" Instructor apis serializers. """ + +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.instructor.access import ROLES + + +class RoleNameSerializer(serializers.Serializer): # pylint: disable=abstract-method + """ + Serializer that describes the response of the problem response report generation API. + """ + + rolename = serializers.CharField(help_text=_("Role name")) + + def validate_rolename(self, value): + """ + Check that the rolename is valid. + """ + if value not in ROLES: + raise ValidationError(_("Invalid role name.")) + return value + + +class UserSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['username', 'email', 'first_name', 'last_name'] + + +class AccessSerializer(serializers.Serializer): + """ + Serializer for managing user access changes. + This serializer validates and processes the data required to modify + user access within a system. + """ + unique_student_identifier = serializers.CharField( + max_length=255, + help_text="Email or username of user to change access" + ) + rolename = serializers.CharField( + help_text="Role name to assign to the user" + ) + action = serializers.ChoiceField( + choices=['allow', 'revoke'], + help_text="Action to perform on the user's access" + ) + + def validate_unique_student_identifier(self, value): + """ + Validate that the unique_student_identifier corresponds to an existing user. + """ + try: + user = get_student_from_identifier(value) + except User.DoesNotExist: + return None + + return user + + +class ShowStudentExtensionSerializer(serializers.Serializer): + """ + Serializer for validating and processing the student identifier. + """ + student = serializers.CharField(write_only=True, required=True) + + def validate_student(self, value): + """ + Validate that the student corresponds to an existing user. + """ + try: + user = get_student_from_identifier(value) + except User.DoesNotExist: + return None + + return user + + +class StudentAttemptsSerializer(serializers.Serializer): + """ + Serializer for resetting a students attempts counter or starts a task to reset all students + attempts counters. + """ + problem_to_reset = serializers.CharField( + help_text="The identifier or description of the problem that needs to be reset." + ) + + # following are optional params. + unique_student_identifier = serializers.CharField( + help_text="Email or username of student.", required=False + ) + all_students = serializers.CharField(required=False) + delete_module = serializers.CharField(required=False) + + def validate_all_students(self, value): + """ + converts the all_student params value to bool. + """ + return self.verify_bool(value) + + def validate_delete_module(self, value): + """ + converts the all_student params value. + """ + return self.verify_bool(value) + + def validate_unique_student_identifier(self, value): + """ + Validate that the student corresponds to an existing user. + """ + try: + user = get_student_from_identifier(value) + except User.DoesNotExist: + return None + + return user + + def verify_bool(self, value): + """ + Returns the value of the boolean parameter with the given + name in the POST request. Handles translation from string + values to boolean values. + """ + if value is not None: + return value in ['true', 'True', True] + + return False + + +class SendEmailSerializer(serializers.Serializer): + """ + Serializer for sending an email with optional scheduling. + + Fields: + send_to (str): The email address of the recipient. This field is required. + subject (str): The subject line of the email. This field is required. + message (str): The body of the email. This field is required. + schedule (str, optional): + An optional field to specify when the email should be sent. + If provided, this should be a string that can be parsed into a + datetime format or some other scheduling logic. + """ + send_to = serializers.CharField(write_only=True, required=True) + + # set max length as per model field. + subject = serializers.CharField(max_length=128, write_only=True, required=True) + message = serializers.CharField(required=True) + schedule = serializers.CharField(required=False) + + +class BlockDueDateSerializer(serializers.Serializer): + """ + Serializer for handling block due date updates for a specific student. + Fields: + url (str): The URL related to the block that needs the due date update. + due_datetime (str): The new due date and time for the block. + student (str): The email or username of the student whose access is being modified. + reason (str): Reason why updating this. + """ + url = serializers.CharField() + due_datetime = serializers.CharField() + student = serializers.CharField( + max_length=255, + help_text="Email or username of user to change access" + ) + reason = serializers.CharField(required=False) + + def validate_student(self, value): + """ + Validate that the student corresponds to an existing user. + """ + try: + user = get_student_from_identifier(value) + except User.DoesNotExist: + return None + + return user diff --git a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py index 8fa590c37f8c..1fb25aeb8c07 100644 --- a/lms/djangoapps/instructor_task/tests/test_tasks_helper.py +++ b/lms/djangoapps/instructor_task/tests/test_tasks_helper.py @@ -406,7 +406,7 @@ def test_query_counts(self): with patch('lms.djangoapps.instructor_task.tasks_helper.runner._get_current_task'): with check_mongo_calls(2): - with self.assertNumQueries(53): + with self.assertNumQueries(54): CourseGradeReport.generate(None, None, course.id, {}, 'graded') def test_inactive_enrollments(self): diff --git a/lms/djangoapps/learner_dashboard/config/waffle.py b/lms/djangoapps/learner_dashboard/config/waffle.py index 2195a2697269..cc63e8d5d13c 100644 --- a/lms/djangoapps/learner_dashboard/config/waffle.py +++ b/lms/djangoapps/learner_dashboard/config/waffle.py @@ -37,20 +37,3 @@ 'learner_dashboard.enable_masters_program_tab_view', __name__, ) - -# .. toggle_name: learner_dashboard.enable_b2c_subscriptions -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Waffle flag to enable new B2C Subscriptions Program data. -# This flag is used to decide whether we need to enable program subscription related properties in program listing -# and detail pages. -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2023-04-13 -# .. toggle_target_removal_date: 2023-07-01 -# .. toggle_warning: When the flag is ON, the new B2C Subscriptions Program data will be enabled in program listing -# and detail pages. -# .. toggle_tickets: PON-79 -ENABLE_B2C_SUBSCRIPTIONS = WaffleFlag( - 'learner_dashboard.enable_b2c_subscriptions', - __name__, -) diff --git a/lms/djangoapps/learner_dashboard/programs.py b/lms/djangoapps/learner_dashboard/programs.py index d567a4b9a350..dc334c0ce34e 100644 --- a/lms/djangoapps/learner_dashboard/programs.py +++ b/lms/djangoapps/learner_dashboard/programs.py @@ -6,7 +6,6 @@ from abc import ABC, abstractmethod from urllib.parse import quote -from django.conf import settings from django.contrib.sites.shortcuts import get_current_site from django.http import Http404 from django.template.loader import render_to_string @@ -18,7 +17,7 @@ from common.djangoapps.student.models import anonymous_id_for_user from common.djangoapps.student.roles import GlobalStaff -from lms.djangoapps.learner_dashboard.utils import b2c_subscriptions_enabled, program_tab_view_is_enabled +from lms.djangoapps.learner_dashboard.utils import program_tab_view_is_enabled from openedx.core.djangoapps.catalog.utils import get_programs from openedx.core.djangoapps.plugin_api.views import EdxFragmentView from openedx.core.djangoapps.programs.models import ( @@ -32,9 +31,7 @@ get_industry_and_credit_pathways, get_program_and_course_data, get_program_marketing_url, - get_program_subscriptions_marketing_url, get_program_urls, - get_programs_subscription_data ) from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences from openedx.core.djangolib.markup import HTML @@ -60,30 +57,12 @@ def render_to_fragment(self, request, **kwargs): raise Http404 meter = ProgramProgressMeter(request.site, user, mobile_only=mobile_only) - is_user_b2c_subscriptions_enabled = b2c_subscriptions_enabled(mobile_only) - programs_subscription_data = ( - get_programs_subscription_data(user) - if is_user_b2c_subscriptions_enabled - else [] - ) - subscription_upsell_data = ( - { - 'marketing_url': get_program_subscriptions_marketing_url(), - 'minimum_price': settings.SUBSCRIPTIONS_MINIMUM_PRICE, - 'trial_length': settings.SUBSCRIPTIONS_TRIAL_LENGTH, - } - if is_user_b2c_subscriptions_enabled - else {} - ) context = { 'marketing_url': get_program_marketing_url(programs_config, mobile_only), 'programs': meter.engaged_programs, 'progress': meter.progress(), - 'programs_subscription_data': programs_subscription_data, - 'subscription_upsell_data': subscription_upsell_data, 'user_preferences': get_user_preferences(user), - 'is_user_b2c_subscriptions_enabled': is_user_b2c_subscriptions_enabled, 'mobile_only': bool(mobile_only) } html = render_to_string('learner_dashboard/programs_fragment.html', context) @@ -137,12 +116,6 @@ def render_to_fragment(self, request, program_uuid, **kwargs): # lint-amnesty, program_discussion_lti = ProgramDiscussionLTI(program_uuid, request) program_live_lti = ProgramLiveLTI(program_uuid, request) - is_user_b2c_subscriptions_enabled = b2c_subscriptions_enabled(mobile_only) - program_subscription_data = ( - get_programs_subscription_data(user, program_uuid) - if is_user_b2c_subscriptions_enabled - else [] - ) def program_tab_view_enabled() -> bool: return program_tab_view_is_enabled() and ( @@ -156,14 +129,11 @@ def program_tab_view_enabled() -> bool: 'urls': urls, 'user_preferences': get_user_preferences(user), 'program_data': program_data, - 'program_subscription_data': program_subscription_data, 'course_data': course_data, 'certificate_data': certificate_data, 'industry_pathways': industry_pathways, 'credit_pathways': credit_pathways, 'program_tab_view_enabled': program_tab_view_enabled(), - 'is_user_b2c_subscriptions_enabled': is_user_b2c_subscriptions_enabled, - 'subscriptions_trial_length': settings.SUBSCRIPTIONS_TRIAL_LENGTH, 'discussion_fragment': { 'configured': program_discussion_lti.is_configured, 'iframe': program_discussion_lti.render_iframe() diff --git a/lms/djangoapps/learner_dashboard/utils.py b/lms/djangoapps/learner_dashboard/utils.py index a604ba73786a..5e9c172fcb78 100644 --- a/lms/djangoapps/learner_dashboard/utils.py +++ b/lms/djangoapps/learner_dashboard/utils.py @@ -7,7 +7,6 @@ from common.djangoapps.student.roles import GlobalStaff from lms.djangoapps.learner_dashboard.config.waffle import ( - ENABLE_B2C_SUBSCRIPTIONS, ENABLE_MASTERS_PROGRAM_TAB_VIEW, ENABLE_PROGRAM_TAB_VIEW ) @@ -50,19 +49,3 @@ def is_enrolled_or_staff(request, program_uuid): except ObjectDoesNotExist: return False return True - - -def b2c_subscriptions_is_enabled() -> bool: - """ - Check if B2C program subscriptions flag is enabled. - """ - return ENABLE_B2C_SUBSCRIPTIONS.is_enabled() - - -def b2c_subscriptions_enabled(is_mobile=False) -> bool: - """ - Check whether B2C Subscriptions pages should be shown to user. - """ - if not is_mobile and b2c_subscriptions_is_enabled(): - return True - return False diff --git a/lms/djangoapps/learner_home/serializers.py b/lms/djangoapps/learner_home/serializers.py index 0ce7bf9c6977..b3471715b9dc 100644 --- a/lms/djangoapps/learner_home/serializers.py +++ b/lms/djangoapps/learner_home/serializers.py @@ -15,6 +15,7 @@ from common.djangoapps.course_modes.models import CourseMode from openedx.features.course_experience import course_home_url from xmodule.data import CertificatesDisplayBehaviors +from lms.djangoapps.learner_home.utils import course_progress_url class LiteralField(serializers.Field): @@ -116,7 +117,7 @@ def get_homeUrl(self, instance): return course_home_url(instance.course_id) def get_progressUrl(self, instance): - return reverse("progress", kwargs={"course_id": instance.course_id}) + return course_progress_url(instance.course_id) def get_unenrollUrl(self, instance): return reverse("course_run_refund_status", args=[instance.course_id]) diff --git a/lms/djangoapps/learner_home/test_serializers.py b/lms/djangoapps/learner_home/test_serializers.py index f588af58aee4..ac11a8b2990d 100644 --- a/lms/djangoapps/learner_home/test_serializers.py +++ b/lms/djangoapps/learner_home/test_serializers.py @@ -51,7 +51,7 @@ SuggestedCourseSerializer, UnfulfilledEntitlementSerializer, ) - +from lms.djangoapps.learner_home.utils import course_progress_url from lms.djangoapps.learner_home.test_utils import ( datetime_to_django_format, random_bool, @@ -224,6 +224,30 @@ def test_missing_resume_url(self): # Then the resumeUrl is None, which is allowed self.assertIsNone(output_data["resumeUrl"]) + def is_progress_url_matching_course_home_mfe_progress_tab_is_active(self): + """ + Compares the progress URL generated by CourseRunSerializer to the expected progress URL. + + :return: True if the generated progress URL matches the expected, False otherwise. + """ + input_data = self.create_test_enrollment() + input_context = self.create_test_context(input_data.course.id) + output_data = CourseRunSerializer(input_data, context=input_context).data + return output_data['progressUrl'] == course_progress_url(input_data.course.id) + + @mock.patch('lms.djangoapps.learner_home.utils.course_home_mfe_progress_tab_is_active') + def test_progress_url(self, mock_course_home_mfe_progress_tab_is_active): + """ + Tests the progress URL generated by the CourseRunSerializer. When course_home_mfe_progress_tab_is_active + is true, the generated progress URL must point to the progress page of the course home (learning) MFE. + Otherwise, it must point to the legacy progress page. + """ + mock_course_home_mfe_progress_tab_is_active.return_value = True + self.assertTrue(self.is_progress_url_matching_course_home_mfe_progress_tab_is_active()) + + mock_course_home_mfe_progress_tab_is_active.return_value = False + self.assertTrue(self.is_progress_url_matching_course_home_mfe_progress_tab_is_active()) + @ddt.ddt class TestCoursewareAccessSerializer(LearnerDashboardBaseTest): diff --git a/lms/djangoapps/learner_home/utils.py b/lms/djangoapps/learner_home/utils.py index 28e4479f9439..96af6a64452b 100644 --- a/lms/djangoapps/learner_home/utils.py +++ b/lms/djangoapps/learner_home/utils.py @@ -4,6 +4,7 @@ import logging +from django.urls import reverse from django.contrib.auth import get_user_model from django.core.exceptions import MultipleObjectsReturned from rest_framework.exceptions import PermissionDenied, NotFound @@ -11,6 +12,8 @@ from common.djangoapps.student.models import ( get_user_by_username_or_email, ) +from lms.djangoapps.course_home_api.toggles import course_home_mfe_progress_tab_is_active +from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url log = logging.getLogger(__name__) User = get_user_model() @@ -54,3 +57,16 @@ def get_masquerade_user(request): ) log.info(success_msg) return masquerade_user + + +def course_progress_url(course_key) -> str: + """ + Returns the course progress page's URL for the current user. + + :param course_key: The course key for which the home url is being requested. + + :return: The course progress page URL. + """ + if course_home_mfe_progress_tab_is_active(course_key): + return get_learning_mfe_home_url(course_key, url_fragment='progress') + return reverse('progress', kwargs={'course_id': course_key}) diff --git a/lms/djangoapps/mobile_api/course_info/constants.py b/lms/djangoapps/mobile_api/course_info/constants.py new file mode 100644 index 000000000000..d62cb463951a --- /dev/null +++ b/lms/djangoapps/mobile_api/course_info/constants.py @@ -0,0 +1,5 @@ +""" +Common constants for the `course_info` API. +""" + +BLOCK_STRUCTURE_CACHE_TIMEOUT = 60 * 60 # 1 hour diff --git a/lms/djangoapps/mobile_api/course_info/serializers.py b/lms/djangoapps/mobile_api/course_info/serializers.py index d7a9471088aa..572afbfbef0d 100644 --- a/lms/djangoapps/mobile_api/course_info/serializers.py +++ b/lms/djangoapps/mobile_api/course_info/serializers.py @@ -1,18 +1,20 @@ """ Course Info serializers """ + +from typing import Dict, Union + from rest_framework import serializers -from typing import Union +from rest_framework.reverse import reverse from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.util.course import get_encoded_course_sharing_utm_params, get_link_for_about_page -from common.djangoapps.util.milestones_helpers import ( - get_pre_requisite_courses_not_completed, -) -from lms.djangoapps.courseware.access import has_access -from lms.djangoapps.courseware.access import administrative_accesses_to_course_for_user +from common.djangoapps.util.milestones_helpers import get_pre_requisite_courses_not_completed +from lms.djangoapps.courseware.access import administrative_accesses_to_course_for_user, has_access 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 openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.features.course_duration_limits.access import get_user_course_expiration_date @@ -31,6 +33,7 @@ class CourseInfoOverviewSerializer(serializers.ModelSerializer): course_sharing_utm_parameters = serializers.SerializerMethodField() course_about = serializers.SerializerMethodField('get_course_about_url') course_modes = serializers.SerializerMethodField() + course_progress = serializers.SerializerMethodField() class Meta: model = CourseOverview @@ -47,6 +50,7 @@ class Meta: 'course_sharing_utm_parameters', 'course_about', 'course_modes', + 'course_progress', ) @staticmethod @@ -75,6 +79,12 @@ def get_course_modes(self, course_overview): for mode in course_modes ] + def get_course_progress(self, obj: CourseOverview) -> Dict[str, int]: + """ + Gets course progress calculated by course completed assignments. + """ + return get_assignments_completions(obj.id, self.context.get('user')) + class MobileCourseEnrollmentSerializer(serializers.ModelSerializer): """ @@ -127,3 +137,94 @@ def get_courseware_access(self, data: dict) -> dict: Determine if the learner has access to the course, otherwise show error message. """ return has_access(data.get('user'), 'load_mobile', data.get('course')).to_json() + + +class CourseDetailSerializer(serializers.Serializer): + """ + Serializer for Course enrollment and overview details. + """ + + id = serializers.SerializerMethodField() + course_access_details = serializers.SerializerMethodField() + certificate = serializers.SerializerMethodField() + enrollment_details = serializers.SerializerMethodField() + course_handouts = serializers.SerializerMethodField() + course_updates = serializers.SerializerMethodField() + discussion_url = serializers.SerializerMethodField() + course_info_overview = serializers.SerializerMethodField() + + @staticmethod + def get_id(data): + """ + Returns course id. + """ + return str(data['course_id']) + + @staticmethod + def get_course_overview(course_id): + """ + Returns course overview. + """ + return CourseOverview.get_from_id(course_id) + + def get_course_info_overview(self, data): + """ + Returns course info overview. + """ + course_overview = self.get_course_overview(data['course_id']) + course_info_context = {'user': data['user']} + return CourseInfoOverviewSerializer(course_overview, context=course_info_context).data + + @staticmethod + def get_discussion_url(data): + """ + Returns discussion url. + """ + course_overview = CourseOverview.get_from_id(data['course_id']) + if not course_overview.is_discussion_tab_enabled(data['user']): + return + + return reverse('discussion_course', kwargs={'course_id': data['course_id']}, request=data['request']) + + def get_course_access_details(self, data): + """ + Returns course access details. + """ + course_access_data = { + 'course': self.get_course_overview(data['course_id']), + 'course_id': data['course_id'], + 'user': data['user'], + } + return CourseAccessSerializer(course_access_data).data + + @staticmethod + def get_certificate(data): + """ + Returns course certificate url. + """ + return get_user_certificate_download_url(data['request'], data['user'], data['course_id']) + + @staticmethod + def get_enrollment_details(data): + """ + Retrieve course enrollment details of the course. + """ + user_enrollment = CourseEnrollment.get_enrollment(user=data['user'], course_key=data['course_id']) + return MobileCourseEnrollmentSerializer(user_enrollment).data + + @staticmethod + def get_course_handouts(data): + """ + Returns course_handouts. + """ + + url_params = {'api_version': data['api_version'], 'course_id': data['course_id']} + return reverse('course-handouts-list', kwargs=url_params, request=data['request']) + + @staticmethod + def get_course_updates(data): + """ + Returns course_updates. + """ + url_params = {'api_version': data['api_version'], 'course_id': data['course_id']} + return reverse('course-updates-list', kwargs=url_params, request=data['request']) diff --git a/lms/djangoapps/mobile_api/course_info/urls.py b/lms/djangoapps/mobile_api/course_info/urls.py index e369930e2550..6b7445d9c75c 100644 --- a/lms/djangoapps/mobile_api/course_info/urls.py +++ b/lms/djangoapps/mobile_api/course_info/urls.py @@ -6,7 +6,13 @@ from django.conf import settings from django.urls import path, re_path -from .views import CourseHandoutsList, CourseUpdatesList, CourseGoalsRecordUserActivity, BlocksInfoInCourseView +from .views import ( + BlocksInfoInCourseView, + CourseEnrollmentDetailsView, + CourseGoalsRecordUserActivity, + CourseHandoutsList, + CourseUpdatesList +) urlpatterns = [ re_path( @@ -19,6 +25,11 @@ CourseUpdatesList.as_view(), name='course-updates-list' ), + re_path( + fr'^{settings.COURSE_ID_PATTERN}/enrollment_details$', + CourseEnrollmentDetailsView.as_view(), + name='course-enrollment-details' + ), path('record_user_activity', CourseGoalsRecordUserActivity.as_view(), name='record_user_activity'), path('blocks/', BlocksInfoInCourseView.as_view(), name="blocks_info_in_course"), ] diff --git a/lms/djangoapps/mobile_api/course_info/utils.py b/lms/djangoapps/mobile_api/course_info/utils.py new file mode 100644 index 000000000000..10ea1fa53d9a --- /dev/null +++ b/lms/djangoapps/mobile_api/course_info/utils.py @@ -0,0 +1,25 @@ +""" +Common utility methods for Course info apis. +""" + +from lms.djangoapps.certificates.api import certificate_downloadable_status + + +def get_user_certificate_download_url(request, user, course_id): + """ + Return the information about the user's certificate in the course. + + Arguments: + request (Request): The request object. + user (User): The user object. + course_id (str): The identifier of the course. + Returns: + (dict): A dict containing information about location of the user's certificate + or an empty dictionary, if there is no certificate. + """ + certificate_info = certificate_downloadable_status(user, course_id) + if certificate_info['is_downloadable']: + return { + 'url': request.build_absolute_uri(certificate_info['download_url']), + } + return {} diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py index bd34336cc824..affefafe5ba0 100644 --- a/lms/djangoapps/mobile_api/course_info/views.py +++ b/lms/djangoapps/mobile_api/course_info/views.py @@ -3,7 +3,7 @@ """ import logging -from typing import Optional, Union +from typing import Dict, Optional, Union import django from django.contrib.auth import get_user_model @@ -14,22 +14,26 @@ from rest_framework.reverse import reverse from rest_framework.views import APIView -from common.djangoapps.student.models import CourseEnrollment, User as StudentUser from common.djangoapps.static_replace import make_static_urls_absolute -from lms.djangoapps.certificates.api import certificate_downloadable_status -from lms.djangoapps.courseware.courses import get_course_info_section_block -from lms.djangoapps.course_goals.models import UserActivity +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.models import User as StudentUser from lms.djangoapps.course_api.blocks.views import BlocksInCourseView +from lms.djangoapps.course_goals.models import UserActivity +from lms.djangoapps.courseware.courses import get_assignments_grades, get_course_info_section_block +from lms.djangoapps.mobile_api.course_info.constants import BLOCK_STRUCTURE_CACHE_TIMEOUT from lms.djangoapps.mobile_api.course_info.serializers import ( - CourseInfoOverviewSerializer, CourseAccessSerializer, + CourseDetailSerializer, + CourseInfoOverviewSerializer, MobileCourseEnrollmentSerializer ) from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.lib.api.view_utils import view_auth_classes from openedx.core.lib.xblock_utils import get_course_update_items from openedx.features.course_experience import ENABLE_COURSE_GOALS + from ..decorators import mobile_course_access, mobile_view +from .utils import get_user_certificate_download_url User = get_user_model() log = logging.getLogger(__name__) @@ -269,6 +273,11 @@ class BlocksInfoInCourseView(BlocksInCourseView): course, chapter, sequential, vertical, html, problem, video, and discussion. display_name: (str) The display name of the block. + course_progress: (dict) Contains information about how many assignments are in the course + and how many assignments the student has completed. + Included here: + * total_assignments_count: (int) Total course's assignments count. + * assignments_completed: (int) Assignments witch the student has completed. **Returns** @@ -302,27 +311,6 @@ def get_requested_user(self, user: UserType, username: Optional[str] = None) -> log.warning('Provided username does not correspond to an existing user %s', username) return None - def get_certificate(self, request, user, course_id): - """ - Return the information about the user's certificate in the course. - - Arguments: - request (Request): The request object. - user (User): The user object. - course_id (str): The identifier of the course. - Returns: - (dict): A dict containing information about location of the user's certificate - or an empty dictionary, if there is no certificate. - """ - certificate_info = certificate_downloadable_status(user, course_id) - if certificate_info['is_downloadable']: - return { - 'url': request.build_absolute_uri( - certificate_info['download_url'] - ), - } - return {} - def list(self, request, **kwargs): # pylint: disable=W0221 """ REST API endpoint for listing all the blocks information in the course and @@ -357,8 +345,14 @@ def list(self, request, **kwargs): # pylint: disable=W0221 course_info_context = {} if requested_user := self.get_requested_user(request.user, requested_username): + self._extend_sequential_info_with_assignment_progress( + requested_user, + course_key, + response.data['blocks'], + ) + course_info_context = { - 'user': requested_user + 'user': requested_user, } user_enrollment = CourseEnrollment.get_enrollment(user=requested_user, course_key=course_key) course_data.update({ @@ -372,7 +366,7 @@ def list(self, request, **kwargs): # pylint: disable=W0221 'course': course_overview, 'course_id': course_key }).data, - 'certificate': self.get_certificate(request, requested_user, course_key), + 'certificate': get_user_certificate_download_url(request, requested_user, course_key), 'enrollment_details': MobileCourseEnrollmentSerializer(user_enrollment).data, }) @@ -380,3 +374,72 @@ def list(self, request, **kwargs): # pylint: disable=W0221 response.data.update(course_data) return response + + @staticmethod + def _extend_sequential_info_with_assignment_progress( + requested_user: User, + course_id: CourseKey, + blocks_info_data: Dict[str, Dict], + ) -> None: + """ + Extends sequential xblock info with assignment's name and progress. + """ + subsection_grades = get_assignments_grades(requested_user, course_id, BLOCK_STRUCTURE_CACHE_TIMEOUT) + grades_with_locations = {str(grade.location): grade for grade in subsection_grades} + + for block_id, block_info in blocks_info_data.items(): + if block_info['type'] == 'sequential': + grade = grades_with_locations.get(block_id) + if grade: + graded_total = grade.graded_total if grade.graded else None + points_earned = graded_total.earned if graded_total else 0 + points_possible = graded_total.possible if graded_total else 0 + assignment_type = grade.format + else: + points_earned, points_possible, assignment_type = 0, 0, None + + block_info.update( + { + 'assignment_progress': { + 'assignment_type': assignment_type, + 'num_points_earned': points_earned, + 'num_points_possible': points_possible, + } + } + ) + + +@mobile_view() +class CourseEnrollmentDetailsView(APIView): + """ + API that returns course details for logged-in user in the given course + + **Example requests**: + + This api works with all versions {api_version}, you can use: v0.5, v1, v2 or v3 + + GET /api/mobile/{api_version}/course_info/{course_id}/enrollment_details + + """ + def get(self, request, *args, **kwargs): + """ + Handle the GET request + + Returns user enrollment and course details. + """ + course_key_string = kwargs.get('course_id') + try: + course_key = CourseKey.from_string(course_key_string) + except InvalidKeyError: + error = {'error': f"'{str(course_key_string)}' is not a valid course key."} + return Response(data=error, status=status.HTTP_400_BAD_REQUEST) + + data = { + 'api_version': self.kwargs.get('api_version'), + 'course_id': course_key, + 'user': request.user, + 'request': request, + } + + course_detail = CourseDetailSerializer(data).data + return Response(data=course_detail, status=status.HTTP_200_OK) diff --git a/lms/djangoapps/mobile_api/tests/test_course_info_serializers.py b/lms/djangoapps/mobile_api/tests/test_course_info_serializers.py index 51d9acba54cc..03044b377743 100644 --- a/lms/djangoapps/mobile_api/tests/test_course_info_serializers.py +++ b/lms/djangoapps/mobile_api/tests/test_course_info_serializers.py @@ -1,17 +1,14 @@ """ Tests for serializers for the Mobile Course Info """ -from unittest.mock import MagicMock, Mock, patch from typing import Dict, List, Tuple, Union +from unittest.mock import MagicMock, Mock, patch import ddt from django.test import TestCase from common.djangoapps.student.tests.factories import UserFactory -from lms.djangoapps.mobile_api.course_info.serializers import ( - CourseAccessSerializer, - CourseInfoOverviewSerializer, -) +from lms.djangoapps.mobile_api.course_info.serializers import CourseAccessSerializer, CourseInfoOverviewSerializer from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory @@ -147,7 +144,8 @@ def setUp(self): self.user = UserFactory() self.course_overview = CourseOverviewFactory() - def test_get_media(self): + @patch('lms.djangoapps.mobile_api.course_info.serializers.get_assignments_completions') + def test_get_media(self, get_assignments_completions_mock: MagicMock) -> None: output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data self.assertIn('media', output_data) @@ -156,16 +154,53 @@ def test_get_media(self): self.assertIn('small', output_data['media']['image']) self.assertIn('large', output_data['media']['image']) - @patch('lms.djangoapps.mobile_api.course_info.serializers.get_link_for_about_page', return_value='mock_about_link') - def test_get_course_sharing_utm_parameters(self, mock_get_link_for_about_page: MagicMock) -> None: + @patch('lms.djangoapps.mobile_api.course_info.serializers.get_assignments_completions') + @patch( + 'lms.djangoapps.mobile_api.course_info.serializers.get_link_for_about_page', + return_value='mock_about_link' + ) + def test_get_course_sharing_utm_parameters( + self, + mock_get_link_for_about_page: MagicMock, + get_assignments_completions_mock: MagicMock, + ) -> None: output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data self.assertEqual(output_data['course_about'], mock_get_link_for_about_page.return_value) mock_get_link_for_about_page.assert_called_once_with(self.course_overview) - def test_get_course_modes(self): + @patch('lms.djangoapps.mobile_api.course_info.serializers.get_assignments_completions') + def test_get_course_modes(self, get_assignments_completions_mock: MagicMock) -> None: expected_course_modes = [{'slug': 'audit', 'sku': None, 'android_sku': None, 'ios_sku': None, 'min_price': 0}] output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data self.assertListEqual(output_data['course_modes'], expected_course_modes) + + @patch('lms.djangoapps.courseware.courses.get_course_assignments') + def test_get_course_progress_no_assignments(self, get_course_assignment_mock: MagicMock) -> None: + expected_course_progress = {'total_assignments_count': 0, 'assignments_completed': 0} + + output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data + + self.assertIn('course_progress', output_data) + self.assertDictEqual(output_data['course_progress'], expected_course_progress) + get_course_assignment_mock.assert_called_once_with( + self.course_overview.id, self.user, include_without_due=True + ) + + @patch('lms.djangoapps.courseware.courses.get_course_assignments') + def test_get_course_progress_with_assignments(self, get_course_assignment_mock: MagicMock) -> None: + assignments_mock = [ + Mock(complete=False), Mock(complete=False), Mock(complete=True), Mock(complete=True), Mock(complete=True) + ] + get_course_assignment_mock.return_value = assignments_mock + expected_course_progress = {'total_assignments_count': 5, 'assignments_completed': 3} + + output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data + + self.assertIn('course_progress', output_data) + self.assertDictEqual(output_data['course_progress'], expected_course_progress) + get_course_assignment_mock.assert_called_once_with( + self.course_overview.id, self.user, include_without_due=True + ) diff --git a/lms/djangoapps/mobile_api/tests/test_course_info_utils.py b/lms/djangoapps/mobile_api/tests/test_course_info_utils.py new file mode 100644 index 000000000000..e0c3ead08c03 --- /dev/null +++ b/lms/djangoapps/mobile_api/tests/test_course_info_utils.py @@ -0,0 +1,41 @@ +""" +Tests for the Mobile Course Info utils +""" +from unittest.mock import patch + +import ddt +from django.test import RequestFactory +from django.urls import reverse + +from lms.djangoapps.mobile_api.course_info.utils import get_user_certificate_download_url +from lms.djangoapps.mobile_api.testutils import MobileAPITestCase + + +@ddt.ddt +class TestCourseInfoUtils(MobileAPITestCase): + """ + Tests for Course info utils + """ + @ddt.data( + ({'is_downloadable': True, 'download_url': 'https://test_certificate_url'}, + {'url': 'https://test_certificate_url'}), + ({'is_downloadable': False}, {}), + ) + @ddt.unpack + @patch('lms.djangoapps.mobile_api.course_info.utils.certificate_downloadable_status') + def test_get_certificate(self, certificate_status_return, expected_output, mock_certificate_status): + """ + Test get_certificate utility from the Course info utils. + Parameters: + certificate_status_return: returned value of the mocked certificate_downloadable_status function. + expected_output: return_value of the get_certificate function with specified mock return_value. + """ + mock_certificate_status.return_value = certificate_status_return + url = reverse('blocks_info_in_course', kwargs={'api_version': 'v3'}) + request = RequestFactory().get(url) + request.user = self.user + + certificate_info = get_user_certificate_download_url( + request, self.user, 'course-v1:Test+T101+2021_T1' + ) + self.assertEqual(certificate_info, expected_output) diff --git a/lms/djangoapps/mobile_api/tests/test_course_info_views.py b/lms/djangoapps/mobile_api/tests/test_course_info_views.py index 25fe08980379..56c020ec8fa3 100644 --- a/lms/djangoapps/mobile_api/tests/test_course_info_views.py +++ b/lms/djangoapps/mobile_api/tests/test_course_info_views.py @@ -1,9 +1,9 @@ """ Tests for course_info """ +from datetime import datetime, timedelta from unittest.mock import patch - import ddt from django.conf import settings from django.contrib.auth import get_user_model @@ -12,23 +12,25 @@ from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_flag from milestones.tests.utils import MilestonesTestCaseMixin +from pytz import utc from rest_framework import status from common.djangoapps.student.tests.factories import UserFactory # pylint: disable=unused-import from common.djangoapps.util.course import get_link_for_about_page +from lms.djangoapps.course_api.blocks.tests.test_views import TestBlocksInCourseView +from lms.djangoapps.mobile_api.course_info.views import BlocksInfoInCourseView from lms.djangoapps.mobile_api.testutils import MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin from lms.djangoapps.mobile_api.utils import API_V1, API_V05 -from lms.djangoapps.mobile_api.course_info.views import BlocksInfoInCourseView -from lms.djangoapps.course_api.blocks.tests.test_views import TestBlocksInCourseView from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.features.course_experience import ENABLE_COURSE_GOALS from xmodule.html_block import CourseInfoBlock # lint-amnesty, pylint: disable=wrong-import-order 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.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import \ + SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.xml_importer import import_course_from_xml # lint-amnesty, pylint: disable=wrong-import-order - User = get_user_model() @@ -318,30 +320,7 @@ def test_get_requested_user(self, user_role, username, expected_username, mock_g else: self.assertIsNone(result_user) - @ddt.data( - ({'is_downloadable': True, 'download_url': 'https://test_certificate_url'}, - {'url': 'https://test_certificate_url'}), - ({'is_downloadable': False}, {}), - ) - @ddt.unpack - @patch('lms.djangoapps.mobile_api.course_info.views.certificate_downloadable_status') - def test_get_certificate(self, certificate_status_return, expected_output, mock_certificate_status): - """ - Test get_certificate utility from the BlocksInfoInCourseView. - - Parameters: - certificate_status_return: returned value of the mocked certificate_downloadable_status function. - expected_output: return_value of the get_certificate function with specified mock return_value. - """ - mock_certificate_status.return_value = certificate_status_return - self.request.user = self.user - - certificate_info = BlocksInfoInCourseView().get_certificate( - self.request, self.user, 'course-v1:Test+T101+2021_T1' - ) - self.assertEqual(certificate_info, expected_output) - - @patch('lms.djangoapps.mobile_api.course_info.views.certificate_downloadable_status') + @patch('lms.djangoapps.mobile_api.course_info.utils.certificate_downloadable_status') def test_additional_info_response(self, mock_certificate_downloadable_status): certificate_url = 'https://test_certificate_url' mock_certificate_downloadable_status.return_value = { @@ -422,3 +401,202 @@ def test_course_modes(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertListEqual(response.data['course_modes'], expected_course_modes) + + def test_extend_sequential_info_with_assignment_progress_get_only_sequential(self) -> None: + response = self.verify_response(url=self.url, params={'block_types_filter': 'sequential'}) + + expected_results = ( + { + 'assignment_type': 'Lecture Sequence', + 'num_points_earned': 0.0, + 'num_points_possible': 0.0 + }, + { + 'assignment_type': None, + 'num_points_earned': 0.0, + 'num_points_possible': 0.0 + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for sequential_info, assignment_progress in zip(response.data['blocks'].values(), expected_results): + self.assertDictEqual(sequential_info['assignment_progress'], assignment_progress) + + @ddt.data('chapter', 'vertical', 'problem', 'video', 'html') + def test_extend_sequential_info_with_assignment_progress_for_other_types(self, block_type: 'str') -> None: + response = self.verify_response(url=self.url, params={'block_types_filter': block_type}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for block_info in response.data['blocks'].values(): + self.assertNotEqual('assignment_progress', block_info) + + +class TestCourseEnrollmentDetailsView(MobileAPITestCase, MilestonesTestCaseMixin): # lint-amnesty, pylint: disable=test-inherits-tests + """ + Test class for CourseEnrollmentDetailsView + """ + + def setUp(self): + super().setUp() + self.url = reverse('course-enrollment-details', kwargs={ + 'api_version': 'v1', + 'course_id': self.course.id + }) + self.login_and_enroll() + + @patch('lms.djangoapps.mobile_api.course_info.utils.certificate_downloadable_status') + def test_response_data(self, mock_certificate_downloadable_status): + """ Test course enrollment detail view """ + + certificate_url = 'https://test_certificate_url' + mock_certificate_downloadable_status.return_value = { + 'is_downloadable': True, + 'download_url': certificate_url, + } + + response = self.client.get(path=self.url) + assert response.status_code == 200 + assert response.data['id'] == str(self.course.id) + + self.verify_course_info_overview(response) + self.verify_certificate(response, mock_certificate_downloadable_status) + self.verify_course_access_details(response) + + def verify_course_info_overview(self, response): + """ Verify course info overview """ + + expected_image_urls = { + 'image': + { + 'large': '/static/needed_for_split/images/course_image.jpg', + 'raw': '/static/needed_for_split/images/course_image.jpg', + 'small': '/static/needed_for_split/images/course_image.jpg' + } + } + + course_info = response.data['course_info_overview'] + assert course_info['name'] == self.course.display_name + assert course_info['number'] == self.course.display_number_with_default + assert course_info['org'] == self.course.display_org_with_default + assert course_info['start'] == self.course.start.strftime('%Y-%m-%dT%H:%M:%SZ') + assert course_info['start_display'] is None + assert course_info['start_type'] == 'empty' + assert course_info['end'] == self.course.end.strftime("%Y-%m-%dT%H:%M:%S.%fZ") + assert course_info['media'] == expected_image_urls + assert course_info['is_self_paced'] is False + expected_course_sharing_utm_parameters = { + 'facebook': 'utm_medium=social&utm_campaign=social-sharing-db&utm_source=facebook', + 'twitter': 'utm_medium=social&utm_campaign=social-sharing-db&utm_source=twitter' + } + self.assertDictEqual(course_info['course_sharing_utm_parameters'], expected_course_sharing_utm_parameters) + + expected_course_modes = [{'slug': 'audit', 'sku': None, 'android_sku': None, 'ios_sku': None, 'min_price': 0}] + self.assertListEqual(course_info['course_modes'], expected_course_modes) + + course_overview = CourseOverview.objects.get(id=self.course.course_id) + expected_course_about_link = get_link_for_about_page(course_overview) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(course_info['course_about'], expected_course_about_link) + + def verify_course_access_details(self, response): + """ Verify access details """ + + expected_course_access_details = { + 'has_unmet_prerequisites': False, + 'is_too_early': False, + 'is_staff': False, + 'audit_access_expires': None, + 'courseware_access': { + 'has_access': True, + 'error_code': None, + 'developer_message': None, + 'user_message': None, + 'additional_context_user_message': None, + 'user_fragment': None + } + } + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.data['course_access_details'], expected_course_access_details) + + def verify_certificate(self, response, mock_certificate_downloadable_status): + """ Verify certificate url """ + mock_certificate_downloadable_status.assert_called_once() + certificate_url = 'https://test_certificate_url' + assert response.data['certificate'] == {'url': certificate_url} + + @patch('lms.djangoapps.mobile_api.course_info.utils.certificate_downloadable_status') + def test_course_not_started(self, mock_certificate_downloadable_status): + """ Test course data which has not started yet """ + + certificate_url = 'https://test_certificate_url' + mock_certificate_downloadable_status.return_value = { + 'is_downloadable': True, + 'download_url': certificate_url, + } + now = datetime.now(utc) + course_not_started = CourseFactory.create( + mobile_available=True, + static_asset_path="needed_for_split", + start=now + timedelta(days=5), + ) + + url = reverse('course-enrollment-details', kwargs={ + 'api_version': 'v1', + 'course_id': course_not_started.id + }) + + response = self.client.get(path=url) + assert response.status_code == 200 + assert response.data['id'] == str(course_not_started.id) + + self.verify_course_access_details(response) + + @patch('lms.djangoapps.mobile_api.course_info.utils.certificate_downloadable_status') + def test_course_closed(self, mock_certificate_downloadable_status): + """ Test course data whose end date is in past """ + + certificate_url = 'https://test_certificate_url' + mock_certificate_downloadable_status.return_value = { + 'is_downloadable': True, + 'download_url': certificate_url, + } + now = datetime.now(utc) + course_closed = CourseFactory.create( + mobile_available=True, + static_asset_path="needed_for_split", + start=now - timedelta(days=250), + end=now - timedelta(days=50), + ) + + url = reverse('course-enrollment-details', kwargs={ + 'api_version': 'v1', + 'course_id': course_closed.id + }) + + response = self.client.get(path=url) + assert response.status_code == 200 + assert response.data['id'] == str(course_closed.id) + + self.verify_course_access_details(response) + + @patch('lms.djangoapps.mobile_api.course_info.utils.certificate_downloadable_status') + def test_invalid_course_id(self, mock_certificate_downloadable_status): + """ Test view with invalid course id """ + + certificate_url = 'https://test_certificate_url' + mock_certificate_downloadable_status.return_value = { + 'is_downloadable': True, + 'download_url': certificate_url, + } + + invalid_id = "invalid" + str(self.course.id) + url = reverse('course-enrollment-details', kwargs={ + 'api_version': 'v1', + 'course_id': invalid_id + }) + + response = self.client.get(path=url) + assert response.status_code == 400 + expected_error = "'{}' is not a valid course key.".format(invalid_id) + assert response.data['error'] == expected_error diff --git a/lms/djangoapps/mobile_api/users/enums.py b/lms/djangoapps/mobile_api/users/enums.py new file mode 100644 index 000000000000..2a072b082fff --- /dev/null +++ b/lms/djangoapps/mobile_api/users/enums.py @@ -0,0 +1,22 @@ +""" +Enums for mobile_api users app. +""" +from enum import Enum + + +class EnrollmentStatuses(Enum): + """ + Enum for enrollment statuses. + """ + + ALL = 'all' + IN_PROGRESS = 'in_progress' + COMPLETED = 'completed' + EXPIRED = 'expired' + + @classmethod + def values(cls): + """ + Returns string representation of all enum values. + """ + return [e.value for e in cls] diff --git a/lms/djangoapps/mobile_api/users/serializers.py b/lms/djangoapps/mobile_api/users/serializers.py index d7005e5f68e7..d8de11e50ff8 100644 --- a/lms/djangoapps/mobile_api/users/serializers.py +++ b/lms/djangoapps/mobile_api/users/serializers.py @@ -2,7 +2,11 @@ Serializer for user API """ +from typing import Dict, List, Optional +from completion.exceptions import UnavailableCompletionData +from completion.utilities import get_key_to_last_completed_block +from opaque_keys.edx.keys import UsageKey from rest_framework import serializers from rest_framework.reverse import reverse @@ -11,7 +15,13 @@ from common.djangoapps.util.course import get_encoded_course_sharing_utm_params, get_link_for_about_page 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 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 +from xmodule.modulestore.search import path_to_location class CourseOverviewField(serializers.RelatedField): # lint-amnesty, pylint: disable=abstract-method @@ -97,7 +107,7 @@ def get_audit_access_expires(self, model): """ Returns expiration date for a course audit expiration, if any or null """ - return get_user_course_expiration_date(model.user, model.course) + return get_user_course_expiration_date(model.user, model.course, model) def get_certificate(self, model): """Returns the information about the user's certificate in the course.""" @@ -124,6 +134,17 @@ def get_course_modes(self, obj): for mode in course_modes ] + def to_representation(self, instance: CourseEnrollment) -> 'OrderedDict': # lint-amnesty, pylint: disable=unused-variable, line-too-long + """ + Override the to_representation method to add the course_status field to the serialized data. + """ + data = super().to_representation(instance) + + if 'course_progress' in self.context.get('requested_fields', []) and self.context.get('api_version') == API_V4: + data['course_progress'] = get_assignments_completions(instance.course_id, instance.user) + + return data + class Meta: model = CourseEnrollment fields = ('audit_access_expires', 'created', 'mode', 'is_active', 'course', 'certificate', 'course_modes') @@ -141,6 +162,76 @@ class Meta: lookup_field = 'username' +class CourseEnrollmentSerializerModifiedForPrimary(CourseEnrollmentSerializer): + """ + Serializes CourseEnrollment models for API v4. + + Adds `course_status` field into serializer data. + """ + + course_status = serializers.SerializerMethodField() + course_progress = serializers.SerializerMethodField() + course_assignments = serializers.SerializerMethodField() + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.course = modulestore().get_course(self.instance.course.id) + + def get_course_status(self, model: CourseEnrollment) -> Optional[Dict[str, List[str]]]: + """ + Gets course status for the given user's enrollments. + """ + try: + block_key = get_key_to_last_completed_block(model.user, model.course.id) + path = path_to_location(modulestore(), block_key, self.context['request'], full_path=True) + except (ItemNotFoundError, NoPathToItem, UnavailableCompletionData): + return None + + path_ids = [str(block) for block in path] + unit = modulestore().get_item(UsageKey.from_string(path_ids[3]), depth=0) + + return { + 'last_visited_module_id': path_ids[2], + 'last_visited_module_path': path_ids[:3], + 'last_visited_block_id': path_ids[-1], + 'last_visited_unit_display_name': unit.display_name, + } + + def get_course_progress(self, model: CourseEnrollment) -> Dict[str, int]: + """ + Returns the progress of the user in the course. + """ + return get_assignments_completions(model.course_id, model.user) + + def get_course_assignments(self, model: CourseEnrollment) -> Dict[str, Optional[List[Dict[str, str]]]]: + """ + Returns the future assignment data and past assignments data for the user in the course. + """ + next_assignments, past_assignments = get_past_and_future_course_assignments( + self.context.get('request'), model.user, self.course + ) + return { + 'future_assignments': DateSummarySerializer(next_assignments, many=True).data, + 'past_assignments': DateSummarySerializer(past_assignments, many=True).data, + } + + class Meta: + model = CourseEnrollment + fields = ( + 'audit_access_expires', + 'created', + 'mode', + 'is_active', + 'course', + 'certificate', + 'course_modes', + 'course_status', + 'course_progress', + 'course_assignments', + ) + lookup_field = 'username' + + class UserSerializer(serializers.ModelSerializer): """ Serializes User models diff --git a/lms/djangoapps/mobile_api/users/tests.py b/lms/djangoapps/mobile_api/users/tests.py index 65b1fba65ce3..6cd5e3d29a4e 100644 --- a/lms/djangoapps/mobile_api/users/tests.py +++ b/lms/djangoapps/mobile_api/users/tests.py @@ -4,7 +4,7 @@ import datetime -from unittest.mock import patch +from unittest.mock import MagicMock, Mock, patch from urllib.parse import parse_qs import ddt @@ -18,6 +18,7 @@ from django.utils.timezone import now from milestones.tests.utils import MilestonesTestCaseMixin from opaque_keys.edx.keys import CourseKey +from rest_framework import status from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment @@ -27,6 +28,7 @@ from lms.djangoapps.certificates.data import CertificateStatuses from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory from lms.djangoapps.courseware.access_response import MilestoneAccessError, StartDateError, VisibilityError +from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.mobile_api.models import MobileConfig from lms.djangoapps.mobile_api.testutils import ( MobileAPITestCase, @@ -34,7 +36,8 @@ MobileAuthUserTestMixin, MobileCourseAccessTestMixin ) -from lms.djangoapps.mobile_api.utils import API_V1, API_V05, API_V2, API_V3 +from lms.djangoapps.mobile_api.users.enums import EnrollmentStatuses +from lms.djangoapps.mobile_api.utils import API_V1, API_V05, API_V2, API_V3, API_V4 from openedx.core.lib.courses import course_image_url from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration from openedx.features.course_duration_limits.models import CourseDurationLimitConfig @@ -406,6 +409,616 @@ def test_pagination_enrollment(self): assert "next" in response.data["enrollments"] assert "previous" in response.data["enrollments"] + def test_student_dont_have_enrollments(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + expected_result = { + 'configs': { + 'iap_configs': {} + }, + 'user_timezone': 'UTC', + 'enrollments': { + 'next': None, + 'previous': None, + 'count': 0, + 'num_pages': 1, + 'current_page': 1, + 'start': 0, + 'results': [] + } + } + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(expected_result, response.data) + self.assertNotIn('primary', response.data) + + def test_student_have_one_enrollment(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(course.id) + expected_enrollments = { + 'next': None, + 'previous': None, + 'count': 0, + 'num_pages': 1, + 'current_page': 1, + 'start': 0, + 'results': [] + } + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(expected_enrollments, response.data['enrollments']) + self.assertIn('primary', response.data) + self.assertEqual(str(course.id), response.data['primary']['course']['id']) + + def test_student_have_two_enrollments(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + course_first = CourseFactory.create(org="edx", mobile_available=True) + course_second = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(course_first.id) + self.enroll(course_second.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data['enrollments']['results']), 1) + self.assertEqual(response.data['enrollments']['count'], 1) + self.assertEqual(response.data['enrollments']['results'][0]['course']['id'], str(course_first.id)) + self.assertIn('primary', response.data) + self.assertEqual(response.data['primary']['course']['id'], str(course_second.id)) + + def test_student_have_more_then_ten_enrollments(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + courses = [CourseFactory.create(org="edx", mobile_available=True) for _ in range(15)] + for course in courses: + self.enroll(course.id) + latest_enrolment = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(latest_enrolment.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['enrollments']['count'], 15) + self.assertEqual(response.data['enrollments']['num_pages'], 3) + self.assertEqual(len(response.data['enrollments']['results']), 5) + self.assertIn('primary', response.data) + self.assertEqual(response.data['primary']['course']['id'], str(latest_enrolment.id)) + + def test_student_have_progress_in_old_course_and_enroll_newest_course(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + old_course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(old_course.id) + courses = [CourseFactory.create(org="edx", mobile_available=True) for _ in range(5)] + for course in courses: + self.enroll(course.id) + new_course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(new_course.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['enrollments']['count'], 6) + self.assertEqual(len(response.data['enrollments']['results']), 5) + # check that we have the new_course in primary section + self.assertIn('primary', response.data) + self.assertEqual(response.data['primary']['course']['id'], str(new_course.id)) + + # doing progress in the old_course + StudentModule.objects.create( + student=self.user, + course_id=old_course.id, + module_state_key=old_course.location, + ) + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['enrollments']['count'], 6) + self.assertEqual(len(response.data['enrollments']['results']), 5) + # check that now we have the old_course in primary section + self.assertIn('primary', response.data) + self.assertEqual(response.data['primary']['course']['id'], str(old_course.id)) + + # enroll to the newest course + newest_course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(newest_course.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['enrollments']['count'], 7) + self.assertEqual(len(response.data['enrollments']['results']), 5) + # check that now we have the newest_course in primary section + self.assertIn('primary', response.data) + self.assertEqual(response.data['primary']['course']['id'], str(newest_course.id)) + + def test_student_enrolled_only_not_mobile_available_courses(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + courses = [CourseFactory.create(org="edx", mobile_available=False) for _ in range(3)] + for course in courses: + self.enroll(course.id) + expected_result = { + "configs": { + "iap_configs": {} + }, + "user_timezone": "UTC", + "enrollments": { + "next": None, + "previous": None, + "count": 0, + "num_pages": 1, + "current_page": 1, + "start": 0, + "results": [] + } + } + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(expected_result, response.data) + self.assertNotIn('primary', response.data) + + def test_do_progress_in_not_mobile_available_course(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + not_mobile_available = CourseFactory.create(org="edx", mobile_available=False) + self.enroll(not_mobile_available.id) + courses = [CourseFactory.create(org="edx", mobile_available=True) for _ in range(5)] + for course in courses: + self.enroll(course.id) + new_course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(new_course.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['enrollments']['count'], 5) + self.assertEqual(len(response.data['enrollments']['results']), 5) + # check that we have the new_course in primary section + self.assertIn('primary', response.data) + self.assertEqual(response.data['primary']['course']['id'], str(new_course.id)) + + # doing progress in the not_mobile_available course + StudentModule.objects.create( + student=self.user, + course_id=not_mobile_available.id, + module_state_key=not_mobile_available.location, + ) + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['enrollments']['count'], 5) + self.assertEqual(len(response.data['enrollments']['results']), 5) + # check that we have the new_course in primary section in the same way + self.assertIn('primary', response.data) + self.assertEqual(response.data['primary']['course']['id'], str(new_course.id)) + + def test_pagination_for_user_enrollments_api_v4(self): + """ + Tests `UserCourseEnrollmentsV4Pagination`, api_version == v4. + """ + self.login() + courses = [CourseFactory.create(org="my_org", mobile_available=True) for _ in range(15)] + for course in courses: + self.enroll(course.id) + + response = self.api_response(api_version=API_V4) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['enrollments']['count'], 14) + self.assertEqual(response.data['enrollments']['num_pages'], 3) + self.assertEqual(response.data['enrollments']['current_page'], 1) + self.assertEqual(len(response.data['enrollments']['results']), 5) + self.assertIn('next', response.data['enrollments']) + self.assertIn('previous', response.data['enrollments']) + self.assertIn('primary', response.data) + + def test_course_status_in_primary_obj_when_student_doesnt_have_progress(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(course.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['primary']['course_status'], None) + + @patch('lms.djangoapps.mobile_api.users.serializers.get_key_to_last_completed_block') + def test_course_status_in_primary_obj_when_student_have_progress( + self, + get_last_completed_block_mock: MagicMock, + ): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + # create test course structure + course = CourseFactory.create(org="edx", mobile_available=True) + section = BlockFactory.create( + parent=course, + category="chapter", + display_name="section", + ) + subsection = BlockFactory.create( + parent=section, + category="sequential", + display_name="subsection", + ) + vertical = BlockFactory.create( + parent=subsection, + category="vertical", + display_name="test unit", + ) + problem = BlockFactory.create( + parent=vertical, + category="problem", + display_name="problem", + ) + self.enroll(course.id) + get_last_completed_block_mock.return_value = problem.location + expected_course_status = { + 'last_visited_module_id': str(subsection.location), + 'last_visited_module_path': [ + str(course.location), + str(section.location), + str(subsection.location), + ], + 'last_visited_block_id': str(problem.location), + 'last_visited_unit_display_name': vertical.display_name, + } + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['primary']['course_status'], expected_course_status) + get_last_completed_block_mock.assert_called_once_with(self.user, course.id) + + def test_user_enrollment_api_v4_in_progress_status(self): + """ + Testing + """ + self.login() + old_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + actual_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=self.NEXT_WEEK + ) + infinite_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=None + ) + + self.enroll(old_course.id) + self.enroll(actual_course.id) + self.enroll(infinite_course.id) + + response = self.api_response(api_version=API_V4, data={'status': EnrollmentStatuses.IN_PROGRESS.value}) + enrollments = response.data['enrollments'] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollments['count'], 2) + self.assertEqual(enrollments['results'][1]['course']['id'], str(actual_course.id)) + self.assertEqual(enrollments['results'][0]['course']['id'], str(infinite_course.id)) + self.assertNotIn('primary', response.data) + + def test_user_enrollment_api_v4_completed_status(self): + """ + Testing + """ + self.login() + old_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + actual_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=self.NEXT_WEEK + ) + infinite_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=None + ) + GeneratedCertificateFactory.create( + user=self.user, + course_id=infinite_course.id, + status=CertificateStatuses.downloadable, + mode='verified', + ) + + self.enroll(old_course.id) + self.enroll(actual_course.id) + self.enroll(infinite_course.id) + + response = self.api_response(api_version=API_V4, data={'status': EnrollmentStatuses.COMPLETED.value}) + enrollments = response.data['enrollments'] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollments['count'], 1) + self.assertEqual(enrollments['results'][0]['course']['id'], str(infinite_course.id)) + self.assertNotIn('primary', response.data) + + def test_user_enrollment_api_v4_expired_status(self): + """ + Testing + """ + self.login() + old_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + actual_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=self.NEXT_WEEK + ) + infinite_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=None + ) + self.enroll(old_course.id) + self.enroll(actual_course.id) + self.enroll(infinite_course.id) + + response = self.api_response(api_version=API_V4, data={'status': EnrollmentStatuses.EXPIRED.value}) + enrollments = response.data['enrollments'] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollments['count'], 1) + self.assertEqual(enrollments['results'][0]['course']['id'], str(old_course.id)) + self.assertNotIn('primary', response.data) + + def test_user_enrollment_api_v4_expired_course_with_certificate(self): + """ + Testing that the API returns a course with + an expiration date in the past if the user has a certificate for this course. + """ + self.login() + expired_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + expired_course_with_cert = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + GeneratedCertificateFactory.create( + user=self.user, + course_id=expired_course_with_cert.id, + status=CertificateStatuses.downloadable, + mode='verified', + ) + + self.enroll(expired_course_with_cert.id) + self.enroll(expired_course.id) + + response = self.api_response(api_version=API_V4, data={'status': EnrollmentStatuses.COMPLETED.value}) + enrollments = response.data['enrollments'] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollments['count'], 1) + self.assertEqual(enrollments['results'][0]['course']['id'], str(expired_course_with_cert.id)) + self.assertNotIn('primary', response.data) + + def test_user_enrollment_api_v4_status_all(self): + """ + Testing + """ + self.login() + old_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + actual_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=self.NEXT_WEEK + ) + infinite_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=None + ) + GeneratedCertificateFactory.create( + user=self.user, + course_id=infinite_course.id, + status=CertificateStatuses.downloadable, + mode='verified', + ) + + self.enroll(old_course.id) + self.enroll(actual_course.id) + self.enroll(infinite_course.id) + + response = self.api_response(api_version=API_V4, data={'status': EnrollmentStatuses.ALL.value}) + enrollments = response.data['enrollments'] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollments['count'], 3) + self.assertEqual(enrollments['results'][0]['course']['id'], str(infinite_course.id)) + self.assertEqual(enrollments['results'][1]['course']['id'], str(actual_course.id)) + self.assertEqual(enrollments['results'][2]['course']['id'], str(old_course.id)) + self.assertNotIn('primary', response.data) + + def test_response_contains_primary_enrollment_assignments_info(self): + self.login() + course = CourseFactory.create(org='edx', mobile_available=True) + self.enroll(course.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('course_assignments', response.data['primary']) + self.assertIn('past_assignments', response.data['primary']['course_assignments']) + self.assertIn('future_assignments', response.data['primary']['course_assignments']) + self.assertListEqual(response.data['primary']['course_assignments']['past_assignments'], []) + self.assertListEqual(response.data['primary']['course_assignments']['future_assignments'], []) + + @patch('lms.djangoapps.courseware.courses.get_course_assignments', return_value=[]) + def test_course_progress_in_primary_enrollment_with_no_assignments( + self, + get_course_assignment_mock: MagicMock, + ) -> None: + self.login() + course = CourseFactory.create(org='edx', mobile_available=True) + self.enroll(course.id) + expected_course_progress = {'total_assignments_count': 0, 'assignments_completed': 0} + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('course_progress', response.data['primary']) + self.assertDictEqual(response.data['primary']['course_progress'], expected_course_progress) + + @patch( + 'lms.djangoapps.mobile_api.users.serializers.CourseEnrollmentSerializerModifiedForPrimary' + '.get_course_assignments' + ) + @patch('lms.djangoapps.courseware.courses.get_course_assignments') + def test_course_progress_in_primary_enrollment_with_assignments( + self, + get_course_assignment_mock: MagicMock, + assignments_mock: MagicMock, + ) -> None: + self.login() + course = CourseFactory.create(org='edx', mobile_available=True) + self.enroll(course.id) + course_assignments_mock = [ + Mock(complete=False), Mock(complete=False), Mock(complete=True), Mock(complete=True), Mock(complete=True) + ] + get_course_assignment_mock.return_value = course_assignments_mock + student_assignments_mock = { + 'future_assignments': [], + 'past_assignments': [], + } + assignments_mock.return_value = student_assignments_mock + expected_course_progress = {'total_assignments_count': 5, 'assignments_completed': 3} + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('course_progress', response.data['primary']) + self.assertDictEqual(response.data['primary']['course_progress'], expected_course_progress) + + @patch('lms.djangoapps.courseware.courses.get_course_assignments') + def test_course_progress_for_secondary_enrollments_no_query_param( + self, + get_course_assignment_mock: MagicMock, + ) -> None: + self.login() + courses = [CourseFactory.create(org='edx', mobile_available=True) for _ in range(5)] + for course in courses: + self.enroll(course.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for enrollment in response.data['enrollments']['results']: + self.assertNotIn('course_progress', enrollment) + + @patch('lms.djangoapps.courseware.courses.get_course_assignments') + def test_course_progress_for_secondary_enrollments_with_query_param( + self, + get_course_assignment_mock: MagicMock, + ) -> None: + self.login() + courses = [CourseFactory.create(org='edx', mobile_available=True) for _ in range(5)] + for course in courses: + self.enroll(course.id) + expected_course_progress = {'total_assignments_count': 0, 'assignments_completed': 0} + + response = self.api_response(api_version=API_V4, data={'requested_fields': 'course_progress'}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for enrollment in response.data['enrollments']['results']: + self.assertIn('course_progress', enrollment) + self.assertDictEqual(enrollment['course_progress'], expected_course_progress) + + @patch( + 'lms.djangoapps.mobile_api.users.serializers.CourseEnrollmentSerializerModifiedForPrimary' + '.get_course_assignments' + ) + @patch('lms.djangoapps.courseware.courses.get_course_assignments') + def test_course_progress_for_secondary_enrollments_with_query_param_and_assignments( + self, + get_course_assignment_mock: MagicMock, + assignments_mock: MagicMock, + ) -> None: + self.login() + courses = [CourseFactory.create(org='edx', mobile_available=True) for _ in range(2)] + for course in courses: + self.enroll(course.id) + course_assignments_mock = [ + Mock(complete=False), Mock(complete=False), Mock(complete=True), Mock(complete=True), Mock(complete=True) + ] + get_course_assignment_mock.return_value = course_assignments_mock + student_assignments_mock = { + 'future_assignments': [], + 'past_assignments': [], + } + assignments_mock.return_value = student_assignments_mock + expected_course_progress = {'total_assignments_count': 5, 'assignments_completed': 3} + + response = self.api_response(api_version=API_V4, data={'requested_fields': 'course_progress'}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('course_progress', response.data['primary']) + self.assertDictEqual(response.data['primary']['course_progress'], expected_course_progress) + self.assertIn('course_progress', response.data['enrollments']['results'][0]) + self.assertDictEqual(response.data['enrollments']['results'][0]['course_progress'], expected_course_progress) + @override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) class TestUserEnrollmentCertificates(UrlResetMixin, MobileAPITestCase, MilestonesTestCaseMixin): diff --git a/lms/djangoapps/mobile_api/users/views.py b/lms/djangoapps/mobile_api/users/views.py index 049678dcd7ba..d959e188b4ee 100644 --- a/lms/djangoapps/mobile_api/users/views.py +++ b/lms/djangoapps/mobile_api/users/views.py @@ -4,13 +4,15 @@ import logging +from functools import cached_property +from typing import Optional from completion.exceptions import UnavailableCompletionData from completion.utilities import get_key_to_last_completed_block from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.contrib.auth.signals import user_logged_in from django.db import transaction -from django.shortcuts import redirect +from django.shortcuts import get_object_or_404, redirect from django.utils import dateparse from django.utils.decorators import method_decorator from opaque_keys import InvalidKeyError @@ -26,19 +28,27 @@ from common.djangoapps.student.models import CourseEnrollment, User # lint-amnesty, pylint: disable=reimported from lms.djangoapps.courseware.access import is_mobile_available_for_user from lms.djangoapps.courseware.access_utils import ACCESS_GRANTED +from lms.djangoapps.courseware.context_processor import get_user_timezone_or_last_seen_timezone_or_utc from lms.djangoapps.courseware.courses import get_current_child from lms.djangoapps.courseware.model_data import FieldDataCache from lms.djangoapps.courseware.block_render import get_block_for_descriptor +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 +from lms.djangoapps.mobile_api.utils import API_V1, API_V05, API_V2, API_V3, API_V4 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 from .. import errors from ..decorators import mobile_course_access, mobile_view -from .serializers import CourseEnrollmentSerializer, CourseEnrollmentSerializerv05, UserSerializer +from .enums import EnrollmentStatuses +from .serializers import ( + CourseEnrollmentSerializer, + CourseEnrollmentSerializerModifiedForPrimary, + CourseEnrollmentSerializerv05, + UserSerializer, +) log = logging.getLogger(__name__) @@ -263,6 +273,10 @@ class UserCourseEnrollmentsList(generics.ListAPIView): An additional attribute "expiration" has been added to the response, which lists the date when access to the course will expire or null if it doesn't expire. + In v4 we added to the response primary object. Primary object contains the latest user's enrollment + or course where user has the latest progress. Primary object has been cut from user's + enrolments array and inserted into separated section with key `primary`. + **Example Request** GET /api/mobile/v1/users/{username}/course_enrollments/ @@ -312,8 +326,12 @@ class UserCourseEnrollmentsList(generics.ListAPIView): * mode: The type of certificate registration for this course (honor or certified). * url: URL to the downloadable version of the certificate, if exists. + * course_progress: Contains information about how many assignments are in the course + and how many assignments the student has completed. + * total_assignments_count: Total course's assignments count. + * assignments_completed: Assignments witch the student has completed. """ - queryset = CourseEnrollment.objects.all() + lookup_field = 'username' # In Django Rest Framework v3, there is a default pagination @@ -332,7 +350,10 @@ def is_org(self, check_org, course_org): def get_serializer_context(self): context = super().get_serializer_context() + requested_fields = self.request.GET.get('requested_fields', '') + context['api_version'] = self.kwargs.get('api_version') + context['requested_fields'] = requested_fields.split(',') return context def get_serializer_class(self): @@ -341,47 +362,142 @@ def get_serializer_class(self): return CourseEnrollmentSerializerv05 return CourseEnrollmentSerializer - def get_queryset(self): + @cached_property + def queryset_for_user(self): + """ + Find and return the list of course enrollments for the user. + + In v4 added filtering by statuses. + """ api_version = self.kwargs.get('api_version') - enrollments = self.queryset.filter( - user__username=self.kwargs['username'], + status = self.request.GET.get('status') + username = self.kwargs['username'] + + queryset = CourseEnrollment.objects.all().select_related('course', 'user').filter( + user__username=username, is_active=True - ).order_by('created').reverse() - org = self.request.query_params.get('org', None) + ).order_by('-created') + + if api_version == API_V4 and status in EnrollmentStatuses.values(): + if status == EnrollmentStatuses.IN_PROGRESS.value: + queryset = queryset.in_progress(username=username, time_zone=self.user_timezone) + elif status == EnrollmentStatuses.COMPLETED.value: + queryset = queryset.completed(username=username) + elif status == EnrollmentStatuses.EXPIRED.value: + queryset = queryset.expired(username=username, time_zone=self.user_timezone) + + return queryset + + def get_queryset(self): + api_version = self.kwargs.get('api_version') + status = self.request.GET.get('status') + mobile_available = self.get_same_org_mobile_available_enrollments() - same_org = ( - enrollment for enrollment in enrollments - if enrollment.course_overview and self.is_org(org, enrollment.course_overview.org) - ) - mobile_available = ( - enrollment for enrollment in same_org - if is_mobile_available_for_user(self.request.user, enrollment.course_overview) - ) not_duration_limited = ( enrollment for enrollment in mobile_available if check_course_expired(self.request.user, enrollment.course) == ACCESS_GRANTED ) + if api_version == API_V4 and status not in EnrollmentStatuses.values(): + primary_enrollment_obj = self.get_primary_enrollment_by_latest_enrollment_or_progress() + if primary_enrollment_obj: + mobile_available.remove(primary_enrollment_obj) + if api_version == API_V05: # for v0.5 don't return expired courses return list(not_duration_limited) else: # return all courses, with associated expiration - return list(mobile_available) + return mobile_available + + def get_same_org_mobile_available_enrollments(self) -> list[CourseEnrollment]: + """ + Gets list with `CourseEnrollment` for mobile available courses. + """ + org = self.request.query_params.get('org', None) + + same_org = ( + enrollment for enrollment in self.queryset_for_user + if enrollment.course_overview and self.is_org(org, enrollment.course_overview.org) + ) + mobile_available = ( + enrollment for enrollment in same_org + if is_mobile_available_for_user(self.request.user, enrollment.course_overview) + ) + return list(mobile_available) def list(self, request, *args, **kwargs): response = super().list(request, *args, **kwargs) api_version = self.kwargs.get('api_version') + status = self.request.GET.get('status') - if api_version in (API_V2, API_V3): + if api_version in (API_V2, API_V3, API_V4): enrollment_data = { 'configs': MobileConfig.get_structured_configs(), + 'user_timezone': str(self.user_timezone), 'enrollments': response.data } + if api_version == API_V4 and status not in EnrollmentStatuses.values(): + primary_enrollment_obj = self.get_primary_enrollment_by_latest_enrollment_or_progress() + if primary_enrollment_obj: + serializer = CourseEnrollmentSerializerModifiedForPrimary( + primary_enrollment_obj, + context=self.get_serializer_context(), + ) + enrollment_data.update({'primary': serializer.data}) + return Response(enrollment_data) return response + @cached_property + def user_timezone(self): + """ + Get the user's timezone. + """ + return get_user_timezone_or_last_seen_timezone_or_utc(self.get_user()) + + def get_user(self) -> User: + """ + Get user object by username. + """ + return get_object_or_404(User, username=self.kwargs['username']) + + def get_primary_enrollment_by_latest_enrollment_or_progress(self) -> Optional[CourseEnrollment]: + """ + Gets primary enrollment obj by latest enrollment or latest progress on the course. + """ + mobile_available = self.get_same_org_mobile_available_enrollments() + if not mobile_available: + return None + + mobile_available_course_ids = [enrollment.course_id for enrollment in mobile_available] + + latest_enrollment = self.queryset_for_user.filter( + course__id__in=mobile_available_course_ids + ).order_by('-created').first() + + if not latest_enrollment: + return None + + latest_progress = StudentModule.objects.filter( + student__username=self.kwargs['username'], + course_id__in=mobile_available_course_ids, + ).order_by('-modified').first() + + if not latest_progress: + return latest_enrollment + + enrollment_with_latest_progress = self.queryset_for_user.filter( + course_id=latest_progress.course_id, + user__username=self.kwargs['username'], + ).first() + + if latest_enrollment.created > latest_progress.modified: + return latest_enrollment + else: + return enrollment_with_latest_progress + # pylint: disable=attribute-defined-outside-init @property def paginator(self): @@ -396,6 +512,8 @@ def paginator(self): if self._paginator is None and api_version == API_V3: self._paginator = DefaultPagination() + if self._paginator is None and api_version == API_V4: + self._paginator = UserCourseEnrollmentsV4Pagination() return self._paginator @@ -410,3 +528,11 @@ def my_user_info(request, api_version): # updating it from the oauth2 related code is too complex user_logged_in.send(sender=User, user=request.user, request=request) return redirect("user-detail", api_version=api_version, username=request.user.username) + + +class UserCourseEnrollmentsV4Pagination(DefaultPagination): + """ + Pagination for `UserCourseEnrollments` API v4. + """ + page_size = 5 + max_page_size = 50 diff --git a/lms/djangoapps/mobile_api/utils.py b/lms/djangoapps/mobile_api/utils.py index 73a0cfea0827..9204b27ab49b 100644 --- a/lms/djangoapps/mobile_api/utils.py +++ b/lms/djangoapps/mobile_api/utils.py @@ -6,6 +6,7 @@ API_V1 = 'v1' API_V2 = 'v2' API_V3 = 'v3' +API_V4 = 'v4' def parsed_version(version): diff --git a/lms/djangoapps/static_template_view/views.py b/lms/djangoapps/static_template_view/views.py index 01b99d51c861..a788f77a95fd 100644 --- a/lms/djangoapps/static_template_view/views.py +++ b/lms/djangoapps/static_template_view/views.py @@ -5,7 +5,7 @@ # List of valid templates is explicitly managed for (short-term) # security reasons. - +import logging import mimetypes from django.conf import settings @@ -23,6 +23,8 @@ from common.djangoapps.util.views import fix_crum_request from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers +log = logging.getLogger(__name__) + valid_templates = [] if settings.STATIC_GRAB: @@ -122,4 +124,21 @@ def render_429(request, exception=None): # lint-amnesty, pylint: disable=unused @fix_crum_request def render_500(request): - return HttpResponseServerError(render_to_string('static_templates/server-error.html', {}, request=request)) + """ + Render the generic error page when we have an uncaught error. + """ + try: + return HttpResponseServerError(render_to_string('static_templates/server-error.html', {}, request=request)) + except BaseException as e: + # If we can't render the error page, ensure we don't raise another + # exception -- because if we do, we'll probably just end up back + # at the same rendering error. + # + # This is an attempt at working around the recursive error handling issues + # observed in , which + # were triggered by Mako and translation errors. + + log.error("Encountered error while rendering error page.", exc_info=True) + # This message is intentionally hardcoded and does not involve + # any translation, templating, etc. Do not translate. + return HttpResponseServerError("Encountered error while rendering error page.") diff --git a/lms/djangoapps/support/migrations/0006_alter_historicalusersocialauth_extra_data_and_more.py b/lms/djangoapps/support/migrations/0006_alter_historicalusersocialauth_extra_data_and_more.py new file mode 100644 index 000000000000..5f09d0cc493b --- /dev/null +++ b/lms/djangoapps/support/migrations/0006_alter_historicalusersocialauth_extra_data_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 4.2.13 on 2024-06-27 20:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('support', '0005_unique_course_id'), + ] + + operations = [ + migrations.AlterField( + model_name='historicalusersocialauth', + name='extra_data', + field=models.JSONField(default=dict), + ), + migrations.AlterField( + model_name='historicalusersocialauth', + name='id', + field=models.BigIntegerField(auto_created=True, blank=True, db_index=True, verbose_name='ID'), + ), + ] diff --git a/lms/djangoapps/verify_student/api.py b/lms/djangoapps/verify_student/api.py index c974fa0c8e5f..f61b90d682ff 100644 --- a/lms/djangoapps/verify_student/api.py +++ b/lms/djangoapps/verify_student/api.py @@ -1,12 +1,25 @@ """ API module. """ +import logging + from django.conf import settings +from django.contrib.auth import get_user_model from django.utils.translation import gettext as _ +from datetime import datetime +from typing import Optional + from lms.djangoapps.verify_student.emails import send_verification_approved_email +from lms.djangoapps.verify_student.exceptions import VerificationAttemptInvalidStatus +from lms.djangoapps.verify_student.models import VerificationAttempt +from lms.djangoapps.verify_student.statuses import VerificationAttemptStatus from lms.djangoapps.verify_student.tasks import send_verification_status_email +log = logging.getLogger(__name__) + +User = get_user_model() + def send_approval_email(attempt): """ @@ -33,3 +46,82 @@ def send_approval_email(attempt): else: email_context = {'user': attempt.user, 'expiration_datetime': expiration_datetime.strftime("%m/%d/%Y")} send_verification_approved_email(context=email_context) + + +def create_verification_attempt(user: User, name: str, status: str, expiration_datetime: Optional[datetime] = None): + """ + Create a verification attempt. + + This method is intended to be used by IDV implementation plugins to create VerificationAttempt instances. + + Args: + user (User): the user (usually a learner) performing the verification attempt + name (string): the name being ID verified + status (string): the initial status of the verification attempt + expiration_datetime (datetime, optional): When the verification attempt expires. Defaults to None. + + Returns: + id (int): The id of the created VerificationAttempt instance + """ + verification_attempt = VerificationAttempt.objects.create( + user=user, + name=name, + status=status, + expiration_datetime=expiration_datetime, + ) + + return verification_attempt.id + + +def update_verification_attempt( + attempt_id: int, + name: Optional[str] = None, + status: Optional[str] = None, + expiration_datetime: Optional[datetime] = None +): + """ + Update a verification attempt. + + This method is intended to be used by IDV implementation plugins to update VerificationAttempt instances. + + Arguments: + * attempt_id (int): the verification attempt id of the attempt to update + * name (string, optional): the new name being ID verified + * status (string, optional): the new status of the verification attempt + * expiration_datetime (datetime, optional): The new expiration date and time + + Returns: + * None + """ + try: + attempt = VerificationAttempt.objects.get(id=attempt_id) + except VerificationAttempt.DoesNotExist: + log.error( + f'VerificationAttempt with id {attempt_id} was not found ' + f'when updating the attempt to status={status}', + ) + raise + + if name is not None: + attempt.name = name + + if status is not None: + attempt.status = status + + status_list = list(VerificationAttemptStatus) + if status not in status_list: + log.error( + 'Attempted to call update_verification_attempt called with invalid status: %(status)s. ' + 'Status must be one of: %(status_list)s', + { + 'status': status, + 'status_list': VerificationAttempt.STATUS_CHOICES, + }, + ) + raise VerificationAttemptInvalidStatus + + # NOTE: Generally, we only set the expiration date from the time that an IDV attempt is marked approved, + # so we allow expiration_datetime to = None for other status updates (e.g. pending). + attempt.expiration_datetime = expiration_datetime + + attempt.save() diff --git a/lms/djangoapps/verify_student/docs/decisions/0001_extending_identity_verification.rst b/lms/djangoapps/verify_student/docs/decisions/0001_extending_identity_verification.rst new file mode 100644 index 000000000000..08735188fcdc --- /dev/null +++ b/lms/djangoapps/verify_student/docs/decisions/0001_extending_identity_verification.rst @@ -0,0 +1,65 @@ +0001. Extending Identity Verification +##################################### + +Status +****** + +**Accepted** *2024-08-26* + +Context +******* + +The backend implementation of identity verification (IDV) is in the `verify_student Django application`_. The +`verify_student Django application`_ also contains a frontend user experience for performing photo IDV via an +integration with Software Secure. There is also a `React-based implementation of this flow`_ in the +`frontend-app-account MFE`_, so the frontend user experience stored in the `verify_student Django application`_ is often +called the "legacy flow". + +The current architecture of the `verify_student Django application`_ requires that any additional implementations of IDV +are stored in the application. For example, the Software Secure integration is stored in this application even though +it is a custom integration that the Open edX community does not use. + +Different Open edX operators have different IDV needs. There is currently no way to add additional IDV implementations +to the platform without committing them to the core. The `verify_student Django application`_ needs enhanced +extensibility mechanisms to enable per-deployment integration of IDV implementations without modifying the core. + +Decision +******** + +* We will support the integration of additional implementations of IDV through the use of Python plugins into the + platform. +* We will add a ``VerificationAttempt`` model, which will store generic, implementation-agnostic information about an + IDV attempt. +* We will expose a simple Python API to write and update instances of the ``VerificationAttempt`` model. This will + enable plugins to publish information about their IDV attempts to the platform. +* The ``VerificationAttempt`` model will be integrated into the `verify_student Django application`_, particularly into + the `IDVerificationService`_. +* We will emit Open edX events for each status change of a ``VerificationAttempt``. +* We will add an Open edX filter hook to change the URL of the photo IDV frontend. + +Consequences +************ + +* It will become possible for Open edX operators to implement and integrate any additional forms of IDV necessary for + their deployment. +* The `verify_student Django application`_ will contain both concrete implementations of forms of IDV (i.e. manual, SSO, + Software Secure, etc.) and a generic, extensible implementation. The work to deprecate and remove the Software Secure + integration and to transition the other existing forms of IDV (i.e. manual and SSO) to Django plugins will occur + independently of the improvements to extensibility described in this decision. + +Rejected Alternatives +********************* + +We considered introducing a ``fetch_verification_attempts`` filter hook to allow plugins to expose additional +``VerificationAttempts`` to the platform in lieu of an additional model. However, doing database queries via filter +hooks can cause unpredictable performance problems, and this has been a pain point for Open edX. + +References +********** +`[Proposal] Add Extensibility Mechanisms to IDV to Enable Integration of New IDV Vendor Persona `_ +`Add Extensibility Mechanisms to IDV to Enable Integration of New IDV Vendor Persona `_ + +.. _frontend-app-account MFE: https://github.com/openedx/frontend-app-account +.. _IDVerificationService: https://github.com/openedx/edx-platform/blob/master/lms/djangoapps/verify_student/services.py#L55 +.. _React-based implementation of this flow: https://github.com/openedx/frontend-app-account/tree/master/src/id-verification +.. _verify_student Django application: https://github.com/openedx/edx-platform/tree/master/lms/djangoapps/verify_student diff --git a/lms/djangoapps/verify_student/exceptions.py b/lms/djangoapps/verify_student/exceptions.py index 59e7d5623f05..d13e52d3e737 100644 --- a/lms/djangoapps/verify_student/exceptions.py +++ b/lms/djangoapps/verify_student/exceptions.py @@ -5,3 +5,7 @@ class WindowExpiredException(Exception): pass + + +class VerificationAttemptInvalidStatus(Exception): + pass diff --git a/lms/djangoapps/verify_student/management/commands/approve_id_verifications.py b/lms/djangoapps/verify_student/management/commands/approve_id_verifications.py index b87b2eee4559..3a08ede0aaf6 100644 --- a/lms/djangoapps/verify_student/management/commands/approve_id_verifications.py +++ b/lms/djangoapps/verify_student/management/commands/approve_id_verifications.py @@ -8,7 +8,6 @@ import time from pprint import pformat -from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.management.base import BaseCommand, CommandError from lms.djangoapps.verify_student.api import send_approval_email diff --git a/lms/djangoapps/verify_student/migrations/0015_verificationattempt.py b/lms/djangoapps/verify_student/migrations/0015_verificationattempt.py new file mode 100644 index 000000000000..3f01047f9f51 --- /dev/null +++ b/lms/djangoapps/verify_student/migrations/0015_verificationattempt.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.15 on 2024-08-26 14:05 + +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), + ('verify_student', '0014_remove_softwaresecurephotoverification_expiry_date'), + ] + + operations = [ + migrations.CreateModel( + name='VerificationAttempt', + 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')), + ('name', models.CharField(blank=True, max_length=255)), + ('status', models.CharField(choices=[('created', 'created'), ('pending', 'pending'), ('approved', 'approved'), ('denied', 'denied')], max_length=64)), + ('expiration_datetime', models.DateTimeField(blank=True, null=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/lms/djangoapps/verify_student/models.py b/lms/djangoapps/verify_student/models.py index f7750a4cd662..9d2195d1e5b0 100644 --- a/lms/djangoapps/verify_student/models.py +++ b/lms/djangoapps/verify_student/models.py @@ -31,6 +31,7 @@ from django.utils.translation import gettext_lazy from model_utils import Choices from model_utils.models import StatusModel, TimeStampedModel +from lms.djangoapps.verify_student.statuses import VerificationAttemptStatus from opaque_keys.edx.django.models import CourseKeyField from lms.djangoapps.verify_student.ssencrypt import ( @@ -1189,3 +1190,42 @@ class Meta: def __str__(self): return str(self.arguments) + + +class VerificationAttempt(TimeStampedModel): + """ + The model represents impelementation-agnostic information about identity verification (IDV) attempts. + + Plugins that implement forms of IDV can store information about IDV attempts in this model for use across + the platform. + """ + user = models.ForeignKey(User, db_index=True, on_delete=models.CASCADE) + name = models.CharField(blank=True, max_length=255) + + STATUS_CHOICES = [ + VerificationAttemptStatus.CREATED, + VerificationAttemptStatus.PENDING, + VerificationAttemptStatus.APPROVED, + VerificationAttemptStatus.DENIED, + ] + status = models.CharField(max_length=64, choices=[(status, status) for status in STATUS_CHOICES]) + + expiration_datetime = models.DateTimeField( + null=True, + blank=True, + ) + + @property + def updated_at(self): + """Backwards compatibility with existing IDVerification models""" + return self.modified + + @classmethod + def retire_user(cls, user_id): + """ + Retire user as part of GDPR pipeline + + :param user_id: int + """ + verification_attempts = cls.objects.filter(user_id=user_id) + verification_attempts.delete() diff --git a/lms/djangoapps/verify_student/services.py b/lms/djangoapps/verify_student/services.py index bdfa31fee6d6..f1c5543e8536 100644 --- a/lms/djangoapps/verify_student/services.py +++ b/lms/djangoapps/verify_student/services.py @@ -17,7 +17,7 @@ from lms.djangoapps.verify_student.utils import is_verification_expiring_soon from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from .models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification +from .models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification, VerificationAttempt from .utils import most_recent_verification log = logging.getLogger(__name__) @@ -75,7 +75,8 @@ def verifications_for_user(cls, user): Return a list of all verifications associated with the given user. """ verifications = [] - for verification in chain(SoftwareSecurePhotoVerification.objects.filter(user=user).order_by('-created_at'), + for verification in chain(VerificationAttempt.objects.filter(user=user).order_by('-created'), + SoftwareSecurePhotoVerification.objects.filter(user=user).order_by('-created_at'), SSOVerification.objects.filter(user=user).order_by('-created_at'), ManualVerification.objects.filter(user=user).order_by('-created_at')): verifications.append(verification) @@ -92,6 +93,11 @@ def get_verified_user_ids(cls, users): 'created_at__gt': now() - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) } return chain( + VerificationAttempt.objects.filter(**{ + 'user__in': users, + 'status': 'approved', + 'created__gt': now() - timedelta(days=settings.VERIFY_STUDENT["DAYS_GOOD_FOR"]) + }).values_list('user_id', flat=True), SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs).values_list('user_id', flat=True), SSOVerification.objects.filter(**filter_kwargs).values_list('user_id', flat=True), ManualVerification.objects.filter(**filter_kwargs).values_list('user_id', flat=True) @@ -117,11 +123,14 @@ def get_expiration_datetime(cls, user, statuses): 'status__in': statuses, } + id_verifications = VerificationAttempt.objects.filter(**filter_kwargs) photo_id_verifications = SoftwareSecurePhotoVerification.objects.filter(**filter_kwargs) sso_id_verifications = SSOVerification.objects.filter(**filter_kwargs) manual_id_verifications = ManualVerification.objects.filter(**filter_kwargs) - attempt = most_recent_verification((photo_id_verifications, sso_id_verifications, manual_id_verifications)) + attempt = most_recent_verification( + (photo_id_verifications, sso_id_verifications, manual_id_verifications, id_verifications) + ) return attempt and attempt.expiration_datetime @classmethod @@ -242,8 +251,18 @@ def get_verification_details_by_id(cls, attempt_id): """ Returns a verification attempt object by attempt_id If the verification object cannot be found, returns None + + This method does not take into account verifications stored in the + VerificationAttempt model used for pluggable IDV implementations. + + As part of the work to implement pluggable IDV, this method's use + will be deprecated: https://openedx.atlassian.net/browse/OSPR-1011 """ verification = None + + # This does not look at the VerificationAttempt model since the provided id would become + # ambiguous between tables. The verification models in this list all inherit from the same + # base class and share the same id space. verification_models = [ SoftwareSecurePhotoVerification, SSOVerification, diff --git a/lms/djangoapps/verify_student/signals.py b/lms/djangoapps/verify_student/signals.py index d929af68dd06..ae54deb74214 100644 --- a/lms/djangoapps/verify_student/signals.py +++ b/lms/djangoapps/verify_student/signals.py @@ -10,9 +10,9 @@ from xmodule.modulestore.django import SignalHandler, modulestore from common.djangoapps.student.models_api import get_name, get_pending_name_change -from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL +from openedx.core.djangoapps.user_api.accounts.signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC -from .models import SoftwareSecurePhotoVerification, VerificationDeadline +from .models import SoftwareSecurePhotoVerification, VerificationDeadline, VerificationAttempt log = logging.getLogger(__name__) @@ -75,3 +75,9 @@ def send_idv_update(sender, instance, **kwargs): # pylint: disable=unused-argum photo_id_name=instance.name, full_name=full_name ) + + +@receiver(USER_RETIRE_LMS_MISC) +def _listen_for_lms_retire_verification_attempts(sender, **kwargs): # pylint: disable=unused-argument + user = kwargs.get('user') + VerificationAttempt.retire_user(user.id) diff --git a/lms/djangoapps/verify_student/statuses.py b/lms/djangoapps/verify_student/statuses.py new file mode 100644 index 000000000000..41ef381cfe06 --- /dev/null +++ b/lms/djangoapps/verify_student/statuses.py @@ -0,0 +1,22 @@ +""" +Status enums for verify_student. +""" +from enum import StrEnum, auto + + +class VerificationAttemptStatus(StrEnum): + """This class describes valid statuses for a verification attempt to be in.""" + + # This is the initial state of a verification attempt, before a learner has started IDV. + CREATED = auto() + + # A verification attempt is pending when it has been started but has not yet been completed. + PENDING = auto() + + # A verification attempt is approved when it has been approved by some mechanism (e.g. automatic review, manual + # review, etc). + APPROVED = auto() + + # A verification attempt is denied when it has been denied by some mechanism (e.g. automatic review, manual review, + # etc). + DENIED = auto() diff --git a/lms/djangoapps/verify_student/tests/factories.py b/lms/djangoapps/verify_student/tests/factories.py index da35e98cc53f..d7eaeaf30211 100644 --- a/lms/djangoapps/verify_student/tests/factories.py +++ b/lms/djangoapps/verify_student/tests/factories.py @@ -3,7 +3,7 @@ """ from factory.django import DjangoModelFactory -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, SSOVerification, VerificationAttempt class SoftwareSecurePhotoVerificationFactory(DjangoModelFactory): @@ -19,3 +19,8 @@ class Meta: class SSOVerificationFactory(DjangoModelFactory): class Meta(): model = SSOVerification + + +class VerificationAttemptFactory(DjangoModelFactory): + class Meta: + model = VerificationAttempt diff --git a/lms/djangoapps/verify_student/tests/test_api.py b/lms/djangoapps/verify_student/tests/test_api.py index acdebaa70c1c..747c76f82b61 100644 --- a/lms/djangoapps/verify_student/tests/test_api.py +++ b/lms/djangoapps/verify_student/tests/test_api.py @@ -3,14 +3,21 @@ """ from unittest.mock import patch +from datetime import datetime, timezone import ddt from django.conf import settings from django.core import mail from django.test import TestCase from common.djangoapps.student.tests.factories import UserFactory -from lms.djangoapps.verify_student.api import send_approval_email -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification +from lms.djangoapps.verify_student.api import ( + create_verification_attempt, + send_approval_email, + update_verification_attempt, +) +from lms.djangoapps.verify_student.exceptions import VerificationAttemptInvalidStatus +from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationAttempt +from lms.djangoapps.verify_student.statuses import VerificationAttemptStatus @ddt.ddt @@ -18,6 +25,7 @@ class TestSendApprovalEmail(TestCase): """ Test cases for the send_approval_email API method. """ + def setUp(self): super().setUp() @@ -41,3 +49,138 @@ def test_send_approval(self, use_ace): with patch.dict(settings.VERIFY_STUDENT, {'USE_DJANGO_MAIL': use_ace}): send_approval_email(self.attempt) self._assert_verification_approved_email(self.attempt.expiration_datetime) + + +@ddt.ddt +class CreateVerificationAttempt(TestCase): + """ + Test cases for the create_verification_attempt API method. + """ + + def setUp(self): + super().setUp() + + self.user = UserFactory.create() + self.attempt = VerificationAttempt( + user=self.user, + name='Tester McTest', + status=VerificationAttemptStatus.CREATED, + expiration_datetime=datetime(2024, 12, 31, tzinfo=timezone.utc) + ) + self.attempt.save() + + def test_create_verification_attempt(self): + expected_id = 2 + self.assertEqual( + create_verification_attempt( + user=self.user, + name='Tester McTest', + status=VerificationAttemptStatus.CREATED, + expiration_datetime=datetime(2024, 12, 31, tzinfo=timezone.utc) + ), + expected_id + ) + verification_attempt = VerificationAttempt.objects.get(id=expected_id) + + self.assertEqual(verification_attempt.user, self.user) + self.assertEqual(verification_attempt.name, 'Tester McTest') + self.assertEqual(verification_attempt.status, VerificationAttemptStatus.CREATED) + self.assertEqual(verification_attempt.expiration_datetime, datetime(2024, 12, 31, tzinfo=timezone.utc)) + + def test_create_verification_attempt_no_expiration_datetime(self): + expected_id = 2 + self.assertEqual( + create_verification_attempt( + user=self.user, + name='Tester McTest', + status=VerificationAttemptStatus.CREATED, + ), + expected_id + ) + verification_attempt = VerificationAttempt.objects.get(id=expected_id) + + self.assertEqual(verification_attempt.user, self.user) + self.assertEqual(verification_attempt.name, 'Tester McTest') + self.assertEqual(verification_attempt.status, VerificationAttemptStatus.CREATED) + self.assertEqual(verification_attempt.expiration_datetime, None) + + +@ddt.ddt +class UpdateVerificationAttempt(TestCase): + """ + Test cases for the update_verification_attempt API method. + """ + + def setUp(self): + super().setUp() + + self.user = UserFactory.create() + self.attempt = VerificationAttempt( + user=self.user, + name='Tester McTest', + status=VerificationAttemptStatus.CREATED, + expiration_datetime=datetime(2024, 12, 31, tzinfo=timezone.utc) + ) + self.attempt.save() + + @ddt.data( + ('Tester McTest', VerificationAttemptStatus.PENDING, datetime(2024, 12, 31, tzinfo=timezone.utc)), + ('Tester McTest2', VerificationAttemptStatus.APPROVED, datetime(2025, 12, 31, tzinfo=timezone.utc)), + ('Tester McTest3', VerificationAttemptStatus.DENIED, datetime(2026, 12, 31, tzinfo=timezone.utc)), + ) + @ddt.unpack + def test_update_verification_attempt(self, name, status, expiration_datetime): + update_verification_attempt( + attempt_id=self.attempt.id, + name=name, + status=status, + expiration_datetime=expiration_datetime, + ) + + verification_attempt = VerificationAttempt.objects.get(id=self.attempt.id) + + # Values should change as a result of this update. + self.assertEqual(verification_attempt.user, self.user) + self.assertEqual(verification_attempt.name, name) + self.assertEqual(verification_attempt.status, status) + self.assertEqual(verification_attempt.expiration_datetime, expiration_datetime) + + def test_update_verification_attempt_none_values(self): + update_verification_attempt( + attempt_id=self.attempt.id, + name=None, + status=None, + expiration_datetime=None, + ) + + verification_attempt = VerificationAttempt.objects.get(id=self.attempt.id) + + # Values should not change as a result of the values passed in being None, except for expiration_datetime. + self.assertEqual(verification_attempt.user, self.user) + self.assertEqual(verification_attempt.name, self.attempt.name) + self.assertEqual(verification_attempt.status, self.attempt.status) + self.assertEqual(verification_attempt.expiration_datetime, None) + + def test_update_verification_attempt_not_found(self): + self.assertRaises( + VerificationAttempt.DoesNotExist, + update_verification_attempt, + attempt_id=999999, + status=VerificationAttemptStatus.APPROVED, + ) + + @ddt.data( + 'completed', + 'failed', + 'submitted', + 'expired', + ) + def test_update_verification_attempt_invalid(self, status): + self.assertRaises( + VerificationAttemptInvalidStatus, + update_verification_attempt, + attempt_id=self.attempt.id, + name=None, + status=status, + expiration_datetime=None, + ) diff --git a/lms/djangoapps/verify_student/tests/test_services.py b/lms/djangoapps/verify_student/tests/test_services.py index 56f388b7c97e..5351e3ede699 100644 --- a/lms/djangoapps/verify_student/tests/test_services.py +++ b/lms/djangoapps/verify_student/tests/test_services.py @@ -2,8 +2,8 @@ Tests for the service classes in verify_student. """ -from datetime import datetime, timedelta, timezone import itertools +from datetime import datetime, timedelta, timezone from random import randint from unittest.mock import patch @@ -16,10 +16,16 @@ from pytz import utc from common.djangoapps.student.tests.factories import UserFactory -from lms.djangoapps.verify_student.models import ManualVerification, SoftwareSecurePhotoVerification, SSOVerification +from lms.djangoapps.verify_student.models import ( + ManualVerification, + SoftwareSecurePhotoVerification, + SSOVerification, + VerificationAttempt +) from lms.djangoapps.verify_student.services import IDVerificationService from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # 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 FAKE_SETTINGS = { @@ -34,12 +40,15 @@ class TestIDVerificationService(ModuleStoreTestCase): Tests for IDVerificationService. """ - def test_user_is_verified(self): + @ddt.data( + SoftwareSecurePhotoVerification, VerificationAttempt + ) + def test_user_is_verified(self, verification_model): """ Test to make sure we correctly answer whether a user has been verified. """ user = UserFactory.create() - attempt = SoftwareSecurePhotoVerification(user=user) + attempt = verification_model(user=user) attempt.save() # If it's any of these, they're not verified... @@ -49,16 +58,24 @@ def test_user_is_verified(self): assert not IDVerificationService.user_is_verified(user), status attempt.status = "approved" + if verification_model == VerificationAttempt: + attempt.expiration_datetime = now() + timedelta(days=19) + else: + attempt.expiration_date = now() + timedelta(days=19) attempt.save() + assert IDVerificationService.user_is_verified(user), attempt.status - def test_user_has_valid_or_pending(self): + @ddt.data( + SoftwareSecurePhotoVerification, VerificationAttempt + ) + def test_user_has_valid_or_pending(self, verification_model): """ Determine whether we have to prompt this user to verify, or if they've already at least initiated a verification submission. """ user = UserFactory.create() - attempt = SoftwareSecurePhotoVerification(user=user) + attempt = verification_model(user=user) # If it's any of these statuses, they don't have anything outstanding for status in ["created", "ready", "denied"]: @@ -70,6 +87,10 @@ def test_user_has_valid_or_pending(self): # -- must_retry, and submitted both count until we hear otherwise for status in ["submitted", "must_retry", "approved"]: attempt.status = status + if verification_model == VerificationAttempt: + attempt.expiration_datetime = now() + timedelta(days=19) + else: + attempt.expiration_date = now() + timedelta(days=19) attempt.save() assert IDVerificationService.user_has_valid_or_pending(user), status @@ -102,18 +123,22 @@ def test_get_verified_user_ids(self): user_a = UserFactory.create() user_b = UserFactory.create() user_c = UserFactory.create() + user_d = UserFactory.create() user_unverified = UserFactory.create() user_denied = UserFactory.create() + user_denied_b = UserFactory.create() SoftwareSecurePhotoVerification.objects.create(user=user_a, status='approved') ManualVerification.objects.create(user=user_b, status='approved') SSOVerification.objects.create(user=user_c, status='approved') + VerificationAttempt.objects.create(user=user_d, status='approved') SSOVerification.objects.create(user=user_denied, status='denied') + VerificationAttempt.objects.create(user=user_denied_b, status='denied') verified_user_ids = set(IDVerificationService.get_verified_user_ids([ - user_a, user_b, user_c, user_unverified, user_denied + user_a, user_b, user_c, user_d, user_unverified, user_denied ])) - expected_user_ids = {user_a.id, user_b.id, user_c.id} + expected_user_ids = {user_a.id, user_b.id, user_c.id, user_d.id} assert expected_user_ids == verified_user_ids @@ -158,6 +183,23 @@ def test_get_expiration_datetime(self): expiration_datetime = IDVerificationService.get_expiration_datetime(user_a, ['approved']) assert expiration_datetime == newer_record.expiration_datetime + def test_get_expiration_datetime_mixed_models(self): + """ + Test that the latest expiration datetime is returned if there are both instances of + IDVerification models and VerificationAttempt models + """ + user = UserFactory.create() + + SoftwareSecurePhotoVerification.objects.create( + user=user, status='approved', expiration_date=datetime(2021, 11, 12, 0, 0, tzinfo=timezone.utc) + ) + newest = VerificationAttempt.objects.create( + user=user, status='approved', expiration_datetime=datetime(2022, 1, 12, 0, 0, tzinfo=timezone.utc) + ) + + expiration_datetime = IDVerificationService.get_expiration_datetime(user, ['approved']) + assert expiration_datetime == newest.expiration_datetime + @ddt.data( {'status': 'denied', 'error_msg': '[{"generalReasons": ["Name mismatch"]}]'}, {'status': 'approved', 'error_msg': ''}, diff --git a/lms/djangoapps/verify_student/tests/test_signals.py b/lms/djangoapps/verify_student/tests/test_signals.py index fb32edeccde0..8d607988d4b4 100644 --- a/lms/djangoapps/verify_student/tests/test_signals.py +++ b/lms/djangoapps/verify_student/tests/test_signals.py @@ -10,9 +10,20 @@ from common.djangoapps.student.models_api import do_name_change_request from common.djangoapps.student.tests.factories import UserFactory -from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline -from lms.djangoapps.verify_student.signals import _listen_for_course_publish, _listen_for_lms_retire -from lms.djangoapps.verify_student.tests.factories import SoftwareSecurePhotoVerificationFactory +from lms.djangoapps.verify_student.models import ( + SoftwareSecurePhotoVerification, + VerificationDeadline, + VerificationAttempt +) +from lms.djangoapps.verify_student.signals import ( + _listen_for_course_publish, + _listen_for_lms_retire, + _listen_for_lms_retire_verification_attempts +) +from lms.djangoapps.verify_student.tests.factories import ( + SoftwareSecurePhotoVerificationFactory, + VerificationAttemptFactory +) from openedx.core.djangoapps.user_api.accounts.tests.retirement_helpers import fake_completed_retirement 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 @@ -174,3 +185,26 @@ def test_post_save_signal_pending_name(self, mock_signal): photo_id_name=attempt.name, full_name=pending_name_change.new_name ) + + +class RetirementSignalVerificationAttemptsTest(ModuleStoreTestCase): + """ + Tests for the LMS User Retirement signal for Verification Attempts + """ + + def setUp(self): + super().setUp() + self.user = UserFactory.create() + self.other_user = UserFactory.create() + VerificationAttemptFactory.create(user=self.user) + VerificationAttemptFactory.create(user=self.other_user) + + def test_retirement_signal(self): + _listen_for_lms_retire_verification_attempts(sender=self.__class__, user=self.user) + self.assertEqual(len(VerificationAttempt.objects.filter(user=self.user)), 0) + self.assertEqual(len(VerificationAttempt.objects.filter(user=self.other_user)), 1) + + def test_retirement_signal_no_attempts(self): + no_attempt_user = UserFactory.create() + _listen_for_lms_retire_verification_attempts(sender=self.__class__, user=no_attempt_user) + self.assertEqual(len(VerificationAttempt.objects.all()), 2) diff --git a/lms/envs/common.py b/lms/envs/common.py index 3074d958b266..334669215397 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -56,7 +56,10 @@ ENTERPRISE_FULFILLMENT_OPERATOR_ROLE, ENTERPRISE_REPORTING_CONFIG_ADMIN_ROLE, ENTERPRISE_SSO_ORCHESTRATOR_OPERATOR_ROLE, - ENTERPRISE_OPERATOR_ROLE + ENTERPRISE_OPERATOR_ROLE, + SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE, + PROVISIONING_ENTERPRISE_CUSTOMER_ADMIN_ROLE, + PROVISIONING_PENDING_ENTERPRISE_CUSTOMER_ADMIN_ROLE, ) from openedx.core.constants import COURSE_KEY_REGEX, COURSE_KEY_PATTERN, COURSE_ID_PATTERN @@ -1101,6 +1104,11 @@ # 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. +GRADEBOOK_FREEZE_DAYS = 30 + # Used with XQueue XQUEUE_WAITTIME_BETWEEN_REQUESTS = 5 # seconds XQUEUE_INTERFACE = { @@ -2287,7 +2295,6 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring 'openedx.core.djangoapps.safe_sessions.middleware.EmailChangeMiddleware', 'common.djangoapps.student.middleware.UserStandingMiddleware', - 'openedx.core.djangoapps.contentserver.middleware.StaticContentServerMiddleware', # Adds user tags to tracking events # Must go before TrackMiddleware, to get the context set up @@ -3350,9 +3357,6 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # Management of external user ids 'openedx.core.djangoapps.external_user_ids', - # Provides api for Demographics support - 'openedx.core.djangoapps.demographics', - # Management of per-user schedules 'openedx.core.djangoapps.schedules', @@ -3389,6 +3393,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring 'openedx_events', # Learning Core Apps, used by v2 content libraries (content_libraries app) + "openedx_learning.apps.authoring.collections", "openedx_learning.apps.authoring.components", "openedx_learning.apps.authoring.contents", "openedx_learning.apps.authoring.publishing", @@ -3681,6 +3686,9 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring # because that decision might happen in a later config file. (The headers to # allow is an application logic, and not site policy.) CORS_ALLOW_HEADERS = corsheaders_default_headers + ( + 'cache-control', + 'expires', + 'pragma', 'use-jwt-cookie', ) @@ -4295,6 +4303,10 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring } } +# Enable First Purchase Discount offer override +FIRST_PURCHASE_DISCOUNT_OVERRIDE_CODE = '' +FIRST_PURCHASE_DISCOUNT_OVERRIDE_PERCENTAGE = 15 + # E-Commerce API Configuration ECOMMERCE_PUBLIC_URL_ROOT = 'http://localhost:8002' ECOMMERCE_API_URL = 'http://localhost:8002/api/v2' @@ -4678,11 +4690,11 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring 'enterprise_channel_worker', 'enterprise_access_worker', 'enterprise_subsidy_worker', - 'subscriptions_worker' ] # Setting for Open API key and prompts used by edx-enterprise. -OPENAI_API_KEY = '' +CHAT_COMPLETION_API = 'https://example.com/chat/completion' +CHAT_COMPLETION_API_KEY = 'i am a key' LEARNER_ENGAGEMENT_PROMPT_FOR_ACTIVE_CONTRACT = '' LEARNER_ENGAGEMENT_PROMPT_FOR_NON_ACTIVE_CONTRACT = '' LEARNER_PROGRESS_PROMPT_FOR_ACTIVE_CONTRACT = '' @@ -4738,6 +4750,10 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring ENTERPRISE_FULFILLMENT_OPERATOR_ROLE, ENTERPRISE_SSO_ORCHESTRATOR_OPERATOR_ROLE, ], + SYSTEM_ENTERPRISE_PROVISIONING_ADMIN_ROLE: [ + PROVISIONING_ENTERPRISE_CUSTOMER_ADMIN_ROLE, + PROVISIONING_PENDING_ENTERPRISE_CUSTOMER_ADMIN_ROLE, + ], } DATA_CONSENT_SHARE_CACHE_TIMEOUT = 8 * 60 * 60 # 8 hours @@ -5367,21 +5383,10 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring AVAILABLE_DISCUSSION_TOURS = [] -######################## Subscriptions API SETTINGS ######################## -SUBSCRIPTIONS_ROOT_URL = "" -SUBSCRIPTIONS_API_PATH = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscription/" - -SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL = None -SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscribe/" -SUBSCRIPTIONS_MANAGE_SUBSCRIPTION_URL = None -SUBSCRIPTIONS_MINIMUM_PRICE = '$39' -SUBSCRIPTIONS_TRIAL_LENGTH = 7 -SUBSCRIPTIONS_SERVICE_WORKER_USERNAME = 'subscriptions_worker' - ############## NOTIFICATIONS ############## NOTIFICATIONS_EXPIRY = 60 EXPIRED_NOTIFICATIONS_DELETE_BATCH_SIZE = 10000 -NOTIFICATION_CREATION_BATCH_SIZE = 83 +NOTIFICATION_CREATION_BATCH_SIZE = 76 NOTIFICATIONS_DEFAULT_FROM_EMAIL = "no-reply@example.com" NOTIFICATION_TYPE_ICONS = {} DEFAULT_NOTIFICATION_ICON_URL = "" @@ -5460,6 +5465,10 @@ def _should_send_learning_badge_events(settings): 'learning-course-access-role-lifecycle': {'event_key_field': 'course_access_role_data.course_key', 'enabled': False}, }, + 'org.openedx.enterprise.learner_credit_course_enrollment.revoked.v1': { + 'learner-credit-course-enrollment-lifecycle': + {'event_key_field': 'learner_credit_course_enrollment.uuid', 'enabled': False}, + }, # CMS events. These have to be copied over here because cms.common adds some derived entries as well, # and the derivation fails if the keys are missing. If we ever fully decouple the lms and cms settings, # we can remove these. diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 552d3276a5f9..7a06f717996c 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -522,15 +522,9 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing ] course_access_role_removed_event_setting['learning-course-access-role-lifecycle']['enabled'] = True -######################## Subscriptions API SETTINGS ######################## -SUBSCRIPTIONS_ROOT_URL = "http://host.docker.internal:18750" -SUBSCRIPTIONS_API_PATH = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscription/" - -SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL = None -SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscribe/" -SUBSCRIPTIONS_MANAGE_SUBSCRIPTION_URL = None -SUBSCRIPTIONS_MINIMUM_PRICE = '$39' -SUBSCRIPTIONS_TRIAL_LENGTH = 7 +lc_enrollment_revoked_setting = \ + EVENT_BUS_PRODUCER_CONFIG['org.openedx.enterprise.learner_credit_course_enrollment.revoked.v1'] +lc_enrollment_revoked_setting['learner-credit-course-enrollment-lifecycle']['enabled'] = True # API access management API_ACCESS_MANAGER_EMAIL = 'api-access@example.com' @@ -555,6 +549,7 @@ def should_show_debug_toolbar(request): # lint-amnesty, pylint: disable=missing 'http://localhost:1999', # frontend-app-authn 'http://localhost:18450', # frontend-app-support-tools 'http://localhost:1994', # frontend-app-gradebook + 'http://localhost:1996', # frontend-app-learner-dashboard ] diff --git a/lms/envs/production.py b/lms/envs/production.py index 84cbdc420e44..a1acd692f4e1 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -922,7 +922,8 @@ def get_env_setting(setting): ENTERPRISE_CATALOG_INTERNAL_ROOT_URL ) -OPENAI_API_KEY = ENV_TOKENS.get('OPENAI_API_KEY', '') +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', @@ -1062,8 +1063,6 @@ def get_env_setting(setting): 'queue': PROGRAM_CERTIFICATES_ROUTING_KEY}, 'openedx.core.djangoapps.programs.tasks.revoke_program_certificates': { 'queue': PROGRAM_CERTIFICATES_ROUTING_KEY}, - 'openedx.core.djangoapps.programs.tasks.update_certificate_visible_date_on_course_update': { - 'queue': PROGRAM_CERTIFICATES_ROUTING_KEY}, 'openedx.core.djangoapps.programs.tasks.update_certificate_available_date_on_course_update': { 'queue': PROGRAM_CERTIFICATES_ROUTING_KEY}, 'openedx.core.djangoapps.programs.tasks.award_course_certificate': { diff --git a/lms/envs/test.py b/lms/envs/test.py index 3c4bb9564927..a9e8aaf9f2e2 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -650,15 +650,6 @@ SURVEY_REPORT_ENABLE = True ANONYMOUS_SURVEY_REPORT = False -######################## Subscriptions API SETTINGS ######################## -SUBSCRIPTIONS_ROOT_URL = "http://localhost:18750" -SUBSCRIPTIONS_API_PATH = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscription/" - -SUBSCRIPTIONS_LEARNER_HELP_CENTER_URL = None -SUBSCRIPTIONS_BUY_SUBSCRIPTION_URL = f"{SUBSCRIPTIONS_ROOT_URL}/api/v1/stripe-subscribe/" -SUBSCRIPTIONS_MANAGE_SUBSCRIPTION_URL = None -SUBSCRIPTIONS_MINIMUM_PRICE = '$39' -SUBSCRIPTIONS_TRIAL_LENGTH = 7 CSRF_TRUSTED_ORIGINS = ['.example.com'] CSRF_TRUSTED_ORIGINS_WITH_SCHEME = ['https://*.example.com'] diff --git a/lms/static/js/demographics_collection/DemographicsCollectionBanner.jsx b/lms/static/js/demographics_collection/DemographicsCollectionBanner.jsx deleted file mode 100644 index d914d7fb03f7..000000000000 --- a/lms/static/js/demographics_collection/DemographicsCollectionBanner.jsx +++ /dev/null @@ -1,99 +0,0 @@ -/* global gettext */ -import React from 'react'; -import Cookies from 'js-cookie'; -import {DemographicsCollectionModal} from './DemographicsCollectionModal'; - -// eslint-disable-next-line import/prefer-default-export -export class DemographicsCollectionBanner extends React.Component { - constructor(props) { - super(props); - this.state = { - modalOpen: false, - hideBanner: false - }; - - this.dismissBanner = this.dismissBanner.bind(this); - } - - /** - * Utility function that controls hiding the CTA from the Course Dashboard where appropriate. - * This can be called one of two ways - when a user clicks the "dismiss" button on the CTA - * itself, or when the learner completes all of the questions within the modal. - * - * The dismiss button itself is nested inside of an , so we need to call stopPropagation() - * here to prevent the Modal from _also_ opening when the Dismiss button is clicked. - */ - async dismissBanner(e) { - // Since this function also doubles as a callback in the Modal, we check if e is null/undefined - // before calling stopPropagation() - if (e) { - e.stopPropagation(); - } - - const requestOptions = { - method: 'PATCH', - credentials: 'include', - headers: { - 'Content-Type': 'application/json', - 'X-CSRFTOKEN': Cookies.get('csrftoken'), - }, - body: JSON.stringify({ - show_call_to_action: false, - }) - }; - - await fetch(`${this.props.lmsRootUrl}/api/demographics/v1/demographics/status/`, requestOptions); - // No matter what the response is from the API call we always allow the learner to dismiss the - // banner when clicking the dismiss button - this.setState({hideBanner: true}); - } - - render() { - if (!(this.state.hideBanner)) { - return ( -