Merge branch 'master' of github.com:openedx/edx-platform into pooja/convert-warning-back-to-html
This commit is contained in:
7
.github/ISSUE_TEMPLATE/config.yml
vendored
7
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,3 +1,10 @@
|
||||
## This configuration file overrides the inherited configuration file defined
|
||||
## in openedx/.github/.github/ISSUE_TEMPLATE because this repo currently does
|
||||
## not have Issues turned on, so we create this override to *only* show DEPR
|
||||
## issues to users creating Issues. Once Issues are turned on and the repo is
|
||||
## ready to accept Issues of all types, this file must be deleted so inheritance
|
||||
## of standard openedx configuration works properly.
|
||||
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Open edX Community Support
|
||||
|
||||
7
.github/ISSUE_TEMPLATE/depr-ticket.yml
vendored
7
.github/ISSUE_TEMPLATE/depr-ticket.yml
vendored
@@ -1,3 +1,10 @@
|
||||
## This configuration file overrides the inherited configuration file defined
|
||||
## in openedx/.github/.github/ISSUE_TEMPLATE because this repo currently does
|
||||
## not have Issues turned on, so we create this override to *only* show DEPR
|
||||
## issues to users creating Issues. Once Issues are turned on and the repo is
|
||||
## ready to accept Issues of all types, this file must be deleted so inheritance
|
||||
## of standard openedx configuration works properly.
|
||||
|
||||
name: Deprecation (DEPR) Ticket
|
||||
description: Per OEP-21, use this template to begin the technology deprecation process.
|
||||
title: "[DEPR]: <Technology Name>"
|
||||
|
||||
34
.github/actions/unit-tests/action.yml
vendored
Normal file
34
.github/actions/unit-tests/action.yml
vendored
Normal file
@@ -0,0 +1,34 @@
|
||||
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 }}
|
||||
|
||||
- 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@v2
|
||||
with:
|
||||
name: pytest-warnings-json
|
||||
path: |
|
||||
test_root/log/pytest_warnings*.json
|
||||
49
.github/actions/verify-tests-count/action.yml
vendored
Normal file
49
.github/actions/verify-tests-count/action.yml
vendored
Normal file
@@ -0,0 +1,49 @@
|
||||
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 --collect-only --ds=cms.envs.test cms/ -q | head -n -2 | wc -l)" >> $GITHUB_ENV
|
||||
echo "root_lms_unit_tests_count=$(pytest --collect-only --ds=lms.envs.test lms/ openedx/ common/djangoapps/ common/lib/ -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 --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 --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
|
||||
10
.github/pull_request_template.md
vendored
10
.github/pull_request_template.md
vendored
@@ -1,10 +1,10 @@
|
||||
<!--
|
||||
|
||||
🍁🍁
|
||||
🍁🍁🍁🍁 🍁 Note: the Maple master branch has been created. Please consider whether your change
|
||||
🍁🍁🍁🍁 should also be applied to Maple. If so, make another pull request against the
|
||||
🍁🍁🍁🍁 open-release/maple.master branch, or ping @nedbat for help or questions.
|
||||
🍁🍁
|
||||
🌰🌰
|
||||
🌰🌰🌰🌰 🌰 Note: the Nutmeg master branch has been created. Please consider whether your change
|
||||
🌰🌰🌰🌰 should also be applied to Nutmeg. If so, make another pull request against the
|
||||
🌰🌰🌰🌰 open-release/nutmeg.master branch, or ping @nedbat for help or questions.
|
||||
🌰🌰
|
||||
|
||||
Please give your pull request a short but descriptive title.
|
||||
Use conventional commits to separate and summarize commits logically:
|
||||
|
||||
2
.github/workflows/pylint-checks.yml
vendored
2
.github/workflows/pylint-checks.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
- module-name: lms-2
|
||||
path: "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/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/envs/ lms/lib/ lms/tests.py"
|
||||
- module-name: openedx-1
|
||||
path: "openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/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/coursegraph/ 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/kafka_consumer/ openedx/core/djangoapps/course_live/"
|
||||
path: "openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/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/"
|
||||
- module-name: openedx-2
|
||||
path: "openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/self_paced/ 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/"
|
||||
- module-name: common
|
||||
|
||||
3
.github/workflows/unit-test-shards.json
vendored
3
.github/workflows/unit-test-shards.json
vendored
@@ -99,7 +99,6 @@
|
||||
"openedx/core/djangoapps/course_apps/",
|
||||
"openedx/core/djangoapps/course_date_signals/",
|
||||
"openedx/core/djangoapps/course_groups/",
|
||||
"openedx/core/djangoapps/coursegraph/",
|
||||
"openedx/core/djangoapps/courseware_api/",
|
||||
"openedx/core/djangoapps/crawlers/",
|
||||
"openedx/core/djangoapps/credentials/",
|
||||
@@ -181,7 +180,6 @@
|
||||
"openedx/core/djangoapps/course_apps/",
|
||||
"openedx/core/djangoapps/course_date_signals/",
|
||||
"openedx/core/djangoapps/course_groups/",
|
||||
"openedx/core/djangoapps/coursegraph/",
|
||||
"openedx/core/djangoapps/courseware_api/",
|
||||
"openedx/core/djangoapps/crawlers/",
|
||||
"openedx/core/djangoapps/credentials/",
|
||||
@@ -240,6 +238,7 @@
|
||||
"paths": [
|
||||
"cms/djangoapps/api/",
|
||||
"cms/djangoapps/cms_user_tasks/",
|
||||
"cms/djangoapps/coursegraph/",
|
||||
"cms/djangoapps/course_creators/",
|
||||
"cms/djangoapps/export_course_metadata/",
|
||||
"cms/djangoapps/maintenance/",
|
||||
|
||||
113
.github/workflows/unit-tests-gh-hosted.yml
vendored
Normal file
113
.github/workflows/unit-tests-gh-hosted.yml
vendored
Normal file
@@ -0,0 +1,113 @@
|
||||
name: unit-tests-gh-hosted
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- open-release/lilac.master
|
||||
|
||||
jobs:
|
||||
run-test:
|
||||
if: github.repository != 'openedx/edx-platform' && github.repository != 'edx/edx-platform-private'
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: [ '3.8' ]
|
||||
django-version: [ "3.2" ]
|
||||
shard_name: [
|
||||
"lms-1",
|
||||
"lms-2",
|
||||
"lms-3",
|
||||
"lms-4",
|
||||
"lms-5",
|
||||
"lms-6",
|
||||
"openedx-1",
|
||||
"openedx-2",
|
||||
"openedx-3",
|
||||
"openedx-4",
|
||||
"cms-1",
|
||||
"cms-2",
|
||||
"common-1",
|
||||
"common-2",
|
||||
"common-3",
|
||||
]
|
||||
name: gh-hosted-python-${{ matrix.python-version }},django-${{ matrix.django-version }},${{ matrix.shard_name }}
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Install Required System Packages
|
||||
run: sudo apt-get update && sudo apt-get install libxmlsec1-dev lynx
|
||||
|
||||
- name: Start MongoDB
|
||||
uses: supercharge/mongodb-github-action@1.7.0
|
||||
with:
|
||||
mongodb-version: 4.4
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Get pip cache dir
|
||||
id: pip-cache-dir
|
||||
run: |
|
||||
echo "::set-output name=dir::$(pip cache dir)"
|
||||
|
||||
- name: Cache pip dependencies
|
||||
id: cache-dependencies
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ steps.pip-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements/edx/development.txt') }}
|
||||
restore-keys: ${{ runner.os }}-pip-
|
||||
|
||||
- name: Install Required Python Dependencies
|
||||
run: |
|
||||
pip install -r requirements/pip.txt
|
||||
pip install -r requirements/edx/development.txt --src ${{ runner.temp }}
|
||||
pip install "django~=${{ matrix.django-version }}.0"
|
||||
|
||||
- name: Setup and run tests
|
||||
uses: ./.github/actions/unit-tests
|
||||
|
||||
collect-and-verify:
|
||||
if: github.repository != 'openedx/edx-platform' && github.repository != 'edx/edx-platform-private'
|
||||
runs-on: ubuntu-20.04
|
||||
strategy:
|
||||
matrix:
|
||||
python-version: [ '3.8' ]
|
||||
django-version: [ "3.2" ]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Install Required System Packages
|
||||
run: sudo apt-get update && sudo apt-get install libxmlsec1-dev
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v2
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
- name: Get pip cache dir
|
||||
id: pip-cache-dir
|
||||
run: |
|
||||
echo "::set-output name=dir::$(pip cache dir)"
|
||||
|
||||
- name: Cache pip dependencies
|
||||
id: cache-dependencies
|
||||
uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ steps.pip-cache-dir.outputs.dir }}
|
||||
key: ${{ runner.os }}-pip-${{ hashFiles('requirements/edx/development.txt') }}
|
||||
restore-keys: ${{ runner.os }}-pip-
|
||||
|
||||
- name: Install Required Python Dependencies
|
||||
run: |
|
||||
pip install -r requirements/pip.txt
|
||||
pip install -r requirements/edx/development.txt --src ${{ runner.temp }}
|
||||
pip install "django~=${{ matrix.django-version }}.0"
|
||||
|
||||
- name: verify unit tests count
|
||||
uses: ./.github/actions/verify-tests-count
|
||||
32
.github/workflows/unit-tests.yml
vendored
32
.github/workflows/unit-tests.yml
vendored
@@ -8,6 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
run-tests:
|
||||
if: github.repository == 'openedx/edx-platform' || github.repository == 'edx/edx-platform-private'
|
||||
runs-on: [ edx-platform-runner ]
|
||||
strategy:
|
||||
matrix:
|
||||
@@ -53,41 +54,14 @@ jobs:
|
||||
sudo chmod -R a+rw /data/db
|
||||
mongod &
|
||||
|
||||
- name: set settings path
|
||||
run: |
|
||||
echo "settings_path=$(python scripts/unit_test_shards_parser.py --shard-name=${{ matrix.shard_name }} --output settings )" >> $GITHUB_ENV
|
||||
|
||||
# - name: set pytest randomly option
|
||||
# run: |
|
||||
# echo "pytest_randomly_option=$(if [ '${{ env.module_name }}' = 'cms' ] || [ '${{ env.module_name }}' = 'common' ]; then echo '-p no:randomly'; else echo '' ; fi)" >> $GITHUB_ENV
|
||||
|
||||
- name: install requirements
|
||||
run: |
|
||||
sudo pip install -r requirements/pip.txt
|
||||
sudo pip install -r requirements/edx/testing.txt
|
||||
sudo pip install "django~=${{ matrix.django-version }}.0"
|
||||
|
||||
- name: get unit tests for shard
|
||||
run: |
|
||||
echo "unit_test_paths=$(python scripts/unit_test_shards_parser.py --shard-name=${{ matrix.shard_name }} )" >> $GITHUB_ENV
|
||||
|
||||
- name: run tests
|
||||
run: |
|
||||
python -Wd -m pytest -p no:randomly --ds=${{ env.settings_path }} ${{ env.unit_test_paths }}
|
||||
|
||||
- name: rename warnings json file
|
||||
if: success()
|
||||
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@v2
|
||||
with:
|
||||
name: pytest-warnings-json
|
||||
path: |
|
||||
test_root/log/pytest_warnings*.json
|
||||
- name: Setup and run tests
|
||||
uses: ./.github/actions/unit-tests
|
||||
|
||||
compile-warnings-report:
|
||||
runs-on: [ edx-platform-runner ]
|
||||
|
||||
@@ -8,6 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
collect-and-verify:
|
||||
if: github.repository == 'openedx/edx-platform' || github.repository == 'edx/edx-platform-private'
|
||||
runs-on: [ edx-platform-runner ]
|
||||
steps:
|
||||
- name: sync directory owner
|
||||
@@ -19,41 +20,5 @@ jobs:
|
||||
sudo pip install -r requirements/pip.txt
|
||||
sudo pip install -r requirements/edx/testing.txt
|
||||
|
||||
- name: collect tests from all modules
|
||||
run: |
|
||||
echo "root_cms_unit_tests_count=$(pytest --collect-only --ds=cms.envs.test cms/ -q | head -n -2 | wc -l)" >> $GITHUB_ENV
|
||||
echo "root_lms_unit_tests_count=$(pytest --collect-only --ds=lms.envs.test lms/ openedx/ common/djangoapps/ common/lib/ -q | head -n -2 | wc -l)" >> $GITHUB_ENV
|
||||
|
||||
- name: get GHA unit test paths
|
||||
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
|
||||
run: |
|
||||
echo "cms_unit_tests_count=$(pytest --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 --collect-only --ds=lms.envs.test ${{ env.lms_unit_test_paths }} -q | head -n -2 | wc -l)" >> $GITHUB_ENV
|
||||
|
||||
|
||||
- name: add unit tests count
|
||||
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
|
||||
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
|
||||
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
|
||||
- name: verify unit tests count
|
||||
uses: ./.github/actions/verify-tests-count
|
||||
|
||||
93
.tx/config
93
.tx/config
@@ -1,68 +1,69 @@
|
||||
[main]
|
||||
host = https://www.transifex.com
|
||||
|
||||
[edx-platform.django-partial]
|
||||
[o:open-edx:p:edx-platform:r:django-partial]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/django-partial.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/django-partial.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
type = PO
|
||||
|
||||
[edx-platform.django-studio]
|
||||
[o:open-edx:p:edx-platform:r:django-studio]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/django-studio.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/django-studio.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
type = PO
|
||||
|
||||
[edx-platform.djangojs-partial]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/djangojs-partial.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/djangojs-partial.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-platform.djangojs-studio]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/djangojs-studio.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/djangojs-studio.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-platform.djangojs-account-settings-view]
|
||||
[o:open-edx:p:edx-platform:r:djangojs-account-settings-view]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/djangojs-account-settings-view.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/djangojs-account-settings-view.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
type = PO
|
||||
|
||||
[edx-platform.mako]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/mako.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/mako.po
|
||||
[o:open-edx:p:edx-platform:r:djangojs-partial]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/djangojs-partial.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/djangojs-partial.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
type = PO
|
||||
|
||||
[edx-platform.mako-studio]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/mako-studio.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/mako-studio.po
|
||||
[o:open-edx:p:edx-platform:r:djangojs-studio]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/djangojs-studio.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/djangojs-studio.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
type = PO
|
||||
|
||||
[edx-platform.underscore]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/underscore.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/underscore.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-platform.underscore-studio]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/underscore-studio.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/underscore-studio.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-platform.wiki]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/wiki.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/wiki.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[edx-platform.edx_proctoring_proctortrack]
|
||||
[o:open-edx:p:edx-platform:r:edx_proctoring_proctortrack]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/edx_proctoring_proctortrack.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/edx_proctoring_proctortrack.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
type = PO
|
||||
|
||||
[o:open-edx:p:edx-platform:r:mako]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/mako.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/mako.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[o:open-edx:p:edx-platform:r:mako-studio]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/mako-studio.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/mako-studio.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[o:open-edx:p:edx-platform:r:underscore]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/underscore.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/underscore.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[o:open-edx:p:edx-platform:r:underscore-studio]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/underscore-studio.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/underscore-studio.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
[o:open-edx:p:edx-platform:r:wiki]
|
||||
file_filter = conf/locale/<lang>/LC_MESSAGES/wiki.po
|
||||
source_file = conf/locale/en/LC_MESSAGES/wiki.po
|
||||
source_lang = en
|
||||
type = PO
|
||||
|
||||
|
||||
19
Dockerfile
19
Dockerfile
@@ -118,10 +118,29 @@ COPY . .
|
||||
# all requirements from scratch.
|
||||
RUN pip install -r requirements/edx/base.txt
|
||||
|
||||
##################################################
|
||||
# Define LMS docker-based non-dev target.
|
||||
FROM base as lms-docker
|
||||
ENV SERVICE_VARIANT lms
|
||||
ARG LMS_CFG_OVERRIDE
|
||||
RUN echo "$LMS_CFG_OVERRIDE"
|
||||
ENV LMS_CFG="${LMS_CFG_OVERRIDE:-$LMS_CFG}"
|
||||
RUN echo "$LMS_CFG"
|
||||
ENV EDX_PLATFORM_SETTINGS='docker-production'
|
||||
ENV DJANGO_SETTINGS_MODULE="lms.envs.$EDX_PLATFORM_SETTINGS"
|
||||
EXPOSE 8000
|
||||
CMD gunicorn \
|
||||
-c /edx/app/edxapp/edx-platform/lms/docker_lms_gunicorn.py \
|
||||
--name lms \
|
||||
--bind=0.0.0.0:8000 \
|
||||
--max-requests=1000 \
|
||||
--access-logfile \
|
||||
- lms.wsgi:application
|
||||
|
||||
##################################################
|
||||
# Define LMS non-dev target.
|
||||
FROM base as lms
|
||||
ENV LMS_CFG="$CONFIG_ROOT/lms.yml"
|
||||
ENV SERVICE_VARIANT lms
|
||||
ENV DJANGO_SETTINGS_MODULE="lms.envs.$EDX_PLATFORM_SETTINGS"
|
||||
EXPOSE 8000
|
||||
|
||||
@@ -5,6 +5,7 @@ import logging
|
||||
from datetime import datetime
|
||||
from functools import wraps
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.dispatch import receiver
|
||||
from pytz import UTC
|
||||
@@ -55,6 +56,9 @@ def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=
|
||||
update_search_index,
|
||||
update_special_exams_and_publish
|
||||
)
|
||||
from cms.djangoapps.coursegraph.tasks import (
|
||||
dump_course_to_neo4j
|
||||
)
|
||||
|
||||
# register special exams asynchronously
|
||||
course_key_str = str(course_key)
|
||||
@@ -64,6 +68,10 @@ def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=
|
||||
# Push the course outline to learning_sequences asynchronously.
|
||||
update_outline_from_modulestore_task.delay(course_key_str)
|
||||
|
||||
if settings.COURSEGRAPH_DUMP_COURSE_ON_PUBLISH:
|
||||
# Push the course out to CourseGraph asynchronously.
|
||||
dump_course_to_neo4j.delay(course_key_str)
|
||||
|
||||
# Finally, call into the course search subsystem
|
||||
# to kick off an indexing action
|
||||
if CoursewareSearchIndexer.indexing_is_enabled() and CourseAboutSearchIndexer.indexing_is_enabled():
|
||||
|
||||
@@ -198,7 +198,6 @@ def _preview_module_system(request, descriptor, field_data):
|
||||
static_url=settings.STATIC_URL,
|
||||
# TODO (cpennington): Do we want to track how instructors are using the preview problems?
|
||||
track_function=lambda event_type, event: None,
|
||||
filestore=descriptor.runtime.resources_fs,
|
||||
get_module=partial(_load_preview_module, request),
|
||||
debug=True,
|
||||
mixins=settings.XBLOCK_MIXINS,
|
||||
|
||||
120
cms/djangoapps/coursegraph/README.rst
Normal file
120
cms/djangoapps/coursegraph/README.rst
Normal file
@@ -0,0 +1,120 @@
|
||||
|
||||
CourseGraph Support
|
||||
-------------------
|
||||
|
||||
This app exists to write data to "CourseGraph", a tool enabling Open edX developers and support specialists to inspect their platform instance's learning content. CourseGraph itself is simply an instance of `Neo4j`_, which is an open-source graph database with a Web interface.
|
||||
|
||||
.. _Neo4j: https://neo4j.com
|
||||
|
||||
Deploying Coursegraph
|
||||
=====================
|
||||
|
||||
There are two ways to deploy CourseGraph:
|
||||
|
||||
* For operators using Tutor, there is a `CourseGraph plugin for Tutor`_ that is currently released as "Beta". Nutmeg is the earliest Open edX release that the plugin will work alongside.
|
||||
|
||||
* For operators still using the old Ansible installation pathway, there exists a `neo4j Ansible playbook`_. Be warned that this method is not well-documented nor officially supported.
|
||||
|
||||
In order for CourseGraph to have queryable, up-to-date data, learning content from CMS must be written to CourseGraph regularly. That is where this Django app comes into play. For details on the various ways to write CMS data to CourseGraph, visit the `operations section of the CourseGraph Tutor plugin docs`_.
|
||||
|
||||
**Please note**: Access to a populated CourseGraph instance confers access to all the learning content in the associated Open edX CMS (Studio). The basic authentication provided by Neo4j may or may not be sufficient for your security needs. Consider taking additional security measures, such as restricting CourseGraph access to only users on a private VPN.
|
||||
|
||||
.. _neo4j Ansible playbook: https://github.com/edx/configuration/blob/master/playbooks/neo4j.yml
|
||||
|
||||
.. _CourseGraph plugin for Tutor: https://github.com/openedx/tutor-contrib-coursegraph/
|
||||
|
||||
.. _operations section of the CourseGraph Tutor plugin docs: https://github.com/openedx/tutor-contrib-coursegraph/#managing-data
|
||||
|
||||
Running CourseGraph locally
|
||||
===========================
|
||||
|
||||
In some circumstances, you may want to run CourseGraph locally, connected to a development-mode Open edX instance. You can do this in both Tutor and Devstack.
|
||||
|
||||
Tutor
|
||||
*****
|
||||
|
||||
The `CourseGraph plugin for Tutor`_ makes it easy to install, configure, and run CourseGraph for local development.
|
||||
|
||||
Devstack
|
||||
********
|
||||
|
||||
CourseGraph is included as an "extra" component in the `Open edX Devstack`_. That is, it is not run or provisioned by default, but can be enabled on-demand.
|
||||
|
||||
To provision Devstack CourseGraph with data from Devstack LMS, run::
|
||||
|
||||
make dev.provision.coursegraph
|
||||
|
||||
CourseGraph should now be accessible at http://localhost:7474 with the username ``neo4j`` and the password ``edx``.
|
||||
|
||||
Under the hood, the provisioning command just invokes ``dump_to_neo4j`` on your LMS, pointed at your CourseGraph. The provisioning command can be run again at any point in the future to refresh CourseGraph with new LMS data. The data in CourseGraph will persist unless you explicitly destroy it (as noted below).
|
||||
|
||||
Other Devstack CourseGraph commands include::
|
||||
|
||||
make dev.up.coursegraph # Bring up the container (without re-provisioning).
|
||||
make dev.down.coursegraph # Stop and remove the container.
|
||||
make dev.shell.coursegraph # Start a shell session in the container.
|
||||
make dev.attach.coursegraph # Attach to the container.
|
||||
make dev.destroy.coursegraph # Stop the container and destroy its database.
|
||||
|
||||
The above commands should be run in your ``devstack`` folder, and they assume that LMS is already properly provisioned. See the `Devstack interface`_ for more details.
|
||||
|
||||
.. _Open edX Devstack: https://github.com/edx/devstack/
|
||||
.. _Devstack interface: https://edx.readthedocs.io/projects/open-edx-devstack/en/latest/devstack_interface.html
|
||||
|
||||
|
||||
Querying Coursegraph
|
||||
====================
|
||||
|
||||
CourseGraph is queryable using the `Cypher`_ query language. Open edX learning content is represented in Neo4j using a straightforward scheme:
|
||||
|
||||
* A node is an XBlock usage.
|
||||
|
||||
* Nodes are tagged with their ``block_type``, such as:
|
||||
|
||||
* ``course``
|
||||
* ``chapter``
|
||||
* ``sequential``
|
||||
* ``vertical``
|
||||
* ``problem``
|
||||
* ``html``
|
||||
* etc.
|
||||
|
||||
* Every node is also tagged with ``item``.
|
||||
|
||||
* Parent-child relationships in the course hierarchy are reflected in the ``PARENT_OF`` relationship.
|
||||
|
||||
* Ordered sibling relationships in the course hierarchy are reflected in the ``PRECEDES`` relationship.
|
||||
|
||||
* Fields on each XBlock usage (``.display_name``, ``.data``, etc) are available on the corresponding node.
|
||||
|
||||
.. _Cypher: https://neo4j.com/developer/cypher/
|
||||
|
||||
|
||||
Example Queries
|
||||
***************
|
||||
|
||||
How many XBlocks exist in the LMS, by type? ::
|
||||
|
||||
MATCH
|
||||
(c:course) -[:PARENT_OF*]-> (n:item)
|
||||
RETURN
|
||||
distinct(n.block_type) as block_type,
|
||||
count(n) as number
|
||||
order by
|
||||
number DESC
|
||||
|
||||
|
||||
In a given course, which units contain problems with custom Python grading code? ::
|
||||
|
||||
MATCH
|
||||
(c:course) -[:PARENT_OF*]-> (u:vertical) -[:PARENT_OF*]-> (p:problem)
|
||||
WHERE
|
||||
p.data CONTAINS 'loncapa/python'
|
||||
AND
|
||||
c.course_key = '<course_key>'
|
||||
RETURN
|
||||
u.location
|
||||
|
||||
You can see many more examples of useful CourseGraph queries on the `query archive wiki page`_.
|
||||
|
||||
.. _query archive wiki page: https://openedx.atlassian.net/wiki/spaces/COMM/pages/3273228388/Useful+CourseGraph+Queries
|
||||
123
cms/djangoapps/coursegraph/admin.py
Normal file
123
cms/djangoapps/coursegraph/admin.py
Normal file
@@ -0,0 +1,123 @@
|
||||
"""
|
||||
Admin site bindings for coursegraph
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.contrib import admin, messages
|
||||
from django.utils.translation import gettext as _
|
||||
from edx_django_utils.admin.mixins import ReadOnlyAdminMixin
|
||||
|
||||
from .models import CourseGraphCourseDump
|
||||
from .tasks import ModuleStoreSerializer
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@admin.action(
|
||||
permissions=['change'],
|
||||
description=_("Dump courses to CourseGraph (respect cache)"),
|
||||
)
|
||||
def dump_courses(modeladmin, request, queryset):
|
||||
"""
|
||||
Admin action to enqueue Dump-to-CourseGraph tasks for a set of courses,
|
||||
excluding courses that haven't been published since they were last dumped.
|
||||
|
||||
queryset is a QuerySet of CourseGraphCourseDump objects, which are just
|
||||
CourseOverview objects under the hood.
|
||||
"""
|
||||
all_course_keys = queryset.values_list('id', flat=True)
|
||||
serializer = ModuleStoreSerializer(all_course_keys)
|
||||
try:
|
||||
submitted, skipped = serializer.dump_courses_to_neo4j()
|
||||
# Unfortunately there is no unified base class for the reasonable
|
||||
# exceptions we could expect from py2neo (connection unavailable, bolt protocol
|
||||
# error, and so on), so we just catch broadly, show a generic error banner,
|
||||
# and then log the exception for site operators to look at.
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
log.exception(
|
||||
"Failed to enqueue CourseGraph dumps to Neo4j (respecting cache): %s",
|
||||
", ".join(str(course_key) for course_key in all_course_keys),
|
||||
)
|
||||
modeladmin.message_user(
|
||||
request,
|
||||
_("Error enqueueing dumps for {} course(s): {}").format(
|
||||
len(all_course_keys), str(err)
|
||||
),
|
||||
level=messages.ERROR,
|
||||
)
|
||||
return
|
||||
if submitted:
|
||||
modeladmin.message_user(
|
||||
request,
|
||||
_(
|
||||
"Enqueued dumps for {} course(s). Skipped {} unchanged course(s)."
|
||||
).format(len(submitted), len(skipped)),
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
else:
|
||||
modeladmin.message_user(
|
||||
request,
|
||||
_(
|
||||
"Skipped all {} course(s), as they were unchanged.",
|
||||
).format(len(skipped)),
|
||||
level=messages.WARNING,
|
||||
)
|
||||
|
||||
|
||||
@admin.action(
|
||||
permissions=['change'],
|
||||
description=_("Dump courses to CourseGraph (override cache)")
|
||||
)
|
||||
def dump_courses_overriding_cache(modeladmin, request, queryset):
|
||||
"""
|
||||
Admin action to enqueue Dump-to-CourseGraph tasks for a set of courses
|
||||
(whether or not they have been published recently).
|
||||
|
||||
queryset is a QuerySet of CourseGraphCourseDump objects, which are just
|
||||
CourseOverview objects under the hood.
|
||||
"""
|
||||
all_course_keys = queryset.values_list('id', flat=True)
|
||||
serializer = ModuleStoreSerializer(all_course_keys)
|
||||
try:
|
||||
submitted, _skipped = serializer.dump_courses_to_neo4j(override_cache=True)
|
||||
# Unfortunately there is no unified base class for the reasonable
|
||||
# exceptions we could expect from py2neo (connection unavailable, bolt protocol
|
||||
# error, and so on), so we just catch broadly, show a generic error banner,
|
||||
# and then log the exception for site operators to look at.
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
log.exception(
|
||||
"Failed to enqueue CourseGraph Neo4j course dumps (overriding cache): %s",
|
||||
", ".join(str(course_key) for course_key in all_course_keys),
|
||||
)
|
||||
modeladmin.message_user(
|
||||
request,
|
||||
_("Error enqueueing dumps for {} course(s): {}").format(
|
||||
len(all_course_keys), str(err)
|
||||
),
|
||||
level=messages.ERROR,
|
||||
)
|
||||
return
|
||||
modeladmin.message_user(
|
||||
request,
|
||||
_("Enqueued dumps for {} course(s).").format(len(submitted)),
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(CourseGraphCourseDump)
|
||||
class CourseGraphCourseDumpAdmin(ReadOnlyAdminMixin, admin.ModelAdmin):
|
||||
"""
|
||||
Model admin for "Course graph course dumps".
|
||||
|
||||
Just a read-only table with some useful metadata, allowing admin users to
|
||||
select courses to be dumped to CourseGraph.
|
||||
"""
|
||||
list_display = [
|
||||
'id',
|
||||
'display_name',
|
||||
'modified',
|
||||
'enrollment_start',
|
||||
'enrollment_end',
|
||||
]
|
||||
search_fields = ['id', 'display_name']
|
||||
actions = [dump_courses, dump_courses_overriding_cache]
|
||||
@@ -12,6 +12,6 @@ class CoursegraphConfig(AppConfig):
|
||||
"""
|
||||
AppConfig for courseware app
|
||||
"""
|
||||
name = 'openedx.core.djangoapps.coursegraph'
|
||||
name = 'cms.djangoapps.coursegraph'
|
||||
|
||||
from openedx.core.djangoapps.coursegraph import tasks
|
||||
from cms.djangoapps.coursegraph import tasks
|
||||
114
cms/djangoapps/coursegraph/management/commands/dump_to_neo4j.py
Normal file
114
cms/djangoapps/coursegraph/management/commands/dump_to_neo4j.py
Normal file
@@ -0,0 +1,114 @@
|
||||
"""
|
||||
This file contains a management command for exporting the modulestore to
|
||||
Neo4j, a graph database.
|
||||
|
||||
Example usages:
|
||||
|
||||
# Dump all courses published since last dump.
|
||||
# Use connection parameters from `settings.COURSEGRAPH_SETTINGS`.
|
||||
python manage.py cms dump_to_neo4j
|
||||
|
||||
# Dump all courses published since last dump.
|
||||
# Use custom connection parameters.
|
||||
python manage.py cms dump_to_neo4j --host localhost --port 7473 \
|
||||
--secure --user user --password password
|
||||
|
||||
# Specify certain courses instead of dumping all of them.
|
||||
# Use connection parameters from `settings.COURSEGRAPH_SETTINGS`.
|
||||
python manage.py cms dump_to_neo4j --courses 'course-v1:A+B+1' 'course-v1:A+B+2'
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
from textwrap import dedent
|
||||
|
||||
from django.core.management.base import BaseCommand
|
||||
|
||||
from cms.djangoapps.coursegraph.tasks import ModuleStoreSerializer
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""
|
||||
Dump recently-published course(s) over to a CourseGraph (Neo4j) instance.
|
||||
"""
|
||||
help = dedent(__doc__).strip()
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument(
|
||||
'--host',
|
||||
type=str,
|
||||
help="the hostname of the Neo4j server",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--port',
|
||||
type=int,
|
||||
help="the port on the Neo4j server that accepts Bolt requests",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--secure',
|
||||
action='store_true',
|
||||
help="connect to server over Bolt/TLS instead of plain unencrypted Bolt",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--user',
|
||||
type=str,
|
||||
help="the username of the Neo4j user",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--password',
|
||||
type=str,
|
||||
help="the password of the Neo4j user",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--courses',
|
||||
metavar='KEY',
|
||||
type=str,
|
||||
nargs='*',
|
||||
help="keys of courses to serialize; if omitted all courses in system are serialized",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--skip',
|
||||
metavar='KEY',
|
||||
type=str,
|
||||
nargs='*',
|
||||
help="keys of courses to NOT to serialize",
|
||||
)
|
||||
parser.add_argument(
|
||||
'--override',
|
||||
action='store_true',
|
||||
help="dump all courses regardless of when they were last published",
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""
|
||||
Iterates through each course, serializes them into graphs, and saves
|
||||
those graphs to neo4j.
|
||||
"""
|
||||
|
||||
mss = ModuleStoreSerializer.create(options['courses'], options['skip'])
|
||||
connection_overrides = {
|
||||
key: options[key]
|
||||
for key in ["host", "port", "secure", "user", "password"]
|
||||
}
|
||||
submitted_courses, skipped_courses = mss.dump_courses_to_neo4j(
|
||||
connection_overrides=connection_overrides,
|
||||
override_cache=options['override'],
|
||||
)
|
||||
|
||||
log.info(
|
||||
"%d courses submitted for export to neo4j. %d courses skipped.",
|
||||
len(submitted_courses),
|
||||
len(skipped_courses),
|
||||
)
|
||||
|
||||
if not submitted_courses:
|
||||
print("No courses submitted for export to neo4j at all!")
|
||||
return
|
||||
|
||||
if submitted_courses:
|
||||
print(
|
||||
"These courses were submitted for export to neo4j successfully:\n\t" +
|
||||
"\n\t".join(submitted_courses)
|
||||
)
|
||||
@@ -8,15 +8,16 @@ from datetime import datetime
|
||||
from unittest import mock
|
||||
import ddt
|
||||
from django.core.management import call_command
|
||||
from django.test.utils import override_settings
|
||||
from edx_toggles.toggles.testutils import override_waffle_switch
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
|
||||
import openedx.core.djangoapps.content.block_structure.config as block_structure_config
|
||||
from openedx.core.djangoapps.content.block_structure.signals import update_block_structure_on_course_publish
|
||||
from openedx.core.djangoapps.coursegraph.management.commands.dump_to_neo4j import ModuleStoreSerializer
|
||||
from openedx.core.djangoapps.coursegraph.management.commands.tests.utils import MockGraph, MockNodeMatcher
|
||||
from openedx.core.djangoapps.coursegraph.tasks import (
|
||||
from cms.djangoapps.coursegraph.management.commands.dump_to_neo4j import ModuleStoreSerializer
|
||||
from cms.djangoapps.coursegraph.management.commands.tests.utils import MockGraph, MockNodeMatcher
|
||||
from cms.djangoapps.coursegraph.tasks import (
|
||||
coerce_types,
|
||||
serialize_course,
|
||||
serialize_item,
|
||||
@@ -115,8 +116,8 @@ class TestDumpToNeo4jCommand(TestDumpToNeo4jCommandBase):
|
||||
Tests for the dump to neo4j management command
|
||||
"""
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.NodeMatcher')
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.Graph')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.NodeMatcher')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.Graph')
|
||||
@ddt.data(1, 2)
|
||||
def test_dump_specific_courses(self, number_of_courses, mock_graph_class, mock_matcher_class):
|
||||
"""
|
||||
@@ -140,8 +141,8 @@ class TestDumpToNeo4jCommand(TestDumpToNeo4jCommandBase):
|
||||
number_rollbacks=0
|
||||
)
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.NodeMatcher')
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.Graph')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.NodeMatcher')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.Graph')
|
||||
def test_dump_skip_course(self, mock_graph_class, mock_matcher_class):
|
||||
"""
|
||||
Test that you can skip courses.
|
||||
@@ -166,8 +167,8 @@ class TestDumpToNeo4jCommand(TestDumpToNeo4jCommandBase):
|
||||
number_rollbacks=0,
|
||||
)
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.NodeMatcher')
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.Graph')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.NodeMatcher')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.Graph')
|
||||
def test_dump_skip_beats_specifying(self, mock_graph_class, mock_matcher_class):
|
||||
"""
|
||||
Test that if you skip and specify the same course, you'll skip it.
|
||||
@@ -193,8 +194,8 @@ class TestDumpToNeo4jCommand(TestDumpToNeo4jCommandBase):
|
||||
number_rollbacks=0,
|
||||
)
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.NodeMatcher')
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.Graph')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.NodeMatcher')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.Graph')
|
||||
def test_dump_all_courses(self, mock_graph_class, mock_matcher_class):
|
||||
"""
|
||||
Test if you don't specify which courses to dump, then you'll dump
|
||||
@@ -219,6 +220,48 @@ class TestDumpToNeo4jCommand(TestDumpToNeo4jCommandBase):
|
||||
number_rollbacks=0,
|
||||
)
|
||||
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.NodeMatcher')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.Graph', autospec=True)
|
||||
@override_settings(
|
||||
COURSEGRAPH_CONNECTION=dict(
|
||||
protocol='bolt',
|
||||
host='coursegraph.example.edu',
|
||||
port=7777,
|
||||
secure=True,
|
||||
user="neo4j",
|
||||
password="default-password",
|
||||
)
|
||||
)
|
||||
def test_dump_to_neo4j_connection_defaults(self, mock_graph_class, mock_matcher_class):
|
||||
"""
|
||||
Test that user can override individual settings.COURSEGRAPH_CONNECTION parameters
|
||||
by passing them to `dump_to_neo4j`, whilst falling back to the ones that they
|
||||
don't override.
|
||||
"""
|
||||
self.setup_mock_graph(
|
||||
mock_matcher_class, mock_graph_class
|
||||
)
|
||||
call_command(
|
||||
'dump_to_neo4j',
|
||||
courses=self.course_strings[:1],
|
||||
port=7788,
|
||||
secure=False,
|
||||
password="overridden-password",
|
||||
)
|
||||
assert mock_graph_class.call_args.args == ()
|
||||
assert mock_graph_class.call_args.kwargs == dict(
|
||||
|
||||
# From settings:
|
||||
protocol='bolt',
|
||||
host='coursegraph.example.edu',
|
||||
user="neo4j",
|
||||
|
||||
# Overriden by command:
|
||||
port=7788,
|
||||
secure=False,
|
||||
password="overridden-password",
|
||||
)
|
||||
|
||||
|
||||
class SomeThing:
|
||||
"""Just to test the stringification of an object."""
|
||||
@@ -395,8 +438,8 @@ class TestModuleStoreSerializer(TestDumpToNeo4jCommandBase):
|
||||
coerced_value = coerce_types(original_value)
|
||||
assert coerced_value == coerced_expected
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.NodeMatcher')
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.authenticate_and_create_graph')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.NodeMatcher')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.authenticate_and_create_graph')
|
||||
def test_dump_to_neo4j(self, mock_graph_constructor, mock_matcher_class):
|
||||
"""
|
||||
Tests the dump_to_neo4j method works against a mock
|
||||
@@ -423,8 +466,8 @@ class TestModuleStoreSerializer(TestDumpToNeo4jCommandBase):
|
||||
assert len(mock_graph.nodes) == 11
|
||||
self.assertCountEqual(submitted, self.course_strings)
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.NodeMatcher')
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.authenticate_and_create_graph')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.NodeMatcher')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.authenticate_and_create_graph')
|
||||
def test_dump_to_neo4j_rollback(self, mock_graph_constructor, mock_matcher_class):
|
||||
"""
|
||||
Tests that the the dump_to_neo4j method handles the case where there's
|
||||
@@ -447,8 +490,8 @@ class TestModuleStoreSerializer(TestDumpToNeo4jCommandBase):
|
||||
|
||||
self.assertCountEqual(submitted, self.course_strings)
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.NodeMatcher')
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.authenticate_and_create_graph')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.NodeMatcher')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.authenticate_and_create_graph')
|
||||
@ddt.data((True, 2), (False, 0))
|
||||
@ddt.unpack
|
||||
def test_dump_to_neo4j_cache(
|
||||
@@ -480,8 +523,8 @@ class TestModuleStoreSerializer(TestDumpToNeo4jCommandBase):
|
||||
)
|
||||
assert len(submitted) == expected_number_courses
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.NodeMatcher')
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.authenticate_and_create_graph')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.NodeMatcher')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.authenticate_and_create_graph')
|
||||
def test_dump_to_neo4j_published(self, mock_graph_constructor, mock_matcher_class):
|
||||
"""
|
||||
Tests that we only dump those courses that have been published after
|
||||
@@ -506,8 +549,8 @@ class TestModuleStoreSerializer(TestDumpToNeo4jCommandBase):
|
||||
assert len(submitted) == 1
|
||||
assert submitted[0] == str(self.course.id)
|
||||
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.get_course_last_published')
|
||||
@mock.patch('openedx.core.djangoapps.coursegraph.tasks.get_command_last_run')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.get_course_last_published')
|
||||
@mock.patch('cms.djangoapps.coursegraph.tasks.get_command_last_run')
|
||||
@ddt.data(
|
||||
(
|
||||
str(datetime(2016, 3, 30)), str(datetime(2016, 3, 31)),
|
||||
21
cms/djangoapps/coursegraph/models.py
Normal file
21
cms/djangoapps/coursegraph/models.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
(Proxy) models supporting CourseGraph.
|
||||
"""
|
||||
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
|
||||
|
||||
class CourseGraphCourseDump(CourseOverview):
|
||||
"""
|
||||
Proxy model for CourseOverview.
|
||||
|
||||
Does *not* create/update/delete CourseOverview objects - only reads the objects.
|
||||
Uses the course IDs of the CourseOverview objects to determine which courses
|
||||
can be dumped to CourseGraph.
|
||||
"""
|
||||
class Meta:
|
||||
proxy = True
|
||||
|
||||
def __str__(self):
|
||||
"""Represent ourselves with the course key."""
|
||||
return str(self.id)
|
||||
@@ -7,6 +7,7 @@ neo4j, a graph database.
|
||||
import logging
|
||||
|
||||
from celery import shared_task
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from edx_django_utils.cache import RequestCache
|
||||
from edx_django_utils.monitoring import set_code_owner_attribute
|
||||
@@ -133,29 +134,26 @@ def get_command_last_run(course_key, graph):
|
||||
|
||||
def get_course_last_published(course_key):
|
||||
"""
|
||||
We use the CourseStructure table to get when this course was last
|
||||
published.
|
||||
Approximately when was a course last published?
|
||||
|
||||
We use the 'modified' column in the CourseOverview table as a quick and easy
|
||||
(although perhaps inexact) way of determining when a course was last
|
||||
published. This works because CourseOverview rows are re-written upon
|
||||
course publish.
|
||||
|
||||
Args:
|
||||
course_key: a CourseKey
|
||||
|
||||
Returns: The datetime the course was last published at, converted into
|
||||
text, or None, if there's no record of the last time this course
|
||||
was published.
|
||||
Returns: The datetime the course was last published at, stringified.
|
||||
Uses Python's default str(...) implementation for datetimes, which
|
||||
is sortable and similar to ISO 8601:
|
||||
https://docs.python.org/3/library/datetime.html#datetime.date.__str__
|
||||
"""
|
||||
# Import is placed here to avoid model import at project startup.
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from openedx.core.djangoapps.content.block_structure.models import BlockStructureModel
|
||||
from openedx.core.djangoapps.content.block_structure.exceptions import BlockStructureNotFound
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
|
||||
store = modulestore()
|
||||
course_usage_key = store.make_course_usage_key(course_key)
|
||||
try:
|
||||
structure = BlockStructureModel.get(course_usage_key)
|
||||
course_last_published_date = str(structure.modified)
|
||||
except BlockStructureNotFound:
|
||||
course_last_published_date = None
|
||||
|
||||
return course_last_published_date
|
||||
approx_last_published = CourseOverview.get_from_id(course_key).modified
|
||||
return str(approx_last_published)
|
||||
|
||||
|
||||
def strip_branch_and_version(location):
|
||||
@@ -268,14 +266,14 @@ def should_dump_course(course_key, graph):
|
||||
|
||||
@shared_task
|
||||
@set_code_owner_attribute
|
||||
def dump_course_to_neo4j(course_key_string, credentials):
|
||||
def dump_course_to_neo4j(course_key_string, connection_overrides=None):
|
||||
"""
|
||||
Serializes a course and writes it to neo4j.
|
||||
|
||||
Arguments:
|
||||
course_key: course key for the course to be exported
|
||||
credentials (dict): the necessary credentials to connect
|
||||
to neo4j and create a py2neo `Graph` obje
|
||||
course_key_string: course key for the course to be exported
|
||||
connection_overrides (dict): overrides to Neo4j connection
|
||||
parameters specified in `settings.COURSEGRAPH_CONNECTION`.
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_key_string)
|
||||
nodes, relationships = serialize_course(course_key)
|
||||
@@ -286,7 +284,9 @@ def dump_course_to_neo4j(course_key_string, credentials):
|
||||
len(relationships),
|
||||
)
|
||||
|
||||
graph = authenticate_and_create_graph(credentials)
|
||||
graph = authenticate_and_create_graph(
|
||||
connection_overrides=connection_overrides
|
||||
)
|
||||
|
||||
transaction = graph.begin()
|
||||
course_string = str(course_key)
|
||||
@@ -346,13 +346,13 @@ class ModuleStoreSerializer:
|
||||
course_keys = [course_key for course_key in course_keys if course_key not in skip_keys]
|
||||
return cls(course_keys)
|
||||
|
||||
def dump_courses_to_neo4j(self, credentials, override_cache=False):
|
||||
def dump_courses_to_neo4j(self, connection_overrides=None, override_cache=False):
|
||||
"""
|
||||
Method that iterates through a list of courses in a modulestore,
|
||||
serializes them, then submits tasks to write them to neo4j.
|
||||
Arguments:
|
||||
credentials (dict): the necessary credentials to connect
|
||||
to neo4j and create a py2neo `Graph` object
|
||||
connection_overrides (dict): overrides to Neo4j connection
|
||||
parameters specified in `settings.COURSEGRAPH_CONNECTION`.
|
||||
override_cache: serialize the courses even if they'be been recently
|
||||
serialized
|
||||
|
||||
@@ -365,19 +365,12 @@ class ModuleStoreSerializer:
|
||||
submitted_courses = []
|
||||
skipped_courses = []
|
||||
|
||||
graph = authenticate_and_create_graph(credentials)
|
||||
graph = authenticate_and_create_graph(connection_overrides)
|
||||
|
||||
for index, course_key in enumerate(self.course_keys):
|
||||
# first, clear the request cache to prevent memory leaks
|
||||
RequestCache.clear_all_namespaces()
|
||||
|
||||
log.info(
|
||||
"Now submitting %s for export to neo4j: course %d of %d total courses",
|
||||
course_key,
|
||||
index + 1,
|
||||
total_number_of_courses,
|
||||
)
|
||||
|
||||
(needs_dump, reason) = should_dump_course(course_key, graph)
|
||||
if not (override_cache or needs_dump):
|
||||
log.info("skipping submitting %s, since it hasn't changed", course_key)
|
||||
@@ -386,38 +379,42 @@ class ModuleStoreSerializer:
|
||||
|
||||
if override_cache:
|
||||
reason = "override_cache is True"
|
||||
log.info("submitting %s, because %s", course_key, reason)
|
||||
|
||||
log.info(
|
||||
"Now submitting %s for export to neo4j, because %s: course %d of %d total courses",
|
||||
course_key,
|
||||
reason,
|
||||
index + 1,
|
||||
total_number_of_courses,
|
||||
)
|
||||
|
||||
dump_course_to_neo4j.apply_async(
|
||||
args=[str(course_key), credentials],
|
||||
kwargs=dict(
|
||||
course_key_string=str(course_key),
|
||||
connection_overrides=connection_overrides,
|
||||
)
|
||||
)
|
||||
submitted_courses.append(str(course_key))
|
||||
|
||||
return submitted_courses, skipped_courses
|
||||
|
||||
|
||||
def authenticate_and_create_graph(credentials):
|
||||
def authenticate_and_create_graph(connection_overrides=None):
|
||||
"""
|
||||
This function authenticates with neo4j and creates a py2neo graph object
|
||||
|
||||
Arguments:
|
||||
credentials (dict): a dictionary of credentials used to authenticate,
|
||||
and then create, a py2neo graph object.
|
||||
connection_overrides (dict): overrides to Neo4j connection
|
||||
parameters specified in `settings.COURSEGRAPH_CONNECTION`.
|
||||
|
||||
Returns: a py2neo `Graph` object.
|
||||
"""
|
||||
|
||||
host = credentials['host']
|
||||
port = credentials['port']
|
||||
secure = credentials['secure']
|
||||
neo4j_user = credentials['user']
|
||||
neo4j_password = credentials['password']
|
||||
|
||||
graph = Graph(
|
||||
protocol='bolt',
|
||||
password=neo4j_password,
|
||||
user=neo4j_user,
|
||||
address=host,
|
||||
port=port,
|
||||
secure=secure,
|
||||
)
|
||||
|
||||
return graph
|
||||
provided_overrides = {
|
||||
key: value
|
||||
for key, value in (connection_overrides or {}).items()
|
||||
# Drop overrides whose values are `None`. Note that `False` is a
|
||||
# legitimate override value that we don't want to drop here.
|
||||
if value is not None
|
||||
}
|
||||
connection_with_overrides = {**settings.COURSEGRAPH_CONNECTION, **provided_overrides}
|
||||
return Graph(**connection_with_overrides)
|
||||
227
cms/djangoapps/coursegraph/tests/test_admin.py
Normal file
227
cms/djangoapps/coursegraph/tests/test_admin.py
Normal file
@@ -0,0 +1,227 @@
|
||||
"""
|
||||
Shallow tests for CourseGraph dump-queueing Django admin interface.
|
||||
|
||||
See ..management.commands.tests.test_dump_to_neo4j for more comprehensive
|
||||
tests of dump_course_to_neo4j.
|
||||
"""
|
||||
|
||||
from unittest import mock
|
||||
|
||||
import py2neo
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
from freezegun import freeze_time
|
||||
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
|
||||
from .. import admin, tasks
|
||||
|
||||
|
||||
_coursegraph_connection = {
|
||||
"protocol": "bolt",
|
||||
"secure": True,
|
||||
"host": "example.edu",
|
||||
"port": 7687,
|
||||
"user": "neo4j",
|
||||
"password": "fake-coursegraph-password",
|
||||
}
|
||||
|
||||
_configure_coursegraph_connection = override_settings(
|
||||
COURSEGRAPH_CONNECTION=_coursegraph_connection,
|
||||
)
|
||||
|
||||
_patch_log_exception = mock.patch.object(
|
||||
admin.log, 'exception', autospec=True
|
||||
)
|
||||
|
||||
_patch_apply_dump_task = mock.patch.object(
|
||||
tasks.dump_course_to_neo4j, 'apply_async'
|
||||
)
|
||||
|
||||
_pretend_last_course_dump_was_may_2020 = mock.patch.object(
|
||||
tasks,
|
||||
'get_command_last_run',
|
||||
new=(lambda _key, _graph: "2020-05-01"),
|
||||
)
|
||||
|
||||
_patch_neo4j_graph = mock.patch.object(
|
||||
tasks, 'Graph', autospec=True
|
||||
)
|
||||
|
||||
_make_neo4j_graph_raise = mock.patch.object(
|
||||
tasks, 'Graph', side_effect=py2neo.ConnectionUnavailable(
|
||||
'we failed to connect or something!'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class CourseGraphAdminActionsTestCase(TestCase):
|
||||
"""
|
||||
Test CourseGraph Django admin actions.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
"""
|
||||
Make course overviews with varying modification dates.
|
||||
"""
|
||||
super().setUpTestData()
|
||||
cls.course_updated_in_april = CourseOverviewFactory(run='april_update')
|
||||
cls.course_updated_in_june = CourseOverviewFactory(run='june_update')
|
||||
cls.course_updated_in_july = CourseOverviewFactory(run='july_update')
|
||||
cls.course_updated_in_august = CourseOverviewFactory(run='august_update')
|
||||
|
||||
# For each course overview, make an arbitrary update and then save()
|
||||
# so that its `.modified` date is set.
|
||||
with freeze_time("2020-04-01"):
|
||||
cls.course_updated_in_april.marketing_url = "https://example.org"
|
||||
cls.course_updated_in_april.save()
|
||||
with freeze_time("2020-06-01"):
|
||||
cls.course_updated_in_june.marketing_url = "https://example.org"
|
||||
cls.course_updated_in_june.save()
|
||||
with freeze_time("2020-07-01"):
|
||||
cls.course_updated_in_july.marketing_url = "https://example.org"
|
||||
cls.course_updated_in_july.save()
|
||||
with freeze_time("2020-08-01"):
|
||||
cls.course_updated_in_august.marketing_url = "https://example.org"
|
||||
cls.course_updated_in_august.save()
|
||||
|
||||
@_configure_coursegraph_connection
|
||||
@_pretend_last_course_dump_was_may_2020
|
||||
@_patch_neo4j_graph
|
||||
@_patch_apply_dump_task
|
||||
@_patch_log_exception
|
||||
def test_dump_courses(self, mock_log_exception, mock_apply_dump_task, mock_neo4j_graph):
|
||||
"""
|
||||
Test that dump_courses admin action dumps requested courses iff they have
|
||||
been modified since the last dump to coursegraph.
|
||||
"""
|
||||
modeladmin_mock = mock.MagicMock()
|
||||
|
||||
# Request all courses except the August-updated one
|
||||
requested_course_keys = {
|
||||
str(self.course_updated_in_april.id),
|
||||
str(self.course_updated_in_june.id),
|
||||
str(self.course_updated_in_july.id),
|
||||
}
|
||||
admin.dump_courses(
|
||||
modeladmin=modeladmin_mock,
|
||||
request=mock.MagicMock(),
|
||||
queryset=CourseOverview.objects.filter(id__in=requested_course_keys),
|
||||
)
|
||||
|
||||
# User should have been messaged
|
||||
assert modeladmin_mock.message_user.call_count == 1
|
||||
assert modeladmin_mock.message_user.call_args.args[1] == (
|
||||
"Enqueued dumps for 2 course(s). Skipped 1 unchanged course(s)."
|
||||
)
|
||||
|
||||
# For enqueueing, graph should've been authenticated once, using configured settings.
|
||||
assert mock_neo4j_graph.call_count == 1
|
||||
assert mock_neo4j_graph.call_args.args == ()
|
||||
assert mock_neo4j_graph.call_args.kwargs == _coursegraph_connection
|
||||
|
||||
# No errors should've been logged.
|
||||
assert mock_log_exception.call_count == 0
|
||||
|
||||
# April course should have been skipped because the command was last run in May.
|
||||
# Dumps for June and July courses should have been enqueued.
|
||||
assert mock_apply_dump_task.call_count == 2
|
||||
actual_dumped_course_keys = {
|
||||
call_args.kwargs['kwargs']['course_key_string']
|
||||
for call_args in mock_apply_dump_task.call_args_list
|
||||
}
|
||||
expected_dumped_course_keys = {
|
||||
str(self.course_updated_in_june.id),
|
||||
str(self.course_updated_in_july.id),
|
||||
}
|
||||
assert actual_dumped_course_keys == expected_dumped_course_keys
|
||||
|
||||
@_configure_coursegraph_connection
|
||||
@_pretend_last_course_dump_was_may_2020
|
||||
@_patch_neo4j_graph
|
||||
@_patch_apply_dump_task
|
||||
@_patch_log_exception
|
||||
def test_dump_courses_overriding_cache(self, mock_log_exception, mock_apply_dump_task, mock_neo4j_graph):
|
||||
"""
|
||||
Test that dump_coursese_overriding_cach admin action dumps requested courses
|
||||
whether or not they been modified since the last dump to coursegraph.
|
||||
"""
|
||||
modeladmin_mock = mock.MagicMock()
|
||||
|
||||
# Request all courses except the August-updated one
|
||||
requested_course_keys = {
|
||||
str(self.course_updated_in_april.id),
|
||||
str(self.course_updated_in_june.id),
|
||||
str(self.course_updated_in_july.id),
|
||||
}
|
||||
admin.dump_courses_overriding_cache(
|
||||
modeladmin=modeladmin_mock,
|
||||
request=mock.MagicMock(),
|
||||
queryset=CourseOverview.objects.filter(id__in=requested_course_keys),
|
||||
)
|
||||
|
||||
# User should have been messaged
|
||||
assert modeladmin_mock.message_user.call_count == 1
|
||||
assert modeladmin_mock.message_user.call_args.args[1] == (
|
||||
"Enqueued dumps for 3 course(s)."
|
||||
)
|
||||
|
||||
# For enqueueing, graph should've been authenticated once, using configured settings.
|
||||
assert mock_neo4j_graph.call_count == 1
|
||||
assert mock_neo4j_graph.call_args.args == ()
|
||||
assert mock_neo4j_graph.call_args.kwargs == _coursegraph_connection
|
||||
|
||||
# No errors should've been logged.
|
||||
assert mock_log_exception.call_count == 0
|
||||
|
||||
# April, June, and July courses should have all been dumped.
|
||||
assert mock_apply_dump_task.call_count == 3
|
||||
actual_dumped_course_keys = {
|
||||
call_args.kwargs['kwargs']['course_key_string']
|
||||
for call_args in mock_apply_dump_task.call_args_list
|
||||
}
|
||||
expected_dumped_course_keys = {
|
||||
str(self.course_updated_in_april.id),
|
||||
str(self.course_updated_in_june.id),
|
||||
str(self.course_updated_in_july.id),
|
||||
}
|
||||
assert actual_dumped_course_keys == expected_dumped_course_keys
|
||||
|
||||
@_configure_coursegraph_connection
|
||||
@_pretend_last_course_dump_was_may_2020
|
||||
@_make_neo4j_graph_raise
|
||||
@_patch_apply_dump_task
|
||||
@_patch_log_exception
|
||||
def test_dump_courses_error(self, mock_log_exception, mock_apply_dump_task, mock_neo4j_graph):
|
||||
"""
|
||||
Test that the dump_courses admin action dumps messages the user if an error
|
||||
occurs when trying to enqueue course dumps.
|
||||
"""
|
||||
modeladmin_mock = mock.MagicMock()
|
||||
|
||||
# Request dump of all four courses.
|
||||
admin.dump_courses(
|
||||
modeladmin=modeladmin_mock,
|
||||
request=mock.MagicMock(),
|
||||
queryset=CourseOverview.objects.all()
|
||||
)
|
||||
|
||||
# Admin user should have been messaged about failure.
|
||||
assert modeladmin_mock.message_user.call_count == 1
|
||||
assert modeladmin_mock.message_user.call_args.args[1] == (
|
||||
"Error enqueueing dumps for 4 course(s): we failed to connect or something!"
|
||||
)
|
||||
|
||||
# For enqueueing, graph should've been authenticated once, using configured settings.
|
||||
assert mock_neo4j_graph.call_count == 1
|
||||
assert mock_neo4j_graph.call_args.args == ()
|
||||
assert mock_neo4j_graph.call_args.kwargs == _coursegraph_connection
|
||||
|
||||
# Exception should have been logged.
|
||||
assert mock_log_exception.call_count == 1
|
||||
assert "Failed to enqueue" in mock_log_exception.call_args.args[0]
|
||||
|
||||
# No courses should have been dumped.
|
||||
assert mock_apply_dump_task.call_count == 0
|
||||
@@ -110,6 +110,13 @@ from lms.envs.common import (
|
||||
|
||||
# Enterprise service settings
|
||||
ENTERPRISE_CATALOG_INTERNAL_ROOT_URL,
|
||||
ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_KEY,
|
||||
ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_SECRET,
|
||||
ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL,
|
||||
|
||||
# Blockstore
|
||||
BLOCKSTORE_USE_BLOCKSTORE_APP_API,
|
||||
BUNDLE_ASSET_STORAGE_SETTINGS,
|
||||
|
||||
# Methods to derive settings
|
||||
_make_mako_template_dirs,
|
||||
@@ -542,6 +549,30 @@ ENABLE_AUTHN_RESET_PASSWORD_HIBP_POLICY = False
|
||||
ENABLE_AUTHN_REGISTER_HIBP_POLICY = False
|
||||
HIBP_REGISTRATION_PASSWORD_FREQUENCY_THRESHOLD = 3
|
||||
|
||||
# .. toggle_name: ENABLE_AUTHN_LOGIN_NUDGE_HIBP_POLICY
|
||||
# .. toggle_implementation: DjangoSetting
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: When enabled, this toggle activates the use of the password validation
|
||||
# on Authn MFE's login.
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2022-03-29
|
||||
# .. toggle_target_removal_date: None
|
||||
# .. toggle_tickets: https://openedx.atlassian.net/browse/VAN-668
|
||||
ENABLE_AUTHN_LOGIN_NUDGE_HIBP_POLICY = False
|
||||
HIBP_LOGIN_NUDGE_PASSWORD_FREQUENCY_THRESHOLD = 3
|
||||
|
||||
# .. toggle_name: ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY
|
||||
# .. toggle_implementation: DjangoSetting
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: When enabled, this toggle activates the use of the password validation
|
||||
# on Authn MFE's login.
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2022-03-29
|
||||
# .. toggle_target_removal_date: None
|
||||
# .. toggle_tickets: https://openedx.atlassian.net/browse/VAN-667
|
||||
ENABLE_AUTHN_LOGIN_BLOCK_HIBP_POLICY = False
|
||||
HIBP_LOGIN_BLOCK_PASSWORD_FREQUENCY_THRESHOLD = 5
|
||||
|
||||
############################# SOCIAL MEDIA SHARING #############################
|
||||
SOCIAL_SHARING_SETTINGS = {
|
||||
# Note: Ensure 'CUSTOM_COURSE_URLS' has a matching value in lms/envs/common.py
|
||||
@@ -1611,7 +1642,7 @@ INSTALLED_APPS = [
|
||||
'openedx.core.djangoapps.self_paced',
|
||||
|
||||
# Coursegraph
|
||||
'openedx.core.djangoapps.coursegraph.apps.CoursegraphConfig',
|
||||
'cms.djangoapps.coursegraph.apps.CoursegraphConfig',
|
||||
|
||||
# Credit courses
|
||||
'openedx.core.djangoapps.credit.apps.CreditConfig',
|
||||
@@ -1722,6 +1753,9 @@ INSTALLED_APPS = [
|
||||
|
||||
# For edx ace template tags
|
||||
'edx_ace',
|
||||
|
||||
# Blockstore
|
||||
'blockstore.apps.bundles',
|
||||
]
|
||||
|
||||
|
||||
@@ -2078,6 +2112,7 @@ ENABLE_COMPREHENSIVE_THEMING = False
|
||||
|
||||
DATABASE_ROUTERS = [
|
||||
'openedx.core.lib.django_courseware_routers.StudentModuleHistoryExtendedRouter',
|
||||
'openedx.core.lib.blockstore_api.db_routers.BlockstoreRouter',
|
||||
]
|
||||
|
||||
############################ Cache Configuration ###############################
|
||||
@@ -2235,7 +2270,33 @@ POLICY_CHANGE_TASK_RATE_LIMIT = '300/h'
|
||||
# .. setting_default: value of LOW_PRIORITY_QUEUE
|
||||
# .. setting_description: The name of the Celery queue to which CourseGraph refresh
|
||||
# tasks will be sent
|
||||
COURSEGRAPH_JOB_QUEUE = LOW_PRIORITY_QUEUE
|
||||
COURSEGRAPH_JOB_QUEUE: str = LOW_PRIORITY_QUEUE
|
||||
|
||||
# .. setting_name: COURSEGRAPH_CONNECTION
|
||||
# .. setting_default: 'bolt+s://localhost:7687', in dictionary form.
|
||||
# .. setting_description: Dictionary specifying Neo4j connection parameters for
|
||||
# CourseGraph refresh. Accepted keys are protocol ('bolt' or 'http'),
|
||||
# secure (bool), host (str), port (int), user (str), and password (str).
|
||||
# See https://py2neo.org/2021.1/profiles.html#individual-settings for a
|
||||
# a description of each of those keys.
|
||||
COURSEGRAPH_CONNECTION: dict = {
|
||||
"protocol": "bolt",
|
||||
"secure": True,
|
||||
"host": "localhost",
|
||||
"port": 7687,
|
||||
"user": "neo4j",
|
||||
"password": None,
|
||||
}
|
||||
|
||||
# .. toggle_name: COURSEGRAPH_DUMP_COURSE_ON_PUBLISH
|
||||
# .. toggle_implementation: DjangoSetting
|
||||
# .. toggle_creation_date: 2022-01-27
|
||||
# .. toggle_use_cases: open_edx
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: Whether, upon publish, a course should automatically
|
||||
# be exported to Neo4j via the connection parameters specified in
|
||||
# `COURSEGRAPH_CONNECTION`.
|
||||
COURSEGRAPH_DUMP_COURSE_ON_PUBLISH: bool = False
|
||||
|
||||
########## Settings for video transcript migration tasks ############
|
||||
VIDEO_TRANSCRIPT_MIGRATIONS_JOB_QUEUE = DEFAULT_PRIORITY_QUEUE
|
||||
|
||||
@@ -213,6 +213,8 @@ IDA_LOGOUT_URI_LIST = [
|
||||
'http://localhost:18150/logout/', # credentials
|
||||
]
|
||||
|
||||
ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL = "http://edx.devstack.lms/oauth2"
|
||||
|
||||
############################### BLOCKSTORE #####################################
|
||||
BLOCKSTORE_API_URL = "http://edx.devstack.blockstore:18250/api/v1/"
|
||||
|
||||
@@ -256,6 +258,17 @@ FEATURES['ENABLE_PREREQUISITE_COURSES'] = True
|
||||
# (ref MST-637)
|
||||
PROCTORING_USER_OBFUSCATION_KEY = '85920908f28904ed733fe576320db18cabd7b6cd'
|
||||
|
||||
############## CourseGraph devstack settings ############################
|
||||
|
||||
COURSEGRAPH_CONNECTION: dict = {
|
||||
"protocol": "bolt",
|
||||
"secure": False,
|
||||
"host": "edx.devstack.coursegraph",
|
||||
"port": 7687,
|
||||
"user": "neo4j",
|
||||
"password": "edx",
|
||||
}
|
||||
|
||||
#################### Webpack Configuration Settings ##############################
|
||||
WEBPACK_LOADER['DEFAULT']['TIMEOUT'] = 5
|
||||
|
||||
@@ -267,3 +280,7 @@ SOCIAL_AUTH_EDX_OAUTH2_PUBLIC_URL_ROOT = 'http://localhost:18000' # used in bro
|
||||
|
||||
# Don't form the return redirect URL with HTTPS on devstack
|
||||
SOCIAL_AUTH_REDIRECT_IS_HTTPS = False
|
||||
|
||||
#################### Network configuration ####################
|
||||
# Devstack is directly exposed to the caller
|
||||
CLOSEST_CLIENT_IP_FROM_HEADERS = []
|
||||
|
||||
@@ -83,6 +83,7 @@ with codecs.open(CONFIG_FILE, encoding='utf-8') as f:
|
||||
'CELERY_QUEUES',
|
||||
'MKTG_URL_LINK_MAP',
|
||||
'MKTG_URL_OVERRIDES',
|
||||
'REST_FRAMEWORK',
|
||||
]
|
||||
for key in KEYS_WITH_MERGED_VALUES:
|
||||
if key in __config_copy__:
|
||||
@@ -602,7 +603,7 @@ EXPLICIT_QUEUES = {
|
||||
'queue': POLICY_CHANGE_GRADES_ROUTING_KEY},
|
||||
'cms.djangoapps.contentstore.tasks.update_search_index': {
|
||||
'queue': UPDATE_SEARCH_INDEX_JOB_QUEUE},
|
||||
'openedx.core.djangoapps.coursegraph.tasks.dump_course_to_neo4j': {
|
||||
'cms.djangoapps.coursegraph.tasks.dump_course_to_neo4j': {
|
||||
'queue': COURSEGRAPH_JOB_QUEUE},
|
||||
}
|
||||
|
||||
@@ -629,3 +630,6 @@ DISCUSSIONS_MICROFRONTEND_URL = ENV_TOKENS.get('DISCUSSIONS_MICROFRONTEND_URL',
|
||||
|
||||
################### Discussions micro frontend Feedback URL###################
|
||||
DISCUSSIONS_MFE_FEEDBACK_URL = ENV_TOKENS.get('DISCUSSIONS_MFE_FEEDBACK_URL', DISCUSSIONS_MFE_FEEDBACK_URL)
|
||||
|
||||
############## DRF overrides ##############
|
||||
REST_FRAMEWORK.update(ENV_TOKENS.get('REST_FRAMEWORK', {}))
|
||||
|
||||
@@ -27,6 +27,8 @@ from .common import *
|
||||
|
||||
# import settings from LMS for consistent behavior with CMS
|
||||
from lms.envs.test import ( # pylint: disable=wrong-import-order
|
||||
BLOCKSTORE_USE_BLOCKSTORE_APP_API,
|
||||
BLOCKSTORE_API_URL,
|
||||
COMPREHENSIVE_THEME_DIRS, # unimport:skip
|
||||
DEFAULT_FILE_STORAGE,
|
||||
ECOMMERCE_API_URL,
|
||||
@@ -40,7 +42,8 @@ from lms.envs.test import ( # pylint: disable=wrong-import-order
|
||||
REGISTRATION_EXTRA_FIELDS,
|
||||
GRADES_DOWNLOAD,
|
||||
SITE_NAME,
|
||||
WIKI_ENABLED
|
||||
WIKI_ENABLED,
|
||||
XBLOCK_RUNTIME_V2_EPHEMERAL_DATA_CACHE,
|
||||
)
|
||||
|
||||
|
||||
@@ -175,6 +178,12 @@ CACHES = {
|
||||
'course_structure_cache': {
|
||||
'BACKEND': 'django.core.cache.backends.dummy.DummyCache',
|
||||
},
|
||||
'blockstore': {
|
||||
'KEY_PREFIX': 'blockstore',
|
||||
'KEY_FUNCTION': 'common.djangoapps.util.memcache.safe_key',
|
||||
'LOCATION': 'edx_loc_mem_cache',
|
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
},
|
||||
}
|
||||
|
||||
############################### BLOCKSTORE #####################################
|
||||
@@ -182,6 +191,13 @@ CACHES = {
|
||||
RUN_BLOCKSTORE_TESTS = os.environ.get('EDXAPP_RUN_BLOCKSTORE_TESTS', 'no').lower() in ('true', 'yes', '1')
|
||||
BLOCKSTORE_API_URL = os.environ.get('EDXAPP_BLOCKSTORE_API_URL', "http://edx.devstack.blockstore-test:18251/api/v1/")
|
||||
BLOCKSTORE_API_AUTH_TOKEN = os.environ.get('EDXAPP_BLOCKSTORE_API_AUTH_TOKEN', 'edxapp-test-key')
|
||||
BUNDLE_ASSET_STORAGE_SETTINGS = dict(
|
||||
STORAGE_CLASS='django.core.files.storage.FileSystemStorage',
|
||||
STORAGE_KWARGS=dict(
|
||||
location=MEDIA_ROOT,
|
||||
base_url=MEDIA_URL,
|
||||
),
|
||||
)
|
||||
|
||||
################################# CELERY ######################################
|
||||
|
||||
@@ -267,12 +283,6 @@ TEST_ELASTICSEARCH_USE_SSL = os.environ.get(
|
||||
TEST_ELASTICSEARCH_HOST = os.environ.get('EDXAPP_TEST_ELASTICSEARCH_HOST', 'edx.devstack.elasticsearch710')
|
||||
TEST_ELASTICSEARCH_PORT = int(os.environ.get('EDXAPP_TEST_ELASTICSEARCH_PORT', '9200'))
|
||||
|
||||
############################# TEMPLATE CONFIGURATION #############################
|
||||
# Adds mako template dirs for content_libraries tests
|
||||
MAKO_TEMPLATE_DIRS_BASE.append(
|
||||
COMMON_ROOT / 'lib' / 'capa' / 'capa' / 'templates'
|
||||
)
|
||||
|
||||
########################## AUTHOR PERMISSION #######################
|
||||
FEATURES['ENABLE_CREATOR_GROUP'] = False
|
||||
|
||||
@@ -348,3 +358,7 @@ RESET_PASSWORD_API_RATELIMIT = '2/m'
|
||||
|
||||
############### Settings for proctoring ###############
|
||||
PROCTORING_USER_OBFUSCATION_KEY = 'test_key'
|
||||
|
||||
#################### Network configuration ####################
|
||||
# Tests are not behind any proxies
|
||||
CLOSEST_CLIENT_IP_FROM_HEADERS = []
|
||||
|
||||
@@ -299,6 +299,27 @@ function(Backbone, BaseView, _, MetadataModel, AbstractEditor, FileUpload, Uploa
|
||||
}
|
||||
});
|
||||
|
||||
Metadata.PublicAccess = Metadata.Option.extend({
|
||||
|
||||
templateName: 'metadata-option-public-access',
|
||||
|
||||
initialize: function() {
|
||||
Metadata.Option.prototype.initialize.apply(this, arguments);
|
||||
this.listenTo(this.model, 'change', this.updateUrlFieldVisibility);
|
||||
this.updateUrlFieldVisibility();
|
||||
},
|
||||
|
||||
updateUrlFieldVisibility: function() {
|
||||
const urlContainer = this.$el.find('.public-access-block-url-container');
|
||||
|
||||
if(this.getValueFromEditor()) {
|
||||
urlContainer.removeClass('is-hidden');
|
||||
} else {
|
||||
urlContainer.addClass('is-hidden');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Metadata.List = AbstractEditor.extend({
|
||||
|
||||
events: {
|
||||
|
||||
@@ -186,18 +186,19 @@ define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page
|
||||
modal = new EditXBlockModal(options);
|
||||
event.preventDefault();
|
||||
|
||||
// check if we want to launch with the new editors (behind waffle flag)
|
||||
var useNewTextEditor = this.$('.edit-button').attr("use-new-editor-text"),
|
||||
useNewVideoEditor = this.$('.edit-button').attr("use-new-editor-video"),
|
||||
useNewProblemEditor = this.$('.edit-button').attr("use-new-editor-problem"),
|
||||
blockType = xblockElement.find('.xblock').attr("data-block-type");
|
||||
if( (useNewTextEditor === "True" && blockType === "html") ||
|
||||
(useNewVideoEditor === "True" && blockType === "video") ||
|
||||
(useNewProblemEditor === "True" && blockType === "problem")
|
||||
) {
|
||||
var destinationUrl = this.$('.edit-button').attr("authoring_MFE_base_url") + '/' + blockType + '/' + encodeURI(xblockElement.find('.xblock').attr("data-usage-id"));
|
||||
window.location.href = destinationUrl;
|
||||
return;
|
||||
if(!options || options.view !== 'visibility_view' ){
|
||||
var useNewTextEditor = this.$('.edit-button').attr("use-new-editor-text"),
|
||||
useNewVideoEditor = this.$('.edit-button').attr("use-new-editor-video"),
|
||||
useNewProblemEditor = this.$('.edit-button').attr("use-new-editor-problem"),
|
||||
blockType = xblockElement.find('.xblock').attr("data-block-type");
|
||||
if( (useNewTextEditor === "True" && blockType === "html") ||
|
||||
(useNewVideoEditor === "True" && blockType === "video") ||
|
||||
(useNewProblemEditor === "True" && blockType === "problem")
|
||||
) {
|
||||
var destinationUrl = this.$('.edit-button').attr("authoring_MFE_base_url") + '/' + blockType + '/' + encodeURI(xblockElement.find('.xblock').attr("data-usage-id"));
|
||||
window.location.href = destinationUrl;
|
||||
return;
|
||||
}
|
||||
}
|
||||
modal.edit(xblockElement, this.model, {
|
||||
readOnlyView: !this.options.canEdit,
|
||||
|
||||
21
cms/templates/js/metadata-option-public-access.underscore
Normal file
21
cms/templates/js/metadata-option-public-access.underscore
Normal file
@@ -0,0 +1,21 @@
|
||||
<div class="wrapper-comp-setting">
|
||||
<label class="label setting-label" for="<%- uniqueId %>"><%- model.get('display_name') %></label>
|
||||
<select class="input setting-input" id="<%- uniqueId %>" name="<%- model.get('display_name') %>">
|
||||
<% _.each(model.get('options'), function(option) { %>
|
||||
<% if (option.display_name !== undefined) { %>
|
||||
<option value="<%- option['display_name'] %>"><%- option['display_name'] %></option>
|
||||
<% } else { %>
|
||||
<option value="<%- option %>"><%- option %></option>
|
||||
<% } %>
|
||||
<% }) %>
|
||||
</select>
|
||||
<button class="action setting-clear inactive" type="button" name="setting-clear" value="<%- gettext("Clear") %>" data-tooltip="<%- gettext("Clear") %>">
|
||||
<span class="icon fa fa-undo" aria-hidden="true"></span><span class="sr">"<%- gettext("Clear Value") %>"</span>
|
||||
</button>
|
||||
</div>
|
||||
<span class="tip setting-help"><%- model.get('help') %></span>
|
||||
<br>
|
||||
<div class="public-access-block-url-container is-hidden">
|
||||
<label class="label setting-label" for="<%- uniqueId %>"><%- gettext("URL") %></label>
|
||||
<input class="input setting-input" type="text" id="<%- uniqueId %>" value='<%- model.get("url") %>' readonly/>
|
||||
</div>
|
||||
@@ -16,7 +16,7 @@
|
||||
<%static:include path="js/${template_name}.underscore" />
|
||||
</script>
|
||||
% endfor
|
||||
% for template_name in ["metadata-number-entry", "metadata-string-entry", "metadata-option-entry", "metadata-list-entry", "metadata-dict-entry", "metadata-file-uploader-entry", "metadata-file-uploader-item"]:
|
||||
% for template_name in ["metadata-number-entry", "metadata-string-entry", "metadata-option-entry", "metadata-option-public-access", "metadata-list-entry", "metadata-dict-entry", "metadata-file-uploader-entry", "metadata-file-uploader-item"]:
|
||||
<script id="${template_name}" type="text/template">
|
||||
<%static:include path="js/${template_name}.underscore" />
|
||||
</script>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<%static:include path="js/metadata-editor.underscore" />
|
||||
</script>
|
||||
|
||||
% for template_name in ["metadata-number-entry", "metadata-string-entry", "metadata-option-entry", "metadata-list-entry", "metadata-dict-entry", "metadata-file-uploader-entry", "metadata-file-uploader-item"]:
|
||||
% for template_name in ["metadata-number-entry", "metadata-string-entry", "metadata-option-entry", "metadata-option-public-access", "metadata-list-entry", "metadata-dict-entry", "metadata-file-uploader-entry", "metadata-file-uploader-item"]:
|
||||
<script id="${template_name}" type="text/template">
|
||||
<%static:include path="js/${template_name}.underscore" />
|
||||
</script>
|
||||
|
||||
@@ -271,6 +271,8 @@ if settings.DEBUG:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
|
||||
|
||||
urlpatterns += static(
|
||||
settings.VIDEO_IMAGE_SETTINGS['STORAGE_KWARGS']['base_url'],
|
||||
document_root=settings.VIDEO_IMAGE_SETTINGS['STORAGE_KWARGS']['location']
|
||||
|
||||
@@ -10,7 +10,6 @@ from requests.exceptions import ConnectionError, Timeout # pylint: disable=rede
|
||||
from slumber.exceptions import SlumberBaseException
|
||||
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
from common.djangoapps.student.helpers import VERIFY_STATUS_APPROVED, VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED # lint-amnesty, pylint: disable=line-too-long
|
||||
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
|
||||
|
||||
DISPLAY_VERIFIED = "verified"
|
||||
@@ -21,7 +20,7 @@ DISPLAY_PROFESSIONAL = "professional"
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def enrollment_mode_display(mode, verification_status, course_id):
|
||||
def enrollment_mode_display(mode, course_id):
|
||||
""" Select appropriate display strings and CSS classes.
|
||||
|
||||
Uses mode and verification status to select appropriate display strings and CSS classes
|
||||
@@ -38,19 +37,13 @@ def enrollment_mode_display(mode, verification_status, course_id):
|
||||
image_alt = ''
|
||||
enrollment_title = ''
|
||||
enrollment_value = ''
|
||||
display_mode = _enrollment_mode_display(mode, verification_status, course_id)
|
||||
display_mode = _enrollment_mode_display(mode, course_id)
|
||||
|
||||
if display_mode == DISPLAY_VERIFIED:
|
||||
if settings.FEATURES.get('ENABLE_INTEGRITY_SIGNATURE') or verification_status == VERIFY_STATUS_APPROVED:
|
||||
enrollment_title = _("You're enrolled as a verified student")
|
||||
enrollment_value = _("Verified")
|
||||
show_image = True
|
||||
image_alt = _("ID Verified Ribbon/Badge")
|
||||
elif verification_status in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED]:
|
||||
enrollment_title = _("Your verification is pending")
|
||||
enrollment_value = _("Verified: Pending Verification")
|
||||
show_image = True
|
||||
image_alt = _("ID verification pending")
|
||||
enrollment_title = _("You're enrolled as a verified student")
|
||||
enrollment_value = _("Verified")
|
||||
show_image = True
|
||||
image_alt = _("ID Verified Ribbon/Badge")
|
||||
elif display_mode == DISPLAY_HONOR:
|
||||
enrollment_title = _("You're enrolled as an honor code student")
|
||||
enrollment_value = _("Honor Code")
|
||||
@@ -67,7 +60,7 @@ def enrollment_mode_display(mode, verification_status, course_id):
|
||||
}
|
||||
|
||||
|
||||
def _enrollment_mode_display(enrollment_mode, verification_status, course_id):
|
||||
def _enrollment_mode_display(enrollment_mode, course_id):
|
||||
"""Checking enrollment mode and status and returns the display mode
|
||||
Args:
|
||||
enrollment_mode (str): enrollment mode.
|
||||
@@ -79,15 +72,9 @@ def _enrollment_mode_display(enrollment_mode, verification_status, course_id):
|
||||
course_mode_slugs = [mode.slug for mode in CourseMode.modes_for_course(course_id)]
|
||||
|
||||
if enrollment_mode == CourseMode.VERIFIED:
|
||||
if (
|
||||
settings.FEATURES.get('ENABLE_INTEGRITY_SIGNATURE')
|
||||
or verification_status in [VERIFY_STATUS_NEED_TO_VERIFY, VERIFY_STATUS_SUBMITTED, VERIFY_STATUS_APPROVED]
|
||||
):
|
||||
display_mode = DISPLAY_VERIFIED
|
||||
elif DISPLAY_HONOR in course_mode_slugs:
|
||||
display_mode = DISPLAY_VERIFIED
|
||||
if DISPLAY_HONOR in course_mode_slugs:
|
||||
display_mode = DISPLAY_HONOR
|
||||
else:
|
||||
display_mode = DISPLAY_AUDIT
|
||||
elif enrollment_mode in [CourseMode.PROFESSIONAL, CourseMode.NO_ID_PROFESSIONAL_MODE]:
|
||||
display_mode = DISPLAY_PROFESSIONAL
|
||||
else:
|
||||
|
||||
@@ -138,6 +138,8 @@ class CourseMode(models.Model):
|
||||
EXECUTIVE_EDUCATION = 'executive-education'
|
||||
PAID_EXECUTIVE_EDUCATION = 'paid-executive-education'
|
||||
UNPAID_EXECUTIVE_EDUCATION = 'unpaid-executive-education'
|
||||
PAID_BOOTCAMP = 'paid-bootcamp'
|
||||
UNPAID_BOOTCAMP = 'unpaid-bootcamp'
|
||||
|
||||
DEFAULT_MODE = Mode(
|
||||
settings.COURSE_MODE_DEFAULTS['slug'],
|
||||
@@ -162,15 +164,17 @@ class CourseMode(models.Model):
|
||||
MASTERS,
|
||||
EXECUTIVE_EDUCATION,
|
||||
PAID_EXECUTIVE_EDUCATION,
|
||||
UNPAID_EXECUTIVE_EDUCATION
|
||||
UNPAID_EXECUTIVE_EDUCATION,
|
||||
PAID_BOOTCAMP,
|
||||
UNPAID_BOOTCAMP
|
||||
]
|
||||
|
||||
# Modes utilized for audit/free enrollments
|
||||
AUDIT_MODES = [AUDIT, HONOR, UNPAID_EXECUTIVE_EDUCATION]
|
||||
AUDIT_MODES = [AUDIT, HONOR, UNPAID_EXECUTIVE_EDUCATION, UNPAID_BOOTCAMP]
|
||||
|
||||
# Modes that allow a student to pursue a verified certificate
|
||||
VERIFIED_MODES = [
|
||||
VERIFIED, PROFESSIONAL, MASTERS, EXECUTIVE_EDUCATION, PAID_EXECUTIVE_EDUCATION
|
||||
VERIFIED, PROFESSIONAL, MASTERS, EXECUTIVE_EDUCATION, PAID_EXECUTIVE_EDUCATION, PAID_BOOTCAMP
|
||||
]
|
||||
|
||||
# Modes that allow a student to pursue a non-verified certificate
|
||||
@@ -181,7 +185,8 @@ class CourseMode(models.Model):
|
||||
|
||||
# Modes that are eligible to purchase credit
|
||||
CREDIT_ELIGIBLE_MODES = [
|
||||
VERIFIED, PROFESSIONAL, NO_ID_PROFESSIONAL_MODE, EXECUTIVE_EDUCATION, PAID_EXECUTIVE_EDUCATION
|
||||
VERIFIED, PROFESSIONAL, NO_ID_PROFESSIONAL_MODE, EXECUTIVE_EDUCATION, PAID_EXECUTIVE_EDUCATION,
|
||||
PAID_BOOTCAMP
|
||||
]
|
||||
|
||||
# Modes for which certificates/programs may need to be updated
|
||||
|
||||
@@ -11,7 +11,6 @@ from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils.timezone import now
|
||||
@@ -342,49 +341,15 @@ class CourseModeModelTest(TestCase):
|
||||
assert exc.messages == ['Professional education modes are not allowed to have expiration_datetime set.']
|
||||
|
||||
@ddt.data(
|
||||
("verified", "verify_need_to_verify", True),
|
||||
("verified", "verify_submitted", True),
|
||||
("verified", "verify_approved", True),
|
||||
("verified", 'dummy', True),
|
||||
("verified", None, True),
|
||||
('honor', None, True),
|
||||
('honor', 'dummy', True),
|
||||
('audit', None, True),
|
||||
('professional', None, True),
|
||||
('no-id-professional', None, True),
|
||||
('no-id-professional', 'dummy', True),
|
||||
("verified", "verify_need_to_verify", False),
|
||||
("verified", "verify_submitted", False),
|
||||
("verified", "verify_approved", False),
|
||||
("verified", 'dummy', False),
|
||||
("verified", None, False),
|
||||
('honor', None, False),
|
||||
('honor', 'dummy', False),
|
||||
('audit', None, False),
|
||||
('professional', None, False),
|
||||
('no-id-professional', None, False),
|
||||
('no-id-professional', 'dummy', False)
|
||||
"verified",
|
||||
"honor",
|
||||
"audit",
|
||||
"professional",
|
||||
"no-id-professional",
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_enrollment_mode_display(self, mode, verification_status, enable_integrity_signature):
|
||||
|
||||
with patch.dict(settings.FEATURES, ENABLE_INTEGRITY_SIGNATURE=enable_integrity_signature):
|
||||
if mode == "verified":
|
||||
assert enrollment_mode_display(mode, verification_status, self.course_key) ==\
|
||||
self._enrollment_display_modes_dicts(mode, verification_status, enable_integrity_signature)
|
||||
assert enrollment_mode_display(mode, verification_status, self.course_key) ==\
|
||||
self._enrollment_display_modes_dicts(mode, verification_status, enable_integrity_signature)
|
||||
assert enrollment_mode_display(mode, verification_status, self.course_key) ==\
|
||||
self._enrollment_display_modes_dicts(mode, verification_status, enable_integrity_signature)
|
||||
elif mode == "honor":
|
||||
assert enrollment_mode_display(mode, verification_status, self.course_key) ==\
|
||||
self._enrollment_display_modes_dicts(mode, mode, enable_integrity_signature)
|
||||
elif mode == "audit":
|
||||
assert enrollment_mode_display(mode, verification_status, self.course_key) ==\
|
||||
self._enrollment_display_modes_dicts(mode, mode, enable_integrity_signature)
|
||||
elif mode == "professional":
|
||||
assert enrollment_mode_display(mode, verification_status, self.course_key) ==\
|
||||
self._enrollment_display_modes_dicts(mode, mode, enable_integrity_signature)
|
||||
def test_enrollment_mode_display(self, mode):
|
||||
assert enrollment_mode_display(mode, self.course_key) == \
|
||||
self._enrollment_display_modes_dicts(mode)
|
||||
|
||||
@ddt.data(
|
||||
(['honor', 'verified', 'credit'], ['honor', 'verified']),
|
||||
@@ -408,30 +373,23 @@ class CourseModeModelTest(TestCase):
|
||||
all_modes = CourseMode.modes_for_course_dict(self.course_key, only_selectable=False)
|
||||
self.assertCountEqual(list(all_modes.keys()), available_modes)
|
||||
|
||||
def _enrollment_display_modes_dicts(self, mode, dict_type, enable_flag):
|
||||
def _enrollment_display_modes_dicts(self, mode):
|
||||
"""
|
||||
Helper function to generate the enrollment display mode dict.
|
||||
"""
|
||||
dict_keys = ['enrollment_title', 'enrollment_value', 'show_image', 'image_alt', 'display_mode']
|
||||
display_values = {
|
||||
"verify_need_to_verify": ["Your verification is pending", "Verified: Pending Verification", True,
|
||||
'ID verification pending', 'verified'],
|
||||
"verify_approved": ["You're enrolled as a verified student", "Verified", True, 'ID Verified Ribbon/Badge',
|
||||
'verified'],
|
||||
"verify_none": ["", "", False, '', 'audit'],
|
||||
"verified": ["You're enrolled as a verified student", "Verified", True, 'ID Verified Ribbon/Badge',
|
||||
'verified'],
|
||||
"honor": ["You're enrolled as an honor code student", "Honor Code", False, '', 'honor'],
|
||||
"audit": ["", "", False, '', 'audit'],
|
||||
"professional": ["You're enrolled as a professional education student", "Professional Ed", False, '',
|
||||
'professional']
|
||||
'professional'],
|
||||
"no-id-professional": ["You're enrolled as a professional education student", "Professional Ed", False, '',
|
||||
'professional'],
|
||||
}
|
||||
if mode == 'verified' and enable_flag:
|
||||
return dict(list(zip(dict_keys, display_values.get('verify_approved'))))
|
||||
elif dict_type in ['verify_need_to_verify', 'verify_submitted']:
|
||||
return dict(list(zip(dict_keys, display_values.get('verify_need_to_verify'))))
|
||||
elif dict_type is None or dict_type == 'dummy':
|
||||
return dict(list(zip(dict_keys, display_values.get('verify_none'))))
|
||||
else:
|
||||
return dict(list(zip(dict_keys, display_values.get(dict_type))))
|
||||
|
||||
return dict(list(zip(dict_keys, display_values.get(mode))))
|
||||
|
||||
def test_expiration_datetime_explicitly_set(self):
|
||||
""" Verify that setting the expiration_date property sets the explicit flag. """
|
||||
|
||||
@@ -104,10 +104,8 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
|
||||
# Check whether we were correctly redirected
|
||||
if redirect:
|
||||
if has_started:
|
||||
self.assertRedirects(
|
||||
response, reverse('openedx.course_experience.course_home', kwargs={'course_id': course.id}),
|
||||
target_status_code=302, # for follow-on redirection to MFE (ideally we'd just be sent there first)
|
||||
)
|
||||
mfe_url = f'http://learning-mfe/course/{course.id}/home'
|
||||
self.assertRedirects(response, mfe_url, fetch_redirect_response=False)
|
||||
else:
|
||||
self.assertRedirects(response, reverse('dashboard'))
|
||||
else:
|
||||
|
||||
@@ -40,6 +40,7 @@ from openedx.core.djangoapps.enrollments.permissions import ENROLL_IN_COURSE
|
||||
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
|
||||
from openedx.features.course_duration_limits.models import CourseDurationLimitConfig
|
||||
from openedx.features.course_duration_limits.access import get_user_course_duration, get_user_course_expiration_date
|
||||
from openedx.features.course_experience import course_home_url
|
||||
from openedx.features.enterprise_support.api import enterprise_customer_for_request
|
||||
from common.djangoapps.student.models import CourseEnrollment
|
||||
from common.djangoapps.util.db import outer_atomic
|
||||
@@ -413,7 +414,7 @@ class ChooseModeView(View):
|
||||
302 to the course if possible or the dashboard if not.
|
||||
"""
|
||||
if course.has_started() or user.is_staff:
|
||||
return redirect(reverse('openedx.course_experience.course_home', kwargs={'course_id': course_key}))
|
||||
return redirect(course_home_url(course_key))
|
||||
else:
|
||||
return redirect(reverse('dashboard'))
|
||||
|
||||
|
||||
@@ -2840,7 +2840,7 @@ class LinkedInAddToProfileConfiguration(ConfigurationModel):
|
||||
),
|
||||
)
|
||||
|
||||
def is_enabled(self, *key_fields):
|
||||
def is_enabled(self, *key_fields): # pylint: disable=arguments-differ
|
||||
"""
|
||||
Checks both the model itself and share_settings to see if LinkedIn Add to Profile is enabled
|
||||
"""
|
||||
|
||||
@@ -29,7 +29,6 @@ from common.djangoapps.student.views import (
|
||||
)
|
||||
from common.djangoapps.third_party_auth.views import inactive_user_view
|
||||
from common.djangoapps.util.testing import EventTestMixin
|
||||
from lms.djangoapps.verify_student.services import IDVerificationService
|
||||
from openedx.core.djangoapps.ace_common.tests.mixins import EmailTemplateTagMixin
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx.core.djangoapps.theming.tests.test_util import with_comprehensive_theme
|
||||
@@ -237,7 +236,6 @@ class ProctoringRequirementsEmailTests(EmailTemplateTagMixin, ModuleStoreTestCas
|
||||
send_proctoring_requirements_email(context)
|
||||
self._assert_email()
|
||||
|
||||
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_INTEGRITY_SIGNATURE': True})
|
||||
def test_send_proctoring_requirements_email_honor(self):
|
||||
self.course = CourseFactory(
|
||||
display_name='honor code on course',
|
||||
@@ -259,13 +257,10 @@ class ProctoringRequirementsEmailTests(EmailTemplateTagMixin, ModuleStoreTestCas
|
||||
|
||||
assert message.subject == f"Proctoring requirements for {self.course.display_name}"
|
||||
|
||||
appears, does_not_appear = self._get_fragments()
|
||||
appears = self._get_fragments()
|
||||
for fragment in appears:
|
||||
self.assertIn(fragment, text)
|
||||
self.assertIn(fragment, html)
|
||||
for fragment in does_not_appear:
|
||||
self.assertNotIn(fragment, text)
|
||||
self.assertNotIn(fragment, html)
|
||||
|
||||
def _get_fragments(self):
|
||||
"""
|
||||
@@ -273,7 +268,6 @@ class ProctoringRequirementsEmailTests(EmailTemplateTagMixin, ModuleStoreTestCas
|
||||
"""
|
||||
course_module = modulestore().get_course(self.course.id)
|
||||
proctoring_provider = capwords(course_module.proctoring_provider.replace('_', ' '))
|
||||
id_verification_url = IDVerificationService.get_verify_location()
|
||||
fragments = [
|
||||
(
|
||||
"You are enrolled in {} at {}. This course contains proctored exams.".format(
|
||||
@@ -292,14 +286,7 @@ class ProctoringRequirementsEmailTests(EmailTemplateTagMixin, ModuleStoreTestCas
|
||||
),
|
||||
settings.PROCTORING_SETTINGS.get('LINK_URLS', {}).get('faq', ''),
|
||||
]
|
||||
idv_fragments = [
|
||||
escape("Before taking a graded proctored exam, you must have approved ID verification photos."),
|
||||
id_verification_url,
|
||||
]
|
||||
if not settings.FEATURES.get('ENABLE_INTEGRITY_SIGNATURE'):
|
||||
fragments.extend(idv_fragments)
|
||||
return (fragments, [])
|
||||
return (fragments, idv_fragments)
|
||||
return fragments
|
||||
|
||||
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', "Test only valid in LMS")
|
||||
|
||||
@@ -1,469 +0,0 @@
|
||||
"""Tests for per-course verification status on the dashboard. """
|
||||
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
import ddt
|
||||
import six
|
||||
from django.conf import settings
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils.timezone import now
|
||||
from pytz import UTC
|
||||
|
||||
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
|
||||
from common.djangoapps.student.helpers import (
|
||||
VERIFY_STATUS_APPROVED,
|
||||
VERIFY_STATUS_MISSED_DEADLINE,
|
||||
VERIFY_STATUS_NEED_TO_REVERIFY,
|
||||
VERIFY_STATUS_NEED_TO_VERIFY,
|
||||
VERIFY_STATUS_RESUBMITTED,
|
||||
VERIFY_STATUS_SUBMITTED
|
||||
)
|
||||
from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from common.djangoapps.util.testing import UrlResetMixin
|
||||
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline
|
||||
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
|
||||
|
||||
|
||||
@patch.dict(settings.FEATURES, {'AUTOMATIC_VERIFY_STUDENT_IDENTITY_FOR_TESTING': True})
|
||||
@override_settings(PLATFORM_NAME='edX')
|
||||
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
|
||||
@ddt.ddt
|
||||
class TestCourseVerificationStatus(UrlResetMixin, ModuleStoreTestCase):
|
||||
"""Tests for per-course verification status on the dashboard. """
|
||||
|
||||
PAST = 'past'
|
||||
FUTURE = 'future'
|
||||
DATES = {
|
||||
PAST: datetime.now(UTC) - timedelta(days=5),
|
||||
FUTURE: datetime.now(UTC) + timedelta(days=5),
|
||||
None: None,
|
||||
}
|
||||
|
||||
URLCONF_MODULES = ['lms.djangoapps.verify_student.urls']
|
||||
|
||||
def setUp(self):
|
||||
# Invoke UrlResetMixin
|
||||
super().setUp()
|
||||
|
||||
self.user = UserFactory(password="edx")
|
||||
self.course = CourseFactory.create()
|
||||
success = self.client.login(username=self.user.username, password="edx")
|
||||
assert success, 'Did not log in successfully'
|
||||
self.dashboard_url = reverse('dashboard')
|
||||
|
||||
def test_enrolled_as_non_verified(self):
|
||||
self._setup_mode_and_enrollment(None, "audit")
|
||||
|
||||
# Expect that the course appears on the dashboard
|
||||
# without any verification messaging
|
||||
self._assert_course_verification_status(None)
|
||||
|
||||
def test_no_verified_mode_available(self):
|
||||
# Enroll the student in a verified mode, but don't
|
||||
# create any verified course mode.
|
||||
# This won't happen unless someone deletes a course mode,
|
||||
# but if so, make sure we handle it gracefully.
|
||||
CourseEnrollmentFactory(
|
||||
course_id=self.course.id,
|
||||
user=self.user,
|
||||
mode="verified"
|
||||
)
|
||||
|
||||
# Continue to show the student as needing to verify.
|
||||
# The student is enrolled as verified, so we might as well let them
|
||||
# complete verification. We'd need to change their enrollment mode
|
||||
# anyway to ensure that the student is issued the correct kind of certificate.
|
||||
self._assert_course_verification_status(VERIFY_STATUS_NEED_TO_VERIFY)
|
||||
|
||||
def test_need_to_verify_no_expiration(self):
|
||||
self._setup_mode_and_enrollment(None, "verified")
|
||||
|
||||
# Since the student has not submitted a photo verification,
|
||||
# the student should see a "need to verify" message
|
||||
self._assert_course_verification_status(VERIFY_STATUS_NEED_TO_VERIFY)
|
||||
|
||||
# Start the photo verification process, but do not submit
|
||||
# Since we haven't submitted the verification, we should still
|
||||
# see the "need to verify" message
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
self._assert_course_verification_status(VERIFY_STATUS_NEED_TO_VERIFY)
|
||||
|
||||
# Upload images, but don't submit to the verification service
|
||||
# We should still need to verify
|
||||
attempt.mark_ready()
|
||||
self._assert_course_verification_status(VERIFY_STATUS_NEED_TO_VERIFY)
|
||||
|
||||
def test_need_to_verify_expiration(self):
|
||||
self._setup_mode_and_enrollment(self.DATES[self.FUTURE], "verified")
|
||||
response = self.client.get(self.dashboard_url)
|
||||
self.assertContains(response, self.BANNER_ALT_MESSAGES[VERIFY_STATUS_NEED_TO_VERIFY])
|
||||
self.assertContains(response, "You only have 4 days left to verify for this course.")
|
||||
|
||||
@ddt.data(None, FUTURE)
|
||||
def test_waiting_approval(self, expiration):
|
||||
self._setup_mode_and_enrollment(self.DATES[expiration], "verified")
|
||||
|
||||
# The student has submitted a photo verification
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
|
||||
# Now the student should see a "verification submitted" message
|
||||
self._assert_course_verification_status(VERIFY_STATUS_SUBMITTED)
|
||||
|
||||
@ddt.data(None, FUTURE)
|
||||
def test_fully_verified(self, expiration):
|
||||
self._setup_mode_and_enrollment(self.DATES[expiration], "verified")
|
||||
|
||||
# The student has an approved verification
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
attempt.approve()
|
||||
|
||||
# Expect that the successfully verified message is shown
|
||||
self._assert_course_verification_status(VERIFY_STATUS_APPROVED)
|
||||
|
||||
# Check that the "verification good until" date is displayed
|
||||
response = self.client.get(self.dashboard_url)
|
||||
self.assertContains(response, attempt.expiration_datetime.strftime("%m/%d/%Y"))
|
||||
|
||||
@patch("lms.djangoapps.verify_student.services.is_verification_expiring_soon")
|
||||
def test_verify_resubmit_button_on_dashboard(self, mock_expiry):
|
||||
mock_expiry.return_value = True
|
||||
SoftwareSecurePhotoVerification.objects.create(
|
||||
user=self.user,
|
||||
status='approved',
|
||||
expiration_date=now() + timedelta(days=1)
|
||||
)
|
||||
response = self.client.get(self.dashboard_url)
|
||||
self.assertContains(response, "Resubmit Verification")
|
||||
|
||||
mock_expiry.return_value = False
|
||||
response = self.client.get(self.dashboard_url)
|
||||
self.assertNotContains(response, "Resubmit Verification")
|
||||
|
||||
def test_missed_verification_deadline(self):
|
||||
# Expiration date in the past
|
||||
self._setup_mode_and_enrollment(self.DATES[self.PAST], "verified")
|
||||
|
||||
# The student does NOT have an approved verification
|
||||
# so the status should show that the student missed the deadline.
|
||||
self._assert_course_verification_status(VERIFY_STATUS_MISSED_DEADLINE)
|
||||
|
||||
def test_missed_verification_deadline_verification_was_expired(self):
|
||||
# Expiration date in the past
|
||||
self._setup_mode_and_enrollment(self.DATES[self.PAST], "verified")
|
||||
|
||||
# Create a verification, but the expiration date of the verification
|
||||
# occurred before the deadline.
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
attempt.approve()
|
||||
attempt.expiration_date = self.DATES[self.PAST] - timedelta(days=900)
|
||||
attempt.save()
|
||||
|
||||
# The student didn't have an approved verification at the deadline,
|
||||
# so we should show that the student missed the deadline.
|
||||
self._assert_course_verification_status(VERIFY_STATUS_MISSED_DEADLINE)
|
||||
|
||||
def test_missed_verification_deadline_but_later_verified(self):
|
||||
# Expiration date in the past
|
||||
self._setup_mode_and_enrollment(self.DATES[self.PAST], "verified")
|
||||
|
||||
# Successfully verify, but after the deadline has already passed
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
attempt.approve()
|
||||
attempt.expiration_date = self.DATES[self.PAST] - timedelta(days=900)
|
||||
attempt.save()
|
||||
|
||||
# The student didn't have an approved verification at the deadline,
|
||||
# so we should show that the student missed the deadline.
|
||||
self._assert_course_verification_status(VERIFY_STATUS_MISSED_DEADLINE)
|
||||
|
||||
def test_verification_denied(self):
|
||||
# Expiration date in the future
|
||||
self._setup_mode_and_enrollment(self.DATES[self.FUTURE], "verified")
|
||||
|
||||
# Create a verification with the specified status
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
attempt.deny("Not valid!")
|
||||
|
||||
# Since this is not a status we handle, don't display any
|
||||
# messaging relating to verification
|
||||
self._assert_course_verification_status(None)
|
||||
|
||||
def test_verification_error(self):
|
||||
# Expiration date in the future
|
||||
self._setup_mode_and_enrollment(self.DATES[self.FUTURE], "verified")
|
||||
|
||||
# Create a verification with the specified status
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.status = "must_retry"
|
||||
attempt.system_error("Error!")
|
||||
|
||||
# Since this is not a status we handle, don't display any
|
||||
# messaging relating to verification
|
||||
self._assert_course_verification_status(None)
|
||||
|
||||
@override_settings(VERIFY_STUDENT={"DAYS_GOOD_FOR": 5, "EXPIRING_SOON_WINDOW": 10})
|
||||
def test_verification_will_expire_by_deadline(self):
|
||||
# Expiration date in the future
|
||||
self._setup_mode_and_enrollment(self.DATES[self.FUTURE], "verified")
|
||||
|
||||
# Create a verification attempt that:
|
||||
# 1) Is current (submitted in the last year)
|
||||
# 2) Will expire by the deadline for the course
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
attempt.approve()
|
||||
attempt.save()
|
||||
|
||||
# Verify that learner can submit photos if verification is set to expire soon.
|
||||
self._assert_course_verification_status(VERIFY_STATUS_NEED_TO_REVERIFY)
|
||||
|
||||
@override_settings(VERIFY_STUDENT={"DAYS_GOOD_FOR": 5, "EXPIRING_SOON_WINDOW": 10})
|
||||
def test_reverification_submitted_with_current_approved_verificaiton(self):
|
||||
# Expiration date in the future
|
||||
self._setup_mode_and_enrollment(self.DATES[self.FUTURE], "verified")
|
||||
|
||||
# Create a verification attempt that is approved but expiring soon
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
attempt.approve()
|
||||
attempt.save()
|
||||
|
||||
# Verify that learner can submit photos if verification is set to expire soon.
|
||||
self._assert_course_verification_status(VERIFY_STATUS_NEED_TO_REVERIFY)
|
||||
|
||||
# Submit photos for reverification
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
|
||||
# Expect that learner has submitted photos for reverfication and their
|
||||
# previous verification is set to expired soon.
|
||||
self._assert_course_verification_status(VERIFY_STATUS_RESUBMITTED)
|
||||
|
||||
def test_verification_occurred_after_deadline(self):
|
||||
# Expiration date in the past
|
||||
self._setup_mode_and_enrollment(self.DATES[self.PAST], "verified")
|
||||
|
||||
# The deadline has passed, and we've asked the student
|
||||
# to reverify (through the support team).
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
|
||||
# Expect that the user's displayed enrollment mode is verified.
|
||||
self._assert_course_verification_status(VERIFY_STATUS_APPROVED)
|
||||
|
||||
def test_with_two_verifications(self):
|
||||
# checking if a user has two verification and but most recent verification course deadline is expired
|
||||
|
||||
self._setup_mode_and_enrollment(self.DATES[self.FUTURE], "verified")
|
||||
|
||||
# The student has an approved verification
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
attempt.approve()
|
||||
# Making created at to previous date to differentiate with 2nd attempt.
|
||||
attempt.created_at = datetime.now(UTC) - timedelta(days=1)
|
||||
attempt.save()
|
||||
|
||||
# Expect that the successfully verified message is shown
|
||||
self._assert_course_verification_status(VERIFY_STATUS_APPROVED)
|
||||
|
||||
# Check that the "verification good until" date is displayed
|
||||
response = self.client.get(self.dashboard_url)
|
||||
self.assertContains(response, attempt.expiration_datetime.strftime("%m/%d/%Y"))
|
||||
|
||||
# Adding another verification with different course.
|
||||
# Its created_at is greater than course deadline.
|
||||
course2 = CourseFactory.create()
|
||||
CourseModeFactory.create(
|
||||
course_id=course2.id,
|
||||
mode_slug="verified",
|
||||
expiration_datetime=self.DATES[self.PAST]
|
||||
)
|
||||
CourseEnrollmentFactory(
|
||||
course_id=course2.id,
|
||||
user=self.user,
|
||||
mode="verified"
|
||||
)
|
||||
|
||||
# The student has an approved verification
|
||||
attempt2 = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt2.mark_ready()
|
||||
attempt2.submit()
|
||||
attempt2.approve()
|
||||
attempt2.save()
|
||||
|
||||
# Mark the attemp2 as approved so its date will appear on dasboard.
|
||||
self._assert_course_verification_status(VERIFY_STATUS_APPROVED)
|
||||
response2 = self.client.get(self.dashboard_url)
|
||||
self.assertContains(response2, attempt2.expiration_datetime.strftime("%m/%d/%Y"))
|
||||
self.assertContains(response2, attempt2.expiration_datetime.strftime("%m/%d/%Y"), count=2)
|
||||
|
||||
@patch.dict(settings.FEATURES, {'ENABLE_INTEGRITY_SIGNATURE': True})
|
||||
@ddt.data(
|
||||
None,
|
||||
'past',
|
||||
'future'
|
||||
)
|
||||
def test_verify_message_idv_disabled(self, deadline_key):
|
||||
if deadline_key:
|
||||
self._setup_mode_and_enrollment(self.DATES[deadline_key], "verified")
|
||||
else:
|
||||
self._setup_mode_and_enrollment(None, "verified")
|
||||
|
||||
self._assert_course_verification_status(None, "verified")
|
||||
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
self._assert_course_verification_status(None, "verified")
|
||||
attempt.mark_ready()
|
||||
self._assert_course_verification_status(None, "verified")
|
||||
attempt.submit()
|
||||
self._assert_course_verification_status(None, "verified")
|
||||
attempt.approve()
|
||||
self._assert_course_verification_status(None, "verified")
|
||||
attempt.expiration_date = self.DATES[self.PAST] - timedelta(days=900)
|
||||
attempt.save()
|
||||
self._assert_course_verification_status(None, "verified")
|
||||
|
||||
@ddt.data(True, False)
|
||||
def test_integrity_disables_sidebar(self, enable_integrity_signature):
|
||||
self._setup_mode_and_enrollment(None, "verified")
|
||||
|
||||
#no sidebar when no IDV yet
|
||||
response = self.client.get(self.dashboard_url)
|
||||
self.assertNotContains(response, "profile-sidebar")
|
||||
|
||||
# The student has an approved verification
|
||||
attempt = SoftwareSecurePhotoVerification.objects.create(user=self.user)
|
||||
attempt.mark_ready()
|
||||
attempt.submit()
|
||||
attempt.approve()
|
||||
|
||||
# sidebar only appears after IDV if integrity signature setting is not on
|
||||
with patch.dict(settings.FEATURES, {'ENABLE_INTEGRITY_SIGNATURE': enable_integrity_signature}):
|
||||
response = self.client.get(self.dashboard_url)
|
||||
if enable_integrity_signature:
|
||||
self.assertNotContains(response, "profile-sidebar")
|
||||
else:
|
||||
self.assertContains(response, "profile-sidebar")
|
||||
|
||||
def _setup_mode_and_enrollment(self, deadline, enrollment_mode):
|
||||
"""Create a course mode and enrollment.
|
||||
|
||||
Arguments:
|
||||
deadline (datetime): The deadline for submitting your verification.
|
||||
enrollment_mode (str): The mode of the enrollment.
|
||||
|
||||
"""
|
||||
CourseModeFactory.create(
|
||||
course_id=self.course.id,
|
||||
mode_slug="verified",
|
||||
expiration_datetime=deadline
|
||||
)
|
||||
CourseEnrollmentFactory(
|
||||
course_id=self.course.id,
|
||||
user=self.user,
|
||||
mode=enrollment_mode
|
||||
)
|
||||
VerificationDeadline.set_deadline(self.course.id, deadline)
|
||||
|
||||
BANNER_ALT_MESSAGES = {
|
||||
VERIFY_STATUS_NEED_TO_VERIFY: "ID verification pending",
|
||||
VERIFY_STATUS_SUBMITTED: "ID verification pending",
|
||||
VERIFY_STATUS_APPROVED: "ID Verified Ribbon/Badge",
|
||||
}
|
||||
|
||||
NOTIFICATION_MESSAGES = {
|
||||
VERIFY_STATUS_NEED_TO_VERIFY: [
|
||||
"You still need to verify for this course.",
|
||||
"Verification not yet complete"
|
||||
],
|
||||
VERIFY_STATUS_SUBMITTED: ["You have submitted your verification information."],
|
||||
VERIFY_STATUS_RESUBMITTED: ["You have submitted your reverification information."],
|
||||
VERIFY_STATUS_APPROVED: ["You have successfully verified your ID with edX"],
|
||||
VERIFY_STATUS_NEED_TO_REVERIFY: ["Your current verification will expire soon."]
|
||||
}
|
||||
|
||||
MODE_CLASSES = {
|
||||
None: "audit",
|
||||
VERIFY_STATUS_NEED_TO_VERIFY: "verified",
|
||||
VERIFY_STATUS_SUBMITTED: "verified",
|
||||
VERIFY_STATUS_APPROVED: "verified",
|
||||
VERIFY_STATUS_MISSED_DEADLINE: "audit",
|
||||
VERIFY_STATUS_NEED_TO_REVERIFY: "audit",
|
||||
VERIFY_STATUS_RESUBMITTED: "audit"
|
||||
}
|
||||
|
||||
def _assert_course_verification_status(self, status, enrollment_mode=None):
|
||||
"""Check whether the specified verification status is shown on the dashboard.
|
||||
|
||||
Arguments:
|
||||
status (str): One of the verification status constants.
|
||||
If None, check that *none* of the statuses are displayed.
|
||||
|
||||
Raises:
|
||||
AssertionError
|
||||
|
||||
"""
|
||||
response = self.client.get(self.dashboard_url)
|
||||
|
||||
# Sanity check: verify that the course is on the page
|
||||
self.assertContains(response, str(self.course.id))
|
||||
|
||||
# Verify that the correct banner is rendered on the dashboard
|
||||
alt_text = self.BANNER_ALT_MESSAGES.get(status)
|
||||
if alt_text:
|
||||
self.assertContains(response, alt_text)
|
||||
|
||||
mode = enrollment_mode if enrollment_mode else self.MODE_CLASSES[status]
|
||||
|
||||
# Verify that the correct banner color is rendered
|
||||
self.assertContains(
|
||||
response,
|
||||
f"<article class=\"course {mode}\""
|
||||
)
|
||||
|
||||
# Verify that the correct copy is rendered on the dashboard
|
||||
if status is not None:
|
||||
if status in self.NOTIFICATION_MESSAGES:
|
||||
# Different states might have different messaging
|
||||
# so in some cases we check several possibilities
|
||||
# and fail if none of these are found.
|
||||
found_msg = False
|
||||
for message in self.NOTIFICATION_MESSAGES[status]:
|
||||
if six.b(message) in response.content:
|
||||
found_msg = True
|
||||
break
|
||||
|
||||
fail_msg = "Could not find any of these messages: {expected}".format(
|
||||
expected=self.NOTIFICATION_MESSAGES[status]
|
||||
)
|
||||
assert found_msg, fail_msg
|
||||
else:
|
||||
# Combine all possible messages into a single list
|
||||
all_messages = []
|
||||
for msg_group in self.NOTIFICATION_MESSAGES.values():
|
||||
all_messages.extend(msg_group)
|
||||
|
||||
# Verify that none of the messages are displayed
|
||||
for msg in all_messages:
|
||||
self.assertNotContains(response, msg)
|
||||
@@ -748,11 +748,6 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem
|
||||
else:
|
||||
redirect_message = ''
|
||||
|
||||
valid_verification_statuses = ['approved', 'must_reverify', 'pending', 'expired']
|
||||
display_sidebar_on_dashboard = not settings.FEATURES.get('ENABLE_INTEGRITY_SIGNATURE') and \
|
||||
verification_status['status'] in valid_verification_statuses and \
|
||||
verification_status['should_display']
|
||||
|
||||
# Filter out any course enrollment course cards that are associated with fulfilled entitlements
|
||||
for entitlement in [e for e in course_entitlements if e.enrollment_course_run is not None]:
|
||||
course_enrollments = [
|
||||
@@ -804,7 +799,6 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem
|
||||
'show_dashboard_tabs': True,
|
||||
'disable_courseware_js': True,
|
||||
'display_course_modes_on_dashboard': enable_verified_certificates and display_course_modes_on_dashboard,
|
||||
'display_sidebar_on_dashboard': display_sidebar_on_dashboard,
|
||||
'display_sidebar_account_activation_message': not(user.is_active or hide_dashboard_courses_until_activated),
|
||||
'display_dashboard_courses': (user.is_active or not hide_dashboard_courses_until_activated),
|
||||
'empty_dashboard_message': empty_dashboard_message,
|
||||
|
||||
@@ -16,7 +16,7 @@ class SAMLConfigurationMixin:
|
||||
serializer_class = SAMLConfigurationSerializer
|
||||
|
||||
|
||||
class SAMLConfigurationViewSet(SAMLConfigurationMixin, viewsets.ModelViewSet):
|
||||
class SAMLConfigurationViewSet(SAMLConfigurationMixin, viewsets.ReadOnlyModelViewSet):
|
||||
"""
|
||||
A View to handle SAMLConfiguration GETs
|
||||
|
||||
|
||||
@@ -1,18 +1,20 @@
|
||||
# pylint: disable=missing-module-docstring
|
||||
import copy
|
||||
import pytz
|
||||
from uuid import uuid4 # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from datetime import datetime # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from unittest import mock
|
||||
from uuid import uuid4 # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
import pytz
|
||||
from django.contrib.sites.models import Site
|
||||
from django.urls import reverse
|
||||
from django.utils.http import urlencode
|
||||
from enterprise.constants import ENTERPRISE_ADMIN_ROLE, ENTERPRISE_LEARNER_ROLE
|
||||
from enterprise.models import EnterpriseCustomer, EnterpriseCustomerIdentityProvider
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from enterprise.models import EnterpriseCustomer, EnterpriseCustomerIdentityProvider
|
||||
from enterprise.constants import ENTERPRISE_ADMIN_ROLE, ENTERPRISE_LEARNER_ROLE
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from common.djangoapps.third_party_auth.models import SAMLProviderData, SAMLProviderConfig
|
||||
from common.djangoapps.third_party_auth.models import SAMLProviderConfig, SAMLProviderData
|
||||
from common.djangoapps.third_party_auth.tests.samlutils import set_jwt_cookie
|
||||
from common.djangoapps.third_party_auth.tests.utils import skip_unless_thirdpartyauth
|
||||
from common.djangoapps.third_party_auth.utils import convert_saml_slug_provider_id
|
||||
@@ -180,3 +182,35 @@ class SAMLProviderDataTests(APITestCase):
|
||||
set_jwt_cookie(self.client, self.user, [(ENTERPRISE_ADMIN_ROLE, BAD_ENTERPRISE_ID)])
|
||||
response = self.client.get(url, format='json')
|
||||
assert response.status_code == status.HTTP_403_FORBIDDEN
|
||||
|
||||
@mock.patch('common.djangoapps.third_party_auth.samlproviderdata.views.fetch_metadata_xml')
|
||||
@mock.patch('common.djangoapps.third_party_auth.samlproviderdata.views.parse_metadata_xml')
|
||||
def test_sync_one_provider_data_success(self, mock_parse, mock_fetch):
|
||||
"""
|
||||
POST auth/saml/v0/provider_data/sync_provider_data -d data
|
||||
"""
|
||||
mock_fetch.return_value = '<?xml><a>tag</a>'
|
||||
public_key = 'askdjf;sakdjfs;adkfjas;dkfjas;dkfjas;dlkfj'
|
||||
sso_url = 'https://fake-test.id'
|
||||
expires_at = datetime.now()
|
||||
mock_parse.return_value = (public_key, sso_url, expires_at)
|
||||
url = reverse('saml_provider_data-sync-provider-data')
|
||||
data = {
|
||||
'entity_id': 'http://entity-id-1',
|
||||
'metadata_url': 'http://a-url',
|
||||
'enterprise_customer_uuid': ENTERPRISE_ID,
|
||||
}
|
||||
SAMLProviderData.objects.all().delete()
|
||||
orig_count = SAMLProviderData.objects.count()
|
||||
|
||||
response = self.client.post(url, data)
|
||||
|
||||
assert response.status_code == status.HTTP_201_CREATED
|
||||
assert response.data == " Created new record for SAMLProviderData for entityID http://entity-id-1"
|
||||
assert SAMLProviderData.objects.count() == orig_count + 1
|
||||
|
||||
# should only update this time
|
||||
response = self.client.post(url, data)
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data == (" Updated existing SAMLProviderData for entityID http://entity-id-1")
|
||||
assert SAMLProviderData.objects.count() == orig_count + 1
|
||||
|
||||
@@ -1,21 +1,32 @@
|
||||
"""
|
||||
Viewset for auth/saml/v0/samlproviderdata
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.http import Http404
|
||||
from django.shortcuts import get_object_or_404
|
||||
from edx_rbac.mixins import PermissionRequiredMixin
|
||||
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
||||
from rest_framework import permissions, viewsets
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.exceptions import ParseError
|
||||
|
||||
from enterprise.models import EnterpriseCustomerIdentityProvider
|
||||
from common.djangoapps.third_party_auth.utils import validate_uuid4_string, convert_saml_slug_provider_id
|
||||
from rest_framework import permissions, status, viewsets
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.decorators import action
|
||||
from rest_framework.exceptions import ParseError
|
||||
from rest_framework.response import Response
|
||||
|
||||
from common.djangoapps.third_party_auth.utils import (
|
||||
convert_saml_slug_provider_id,
|
||||
create_or_update_saml_provider_data,
|
||||
fetch_metadata_xml,
|
||||
parse_metadata_xml,
|
||||
validate_uuid4_string
|
||||
)
|
||||
|
||||
from ..models import SAMLProviderConfig, SAMLProviderData
|
||||
from .serializers import SAMLProviderDataSerializer
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SAMLProviderDataMixin:
|
||||
authentication_classes = [JwtAuthentication, SessionAuthentication]
|
||||
@@ -36,6 +47,7 @@ class SAMLProviderDataViewSet(PermissionRequiredMixin, SAMLProviderDataMixin, vi
|
||||
POST /auth/saml/v0/provider_data/ -d postData (must contain 'enterprise_customer_uuid')
|
||||
DELETE /auth/saml/v0/provider_data/:pk -d postData (must contain 'enterprise_customer_uuid')
|
||||
PATCH /auth/saml/v0/provider_data/:pk -d postData (must contain 'enterprise_customer_uuid')
|
||||
POST /auth/saml/v0/provider_data/sync_provider_data (fetches metadata info from metadata url provided)
|
||||
|
||||
"""
|
||||
permission_required = 'enterprise.can_access_admin_dashboard'
|
||||
@@ -81,3 +93,37 @@ class SAMLProviderDataViewSet(PermissionRequiredMixin, SAMLProviderDataMixin, vi
|
||||
Retrieve an EnterpriseCustomer to do auth against
|
||||
"""
|
||||
return self.requested_enterprise_uuid
|
||||
|
||||
@action(detail=False, methods=['post'])
|
||||
def sync_provider_data(self, request):
|
||||
"""
|
||||
Creates or updates a SAMProviderData record using info fetched from remote SAML metadata
|
||||
For now we will require entityID but in future we will enhance this to try and extract entityID
|
||||
from the metadata file, and make entityId optional, and return error response if there are
|
||||
multiple entityIDs listed so that the user can choose and retry with a specified entityID
|
||||
"""
|
||||
entity_id = request.POST.get('entity_id')
|
||||
metadata_url = request.POST.get('metadata_url')
|
||||
if not entity_id:
|
||||
return Response('entity_id is required!', status.HTTP_400_BAD_REQUEST)
|
||||
if not metadata_url:
|
||||
return Response('metadata_url is required!', status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
# part 1: fetch information from remote metadata based on metadataUrl in samlproviderconfig
|
||||
xml = fetch_metadata_xml(metadata_url)
|
||||
|
||||
# part 2: create/update samlproviderdata
|
||||
log.info("Processing IdP with entityID %s", entity_id)
|
||||
public_key, sso_url, expires_at = parse_metadata_xml(xml, entity_id)
|
||||
changed = create_or_update_saml_provider_data(entity_id, public_key, sso_url, expires_at)
|
||||
if changed:
|
||||
str_message = f" Created new record for SAMLProviderData for entityID {entity_id}"
|
||||
log.info(str_message)
|
||||
response = str_message
|
||||
http_status = status.HTTP_201_CREATED
|
||||
else:
|
||||
str_message = f" Updated existing SAMLProviderData for entityID {entity_id}"
|
||||
log.info(str_message)
|
||||
response = str_message
|
||||
http_status = status.HTTP_200_OK
|
||||
return Response(response, status=http_status)
|
||||
|
||||
@@ -7,13 +7,16 @@ import logging
|
||||
|
||||
import requests
|
||||
from celery import shared_task
|
||||
from django.utils.timezone import now
|
||||
from edx_django_utils.monitoring import set_code_owner_attribute
|
||||
from lxml import etree
|
||||
from requests import exceptions
|
||||
|
||||
from common.djangoapps.third_party_auth.models import SAMLConfiguration, SAMLProviderConfig, SAMLProviderData
|
||||
from common.djangoapps.third_party_auth.utils import MetadataParseError, parse_metadata_xml
|
||||
from common.djangoapps.third_party_auth.models import SAMLConfiguration, SAMLProviderConfig
|
||||
from common.djangoapps.third_party_auth.utils import (
|
||||
MetadataParseError,
|
||||
create_or_update_saml_provider_data,
|
||||
parse_metadata_xml,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -85,7 +88,7 @@ def fetch_saml_metadata():
|
||||
for entity_id in entity_ids:
|
||||
log.info("Processing IdP with entityID %s", entity_id)
|
||||
public_key, sso_url, expires_at = parse_metadata_xml(xml, entity_id)
|
||||
changed = _update_data(entity_id, public_key, sso_url, expires_at)
|
||||
changed = create_or_update_saml_provider_data(entity_id, public_key, sso_url, expires_at)
|
||||
if changed:
|
||||
log.info(f"→ Created new record for SAMLProviderData for entityID {entity_id}")
|
||||
num_updated += 1
|
||||
@@ -124,28 +127,3 @@ 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
|
||||
|
||||
|
||||
def _update_data(entity_id, public_key, sso_url, expires_at):
|
||||
"""
|
||||
Update/Create the SAMLProviderData for the given entity ID.
|
||||
Return value:
|
||||
False if nothing has changed and existing data's "fetched at" timestamp is just updated.
|
||||
True if a new record was created. (Either this is a new provider or something changed.)
|
||||
"""
|
||||
data_obj = SAMLProviderData.current(entity_id)
|
||||
fetched_at = now()
|
||||
if data_obj and (data_obj.public_key == public_key and data_obj.sso_url == sso_url):
|
||||
data_obj.expires_at = expires_at
|
||||
data_obj.fetched_at = fetched_at
|
||||
data_obj.save()
|
||||
return False
|
||||
else:
|
||||
SAMLProviderData.objects.create(
|
||||
entity_id=entity_id,
|
||||
fetched_at=fetched_at,
|
||||
expires_at=expires_at,
|
||||
sso_url=sso_url,
|
||||
public_key=public_key,
|
||||
)
|
||||
return True
|
||||
|
||||
@@ -4,17 +4,23 @@ Test the views served by third_party_auth.
|
||||
|
||||
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import ddt
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.test import TestCase, override_settings
|
||||
from django.test.client import RequestFactory
|
||||
from django.urls import reverse
|
||||
from lxml import etree
|
||||
from onelogin.saml2.errors import OneLogin_Saml2_Error
|
||||
|
||||
from common.djangoapps.student.models import Registration
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from common.djangoapps.third_party_auth import pipeline
|
||||
# Define some XML namespaces:
|
||||
from common.djangoapps.third_party_auth.utils import SAML_XML_NS
|
||||
from common.djangoapps.third_party_auth.views import inactive_user_view
|
||||
|
||||
from .testutil import AUTH_FEATURE_ENABLED, AUTH_FEATURES_KEY, SAMLTestCase
|
||||
|
||||
@@ -197,3 +203,55 @@ class IdPRedirectViewTest(SAMLTestCase):
|
||||
idp_redirect_url=reverse('idp_redirect', kwargs={'provider_slug': provider_slug}),
|
||||
next_destination=next_destination,
|
||||
)
|
||||
|
||||
|
||||
@unittest.skipUnless(AUTH_FEATURE_ENABLED, AUTH_FEATURES_KEY + ' not enabled')
|
||||
class InactiveUserViewTests(TestCase):
|
||||
"""Test inactive user view """
|
||||
@patch('common.djangoapps.third_party_auth.views.redirect')
|
||||
@override_settings(LOGIN_REDIRECT_WHITELIST=['courses.edx.org'])
|
||||
def test_inactive_user_view_allows_valid_redirect(self, mock_redirect):
|
||||
inactive_user = UserFactory(is_active=False)
|
||||
Registration().register(inactive_user)
|
||||
request = RequestFactory().get(settings.SOCIAL_AUTH_INACTIVE_USER_URL, {'next': 'https://courses.edx.org'})
|
||||
request.user = inactive_user
|
||||
with patch('common.djangoapps.edxmako.request_context.get_current_request', return_value=request):
|
||||
with patch('common.djangoapps.third_party_auth.pipeline.running', return_value=False):
|
||||
inactive_user_view(request)
|
||||
mock_redirect.assert_called_with('https://courses.edx.org')
|
||||
|
||||
@patch('common.djangoapps.third_party_auth.views.redirect')
|
||||
def test_inactive_user_view_prevents_invalid_redirect(self, mock_redirect):
|
||||
inactive_user = UserFactory(is_active=False)
|
||||
Registration().register(inactive_user)
|
||||
request = RequestFactory().get(settings.SOCIAL_AUTH_INACTIVE_USER_URL, {'next': 'https://evil.com'})
|
||||
request.user = inactive_user
|
||||
with patch('common.djangoapps.edxmako.request_context.get_current_request', return_value=request):
|
||||
with patch('common.djangoapps.third_party_auth.pipeline.running', return_value=False):
|
||||
inactive_user_view(request)
|
||||
mock_redirect.assert_called_with('dashboard')
|
||||
|
||||
@patch('common.djangoapps.third_party_auth.views.redirect')
|
||||
def test_inactive_user_view_redirects_back_to_host(self, mock_redirect):
|
||||
inactive_user = UserFactory(is_active=False)
|
||||
Registration().register(inactive_user)
|
||||
request = RequestFactory().get(settings.SOCIAL_AUTH_INACTIVE_USER_URL, {'next': 'https://myedxhost.com'},
|
||||
HTTP_HOST='myedxhost.com')
|
||||
request.user = inactive_user
|
||||
with patch('common.djangoapps.edxmako.request_context.get_current_request', return_value=request):
|
||||
with patch('common.djangoapps.third_party_auth.pipeline.running', return_value=False):
|
||||
inactive_user_view(request)
|
||||
mock_redirect.assert_called_with('https://myedxhost.com')
|
||||
|
||||
@patch('common.djangoapps.third_party_auth.views.redirect')
|
||||
@override_settings(LOGIN_REDIRECT_WHITELIST=['courses.edx.org'])
|
||||
def test_inactive_user_view_does_not_redirect_https_to_http(self, mock_redirect):
|
||||
inactive_user = UserFactory(is_active=False)
|
||||
Registration().register(inactive_user)
|
||||
request = RequestFactory().get(settings.SOCIAL_AUTH_INACTIVE_USER_URL, {'next': 'http://courses.edx.org'},
|
||||
secure=True)
|
||||
request.user = inactive_user
|
||||
with patch('common.djangoapps.edxmako.request_context.get_current_request', return_value=request):
|
||||
with patch('common.djangoapps.third_party_auth.pipeline.running', return_value=False):
|
||||
inactive_user_view(request)
|
||||
mock_redirect.assert_called_with('dashboard')
|
||||
|
||||
@@ -3,29 +3,68 @@ Utility functions for third_party_auth
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from uuid import UUID
|
||||
|
||||
import dateutil.parser
|
||||
import pytz
|
||||
import requests
|
||||
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
|
||||
from django.utils.timezone import now
|
||||
from enterprise.models import EnterpriseCustomerIdentityProvider, EnterpriseCustomerUser
|
||||
from lxml import etree
|
||||
from onelogin.saml2.utils import OneLogin_Saml2_Utils
|
||||
from requests import exceptions
|
||||
from social_core.pipeline.social_auth import associate_by_email
|
||||
|
||||
from common.djangoapps.third_party_auth.models import OAuth2ProviderConfig
|
||||
from common.djangoapps.third_party_auth.models import OAuth2ProviderConfig, SAMLProviderData
|
||||
from openedx.core.djangolib.markup import Text
|
||||
|
||||
from . import provider
|
||||
|
||||
SAML_XML_NS = 'urn:oasis:names:tc:SAML:2.0:metadata' # The SAML Metadata XML namespace
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class MetadataParseError(Exception):
|
||||
""" An error occurred while parsing the SAML metadata from an IdP """
|
||||
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
||||
|
||||
|
||||
def fetch_metadata_xml(url):
|
||||
"""
|
||||
Fetches IDP metadata from provider url
|
||||
Returns: xml document
|
||||
"""
|
||||
try:
|
||||
log.info("Fetching %s", url)
|
||||
if not url.lower().startswith('https'):
|
||||
log.warning("This SAML metadata URL is not secure! It should use HTTPS. (%s)", url)
|
||||
response = requests.get(url, verify=True) # May raise HTTPError or SSLError or ConnectionError
|
||||
response.raise_for_status() # May raise an HTTPError
|
||||
|
||||
try:
|
||||
parser = etree.XMLParser(remove_comments=True)
|
||||
xml = etree.fromstring(response.content, parser)
|
||||
except etree.XMLSyntaxError: # lint-amnesty, pylint: disable=try-except-raise
|
||||
raise
|
||||
# TODO: Can use OneLogin_Saml2_Utils to validate signed XML if anyone is using that
|
||||
return xml
|
||||
except (exceptions.SSLError, exceptions.HTTPError, exceptions.RequestException, MetadataParseError) as error:
|
||||
# Catch and process exception in case of errors during fetching and processing saml metadata.
|
||||
# Here is a description of each exception.
|
||||
# SSLError is raised in case of errors caused by SSL (e.g. SSL cer verification failure etc.)
|
||||
# HTTPError is raised in case of unexpected status code (e.g. 500 error etc.)
|
||||
# RequestException is the base exception for any request related error that "requests" lib raises.
|
||||
# MetadataParseError is raised if there is error in the fetched meta data (e.g. missing @entityID etc.)
|
||||
log.exception(str(error), exc_info=error)
|
||||
raise error
|
||||
except etree.XMLSyntaxError as error:
|
||||
log.exception(str(error), exc_info=error)
|
||||
raise error
|
||||
|
||||
|
||||
def parse_metadata_xml(xml, entity_id):
|
||||
"""
|
||||
Given an XML document containing SAML 2.0 metadata, parse it and return a tuple of
|
||||
@@ -125,6 +164,31 @@ def get_user_from_email(details):
|
||||
return None
|
||||
|
||||
|
||||
def create_or_update_saml_provider_data(entity_id, public_key, sso_url, expires_at):
|
||||
"""
|
||||
Update/Create the SAMLProviderData for the given entity ID.
|
||||
Return value:
|
||||
False if nothing has changed and existing data's "fetched at" timestamp is just updated.
|
||||
True if a new record was created. (Either this is a new provider or something changed.)
|
||||
"""
|
||||
data_obj = SAMLProviderData.current(entity_id)
|
||||
fetched_at = now()
|
||||
if data_obj and (data_obj.public_key == public_key and data_obj.sso_url == sso_url):
|
||||
data_obj.expires_at = expires_at
|
||||
data_obj.fetched_at = fetched_at
|
||||
data_obj.save()
|
||||
return False
|
||||
else:
|
||||
SAMLProviderData.objects.create(
|
||||
entity_id=entity_id,
|
||||
fetched_at=fetched_at,
|
||||
expires_at=expires_at,
|
||||
sso_url=sso_url,
|
||||
public_key=public_key,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def convert_saml_slug_provider_id(provider): # lint-amnesty, pylint: disable=redefined-outer-name
|
||||
"""
|
||||
Provider id is stored with the backend type prefixed to it (ie "saml-")
|
||||
|
||||
@@ -14,7 +14,7 @@ from social_django.utils import load_backend, load_strategy, psa
|
||||
from social_django.views import complete
|
||||
|
||||
from common.djangoapps import third_party_auth
|
||||
from common.djangoapps.student.helpers import get_next_url_for_login_page
|
||||
from common.djangoapps.student.helpers import get_next_url_for_login_page, is_safe_login_or_logout_redirect
|
||||
from common.djangoapps.student.models import UserProfile
|
||||
from common.djangoapps.student.views import compose_and_send_activation_email
|
||||
from common.djangoapps.third_party_auth import pipeline, provider
|
||||
@@ -54,7 +54,15 @@ def inactive_user_view(request):
|
||||
if not activated:
|
||||
compose_and_send_activation_email(user, profile)
|
||||
|
||||
return redirect(request.GET.get('next', 'dashboard'))
|
||||
request_params = request.GET
|
||||
redirect_to = request_params.get('next')
|
||||
|
||||
if redirect_to and is_safe_login_or_logout_redirect(redirect_to=redirect_to, request_host=request.get_host(),
|
||||
dot_client_id=request_params.get('client_id'),
|
||||
require_https=request.is_secure()):
|
||||
return redirect(redirect_to)
|
||||
|
||||
return redirect('dashboard')
|
||||
|
||||
|
||||
def saml_metadata_view(request):
|
||||
|
||||
@@ -103,10 +103,10 @@ class LoncapaSystem(object):
|
||||
can_execute_unsafe_code,
|
||||
get_python_lib_zip,
|
||||
DEBUG,
|
||||
filestore,
|
||||
i18n,
|
||||
node_path,
|
||||
render_template,
|
||||
resources_fs,
|
||||
seed, # Why do we do this if we have self.seed?
|
||||
STATIC_URL,
|
||||
xqueue,
|
||||
@@ -118,10 +118,10 @@ class LoncapaSystem(object):
|
||||
self.can_execute_unsafe_code = can_execute_unsafe_code
|
||||
self.get_python_lib_zip = get_python_lib_zip
|
||||
self.DEBUG = DEBUG # pylint: disable=invalid-name
|
||||
self.filestore = filestore
|
||||
self.i18n = i18n
|
||||
self.node_path = node_path
|
||||
self.render_template = render_template
|
||||
self.resources_fs = resources_fs
|
||||
self.seed = seed # Why do we do this if we have self.seed?
|
||||
self.STATIC_URL = STATIC_URL # pylint: disable=invalid-name
|
||||
self.xqueue = xqueue
|
||||
@@ -814,8 +814,8 @@ class LoncapaProblem(object):
|
||||
filename = inc.get('file') if six.PY3 else inc.get('file').decode('utf-8')
|
||||
if filename is not None:
|
||||
try:
|
||||
# open using LoncapaSystem OSFS filestore
|
||||
ifp = self.capa_system.filestore.open(filename)
|
||||
# open using LoncapaSystem OSFS filesystem
|
||||
ifp = self.capa_system.resources_fs.open(filename)
|
||||
except Exception as err: # lint-amnesty, pylint: disable=broad-except
|
||||
log.warning(
|
||||
'Error %s in problem xml include: %s',
|
||||
@@ -823,7 +823,7 @@ class LoncapaProblem(object):
|
||||
etree.tostring(inc, pretty_print=True)
|
||||
)
|
||||
log.warning(
|
||||
'Cannot find file %s in %s', filename, self.capa_system.filestore
|
||||
'Cannot find file %s in %s', filename, self.capa_system.resources_fs
|
||||
)
|
||||
# if debugging, don't fail - just log error
|
||||
# TODO (vshnayder): need real error handling, display to users
|
||||
@@ -876,9 +876,9 @@ class LoncapaProblem(object):
|
||||
continue
|
||||
|
||||
# path is an absolute path or a path relative to the data dir
|
||||
dir = os.path.join(self.capa_system.filestore.root_path, dir)
|
||||
# Check that we are within the filestore tree.
|
||||
reldir = os.path.relpath(dir, self.capa_system.filestore.root_path)
|
||||
dir = os.path.join(self.capa_system.resources_fs.root_path, dir)
|
||||
# Check that we are within the resources_fs tree.
|
||||
reldir = os.path.relpath(dir, self.capa_system.resources_fs.root_path)
|
||||
if ".." in reldir:
|
||||
log.warning("Ignoring Python directory outside of course: %r", dir)
|
||||
continue
|
||||
|
||||
@@ -3268,7 +3268,7 @@ class SchematicResponse(LoncapaResponse):
|
||||
answer_src = answer.get('src')
|
||||
if answer_src is not None:
|
||||
# Untested; never used
|
||||
self.code = self.capa_system.filestore.open('src/' + answer_src).read()
|
||||
self.code = self.capa_system.resources_fs.open('src/' + answer_src).read()
|
||||
else:
|
||||
self.code = answer.text
|
||||
|
||||
|
||||
@@ -72,10 +72,10 @@ def test_capa_system(render_template=None):
|
||||
can_execute_unsafe_code=lambda: False,
|
||||
get_python_lib_zip=lambda: None,
|
||||
DEBUG=True,
|
||||
filestore=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")),
|
||||
i18n=gettext.NullTranslations(),
|
||||
node_path=os.environ.get("NODE_PATH", "/usr/local/lib/node_modules"),
|
||||
render_template=render_template or tst_render_template,
|
||||
resources_fs=fs.osfs.OSFS(os.path.join(TEST_DIR, "test_files")),
|
||||
seed=0,
|
||||
STATIC_URL='/dummy-static/',
|
||||
STATUS_CLASS=Status,
|
||||
|
||||
@@ -300,7 +300,7 @@ class CapaHtmlRenderTest(unittest.TestCase):
|
||||
self.assertRegex(the_html, r"<div>\s*</div>")
|
||||
|
||||
def _create_test_file(self, path, content_str): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
test_fp = self.capa_system.filestore.open(path, "w")
|
||||
test_fp = self.capa_system.resources_fs.open(path, "w")
|
||||
test_fp.write(content_str)
|
||||
test_fp.close()
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import six
|
||||
from calc import evaluator
|
||||
from lxml import etree
|
||||
|
||||
from bleach.css_sanitizer import CSSSanitizer
|
||||
from openedx.core.djangolib.markup import HTML
|
||||
|
||||
#-----------------------------------------------------------------------------
|
||||
@@ -192,7 +193,7 @@ def sanitize_html(html_code):
|
||||
html_code,
|
||||
protocols=bleach.ALLOWED_PROTOCOLS + ['data'],
|
||||
tags=bleach.ALLOWED_TAGS + ['div', 'p', 'audio', 'pre', 'img', 'span'],
|
||||
styles=['white-space'],
|
||||
css_sanitizer=CSSSanitizer(allowed_css_properties=["white-space"]),
|
||||
attributes=attributes
|
||||
)
|
||||
return output
|
||||
|
||||
@@ -19,7 +19,6 @@ from xmodule.x_module import (
|
||||
ResourceTemplates,
|
||||
shim_xmodule_js,
|
||||
XModuleMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
|
||||
@@ -35,7 +34,6 @@ class AnnotatableBlock(
|
||||
RawMixin,
|
||||
XmlMixin,
|
||||
EditingMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
|
||||
@@ -41,7 +41,6 @@ from xmodule.util.xmodule_django import add_webpack_to_fragment
|
||||
from xmodule.x_module import (
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleMixin,
|
||||
XModuleToXBlockMixin,
|
||||
shim_xmodule_js
|
||||
@@ -132,7 +131,6 @@ class ProblemBlock(
|
||||
RawMixin,
|
||||
XmlMixin,
|
||||
EditingMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
@@ -164,11 +162,6 @@ class ProblemBlock(
|
||||
mako_template = "widgets/problem-edit.html"
|
||||
has_author_view = True
|
||||
|
||||
# The capa format specifies that what we call max_attempts in the code
|
||||
# is the attribute `attempts`. This will do that conversion
|
||||
metadata_translations = dict(XmlMixin.metadata_translations)
|
||||
metadata_translations['attempts'] = 'max_attempts'
|
||||
|
||||
icon_class = 'problem'
|
||||
|
||||
uses_xmodule_styles_setup = True
|
||||
@@ -616,10 +609,10 @@ class ProblemBlock(
|
||||
can_execute_unsafe_code=None,
|
||||
get_python_lib_zip=None,
|
||||
DEBUG=None,
|
||||
filestore=self.runtime.resources_fs,
|
||||
i18n=self.runtime.service(self, "i18n"),
|
||||
node_path=None,
|
||||
render_template=None,
|
||||
resources_fs=self.runtime.resources_fs,
|
||||
seed=None,
|
||||
STATIC_URL=None,
|
||||
xqueue=None,
|
||||
@@ -678,10 +671,10 @@ class ProblemBlock(
|
||||
can_execute_unsafe_code=lambda: None,
|
||||
get_python_lib_zip=(lambda: get_python_lib_zip(contentstore, self.runtime.course_id)),
|
||||
DEBUG=None,
|
||||
filestore=self.runtime.resources_fs,
|
||||
i18n=self.runtime.service(self, "i18n"),
|
||||
node_path=None,
|
||||
render_template=None,
|
||||
resources_fs=self.runtime.resources_fs,
|
||||
seed=1,
|
||||
STATIC_URL=None,
|
||||
xqueue=None,
|
||||
@@ -827,10 +820,10 @@ class ProblemBlock(
|
||||
can_execute_unsafe_code=sandbox_service.can_execute_unsafe_code,
|
||||
get_python_lib_zip=sandbox_service.get_python_lib_zip,
|
||||
DEBUG=self.runtime.DEBUG,
|
||||
filestore=self.runtime.filestore,
|
||||
i18n=self.runtime.service(self, "i18n"),
|
||||
node_path=self.runtime.node_path,
|
||||
render_template=self.runtime.service(self, 'mako').render_template,
|
||||
resources_fs=self.runtime.resources_fs,
|
||||
seed=seed, # Why do we do this if we have self.seed?
|
||||
STATIC_URL=self.runtime.STATIC_URL,
|
||||
xqueue=self.runtime.service(self, 'xqueue'),
|
||||
|
||||
@@ -27,7 +27,6 @@ from xmodule.x_module import (
|
||||
ResourceTemplates,
|
||||
shim_xmodule_js,
|
||||
STUDENT_VIEW,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
@@ -44,7 +43,6 @@ class ConditionalBlock(
|
||||
SequenceMixin,
|
||||
MakoTemplateBlockBase,
|
||||
XmlMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
|
||||
@@ -9,7 +9,7 @@ from urllib.parse import parse_qsl, quote_plus, urlencode, urlparse, urlunparse
|
||||
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import AssetKey, CourseKey
|
||||
from opaque_keys.edx.locator import AssetLocator
|
||||
from opaque_keys.edx.locator import AssetLocator, LibraryLocatorV2
|
||||
from PIL import Image
|
||||
|
||||
from xmodule.assetstore.assetmgr import AssetManager
|
||||
@@ -123,7 +123,7 @@ class StaticContent: # lint-amnesty, pylint: disable=missing-class-docstring
|
||||
|
||||
@staticmethod
|
||||
def get_base_url_path_for_course_assets(course_key): # lint-amnesty, pylint: disable=missing-function-docstring
|
||||
if course_key is None:
|
||||
if (course_key is None) or isinstance(course_key, LibraryLocatorV2):
|
||||
return None
|
||||
|
||||
assert isinstance(course_key, CourseKey)
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
.simple-editor-cheatsheet {
|
||||
position: absolute;
|
||||
top: 41px;
|
||||
left: 70%;
|
||||
@include left(70%);
|
||||
width: 0;
|
||||
border-left: 1px solid $gray-l2;
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ from xmodule.x_module import (
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
XModuleMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
|
||||
@@ -47,7 +46,6 @@ class ErrorFields:
|
||||
@XBlock.needs('mako')
|
||||
class ErrorBlock(
|
||||
ErrorFields,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
@@ -181,6 +179,16 @@ class ErrorBlock(
|
||||
|
||||
return cls._construct(system, xml_data, error_msg, location=id_generator.create_definition('error'))
|
||||
|
||||
@classmethod
|
||||
def parse_xml(cls, node, runtime, keys, id_generator): # lint-amnesty, pylint: disable=unused-argument
|
||||
"""
|
||||
Interpret the parsed XML in `node`, creating an XModuleDescriptor.
|
||||
"""
|
||||
# It'd be great to not reserialize and deserialize the xml
|
||||
xml = etree.tostring(node).decode('utf-8')
|
||||
block = cls.from_xml(xml, runtime, id_generator)
|
||||
return block
|
||||
|
||||
def export_to_xml(self, resource_fs):
|
||||
'''
|
||||
If the definition data is invalid xml, export it wrapped in an "error"
|
||||
@@ -201,6 +209,25 @@ class ErrorBlock(
|
||||
err_node.text = self.error_msg
|
||||
return etree.tostring(root, encoding='unicode')
|
||||
|
||||
def add_xml_to_node(self, node):
|
||||
"""
|
||||
Export this :class:`XModuleDescriptor` as XML, by setting attributes on the provided
|
||||
`node`.
|
||||
"""
|
||||
xml_string = self.export_to_xml(self.runtime.export_fs)
|
||||
exported_node = etree.fromstring(xml_string)
|
||||
node.tag = exported_node.tag
|
||||
node.text = exported_node.text
|
||||
node.tail = exported_node.tail
|
||||
|
||||
for key, value in exported_node.items():
|
||||
if key == 'url_name' and value == 'course' and key in node.attrib:
|
||||
# if url_name is set in ExportManager then do not override it here.
|
||||
continue
|
||||
node.set(key, value)
|
||||
|
||||
node.extend(list(exported_node))
|
||||
|
||||
|
||||
class NonStaffErrorBlock(ErrorBlock): # pylint: disable=abstract-method
|
||||
"""
|
||||
|
||||
@@ -7,7 +7,6 @@ from xblock.core import XBlock
|
||||
from xmodule.raw_module import RawMixin
|
||||
from xmodule.xml_module import XmlMixin
|
||||
from xmodule.x_module import (
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
@@ -17,7 +16,6 @@ from xmodule.x_module import (
|
||||
class HiddenDescriptor(
|
||||
RawMixin,
|
||||
XmlMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
XModuleMixin,
|
||||
):
|
||||
|
||||
@@ -30,7 +30,6 @@ from xmodule.x_module import (
|
||||
ResourceTemplates,
|
||||
shim_xmodule_js,
|
||||
XModuleMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
from xmodule.xml_module import XmlMixin, name_to_pathname
|
||||
@@ -47,7 +46,7 @@ _ = lambda text: text
|
||||
@XBlock.needs("user")
|
||||
class HtmlBlockMixin( # lint-amnesty, pylint: disable=abstract-method
|
||||
XmlMixin, EditingMixin,
|
||||
XModuleDescriptorToXBlockMixin, XModuleToXBlockMixin, HTMLSnippet, ResourceTemplates, XModuleMixin,
|
||||
XModuleToXBlockMixin, HTMLSnippet, ResourceTemplates, XModuleMixin,
|
||||
):
|
||||
"""
|
||||
The HTML XBlock mixin.
|
||||
|
||||
@@ -35,7 +35,6 @@ from xmodule.x_module import (
|
||||
shim_xmodule_js,
|
||||
STUDENT_VIEW,
|
||||
XModuleMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
|
||||
@@ -75,7 +74,6 @@ def _get_capa_types():
|
||||
class LibraryContentBlock(
|
||||
MakoTemplateBlockBase,
|
||||
XmlMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
|
||||
@@ -90,7 +90,6 @@ from xmodule.x_module import (
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
shim_xmodule_js,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
@@ -282,7 +281,6 @@ class LTIBlock(
|
||||
XmlMixin,
|
||||
EditingMixin,
|
||||
MakoTemplateBlockBase,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
|
||||
@@ -242,10 +242,6 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): # li
|
||||
|
||||
definition = json_data.get('definition', {})
|
||||
metadata = json_data.get('metadata', {})
|
||||
for old_name, new_name in getattr(class_, 'metadata_translations', {}).items():
|
||||
if old_name in metadata:
|
||||
metadata[new_name] = metadata[old_name]
|
||||
del metadata[old_name]
|
||||
|
||||
children = [
|
||||
self._convert_reference_to_key(childloc)
|
||||
|
||||
@@ -46,7 +46,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): # li
|
||||
module_data: a dict mapping Location -> json that was cached from the
|
||||
underlying modulestore
|
||||
"""
|
||||
# needed by capa_problem (as runtime.filestore via this.resources_fs)
|
||||
# needed by capa_problem (as runtime.resources_fs via this.resources_fs)
|
||||
if course_entry.course_key.course:
|
||||
root = modulestore.fs_root / course_entry.course_key.org / course_entry.course_key.course / course_entry.course_key.run # lint-amnesty, pylint: disable=line-too-long
|
||||
else:
|
||||
|
||||
@@ -28,7 +28,6 @@ from xmodule.x_module import (
|
||||
ResourceTemplates,
|
||||
shim_xmodule_js,
|
||||
XModuleMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
from xmodule.xml_module import XmlMixin
|
||||
@@ -41,7 +40,6 @@ _ = lambda text: text
|
||||
class PollBlock(
|
||||
MakoTemplateBlockBase,
|
||||
XmlMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
|
||||
@@ -14,7 +14,6 @@ from xmodule.x_module import (
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
STUDENT_VIEW,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
@@ -26,7 +25,6 @@ class RandomizeBlock(
|
||||
SequenceMixin,
|
||||
MakoTemplateBlockBase,
|
||||
XmlMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
|
||||
@@ -29,7 +29,6 @@ from xmodule.x_module import (
|
||||
ResourceTemplates,
|
||||
shim_xmodule_js,
|
||||
STUDENT_VIEW,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
@@ -246,7 +245,6 @@ class ProctoringFields:
|
||||
|
||||
|
||||
@XBlock.wants('proctoring')
|
||||
@XBlock.wants('verification')
|
||||
@XBlock.wants('gating')
|
||||
@XBlock.wants('credit')
|
||||
@XBlock.wants('completion')
|
||||
@@ -261,7 +259,6 @@ class SequenceBlock(
|
||||
ProctoringFields,
|
||||
MakoTemplateBlockBase,
|
||||
XmlMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
@@ -922,7 +919,6 @@ class SequenceBlock(
|
||||
|
||||
proctoring_service = self.runtime.service(self, 'proctoring')
|
||||
credit_service = self.runtime.service(self, 'credit')
|
||||
verification_service = self.runtime.service(self, 'verification')
|
||||
|
||||
# Is this sequence designated as a Timed Examination, which includes
|
||||
# Proctored Exams
|
||||
@@ -949,7 +945,6 @@ class SequenceBlock(
|
||||
'allow_proctoring_opt_out': self.allow_proctoring_opt_out,
|
||||
'due_date': self.due,
|
||||
'grace_period': self.graceperiod, # lint-amnesty, pylint: disable=no-member
|
||||
'is_integrity_signature_enabled': settings.FEATURES.get('ENABLE_INTEGRITY_SIGNATURE'),
|
||||
}
|
||||
|
||||
# inject the user's credit requirements and fulfillments
|
||||
@@ -960,14 +955,6 @@ class SequenceBlock(
|
||||
'credit_state': credit_state
|
||||
})
|
||||
|
||||
# inject verification status
|
||||
if verification_service:
|
||||
verification_status = verification_service.get_status(user_id)
|
||||
context.update({
|
||||
'verification_status': verification_status['status'],
|
||||
'reverify_url': verification_service.reverify_url(),
|
||||
})
|
||||
|
||||
# See if the edx-proctoring subsystem wants to present
|
||||
# a special view to the student rather
|
||||
# than the actual sequence content
|
||||
|
||||
@@ -30,7 +30,6 @@ from xmodule.x_module import (
|
||||
ResourceTemplates,
|
||||
shim_xmodule_js,
|
||||
STUDENT_VIEW,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
@@ -132,7 +131,6 @@ class SplitTestBlock( # lint-amnesty, pylint: disable=abstract-method
|
||||
ProctoringFields,
|
||||
MakoTemplateBlockBase,
|
||||
XmlMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
|
||||
@@ -16,7 +16,6 @@ from xmodule.x_module import (
|
||||
ResourceTemplates,
|
||||
shim_xmodule_js,
|
||||
XModuleMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
from xmodule.xml_module import XmlMixin
|
||||
@@ -28,7 +27,6 @@ class CustomTagTemplateBlock( # pylint: disable=abstract-method
|
||||
RawMixin,
|
||||
XmlMixin,
|
||||
EditingMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
@@ -139,7 +137,6 @@ class CustomTagBlock(CustomTagTemplateBlock): # pylint: disable=abstract-method
|
||||
|
||||
|
||||
class TranslateCustomTagBlock( # pylint: disable=abstract-method
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
XModuleMixin,
|
||||
):
|
||||
|
||||
@@ -41,7 +41,7 @@ data: |
|
||||
<h2>Frequently Asked Questions</h2>
|
||||
<article class="response">
|
||||
<h3>What web browser should I use?</h3>
|
||||
<p>The Open edX platform works best with current versions of Chrome, Edge, Firefox, Internet Explorer, or Safari.</p>
|
||||
<p>The Open edX platform works best with current versions of Chrome, Edge, Firefox, or Safari.</p>
|
||||
<p>See our <a href="https://edx.readthedocs.org/projects/open-edx-learner-guide/en/latest/front_matter/browsers.html">list of supported browsers</a> for the most up-to-date information.</p>
|
||||
</article>
|
||||
|
||||
|
||||
@@ -82,6 +82,10 @@ class TestModuleSystem(ModuleSystem): # pylint: disable=abstract-method
|
||||
def get_asides(self, block):
|
||||
return []
|
||||
|
||||
@property
|
||||
def resources_fs(self):
|
||||
return Mock(name='TestModuleSystem.resources_fs', root_path='.'),
|
||||
|
||||
def __repr__(self):
|
||||
"""
|
||||
Custom hacky repr.
|
||||
@@ -149,7 +153,6 @@ def get_test_system(
|
||||
static_url='/static',
|
||||
track_function=Mock(name='get_test_system.track_function'),
|
||||
get_module=get_module,
|
||||
filestore=Mock(name='get_test_system.filestore', root_path='.'),
|
||||
debug=True,
|
||||
hostname="edx.org",
|
||||
services={
|
||||
|
||||
@@ -45,7 +45,7 @@ from xmodule.video_module import manage_video_subtitles_save
|
||||
from xmodule.x_module import (
|
||||
PUBLIC_VIEW, STUDENT_VIEW,
|
||||
HTMLSnippet, ResourceTemplates, shim_xmodule_js,
|
||||
XModuleMixin, XModuleToXBlockMixin, XModuleDescriptorToXBlockMixin,
|
||||
XModuleMixin, XModuleToXBlockMixin,
|
||||
)
|
||||
from xmodule.xml_module import XmlMixin, deserialize_field, is_pointer_tag, name_to_pathname
|
||||
|
||||
@@ -113,7 +113,7 @@ EXPORT_IMPORT_STATIC_DIR = 'static'
|
||||
class VideoBlock(
|
||||
VideoFields, VideoTranscriptsMixin, VideoStudioViewHandlers, VideoStudentViewHandlers,
|
||||
TabsEditingMixin, EmptyDataRawMixin, XmlMixin, EditingMixin,
|
||||
XModuleDescriptorToXBlockMixin, XModuleToXBlockMixin, HTMLSnippet, ResourceTemplates, XModuleMixin,
|
||||
XModuleToXBlockMixin, HTMLSnippet, ResourceTemplates, XModuleMixin,
|
||||
LicenseMixin):
|
||||
"""
|
||||
XML source example:
|
||||
@@ -589,6 +589,13 @@ class VideoBlock(
|
||||
# Backbonjs view can handle it.
|
||||
editable_fields['edx_video_id']['type'] = 'VideoID'
|
||||
|
||||
# `public_access` is a boolean field and by default backbonejs code render it as a dropdown with 2 options
|
||||
# but in our case we also need to show an input field with dropdown, the input field will show the url to
|
||||
# be shared with leaners. This is not possible with default rendering logic in backbonjs code, that is why
|
||||
# we are setting a new type and then do a custom rendering in backbonejs code to render the desired UI.
|
||||
editable_fields['public_access']['type'] = 'PublicAccess'
|
||||
editable_fields['public_access']['url'] = fr'{settings.LMS_ROOT_URL}/videos/{str(self.location)}'
|
||||
|
||||
# construct transcripts info and also find if `en` subs exist
|
||||
transcripts_info = self.get_transcripts_info()
|
||||
possible_sub_ids = [self.sub, self.youtube_id_1_0] + get_html5_ids(self.html5_sources)
|
||||
|
||||
@@ -206,3 +206,9 @@ class VideoFields:
|
||||
scope=Scope.preferences,
|
||||
default=False,
|
||||
)
|
||||
public_access = Boolean(
|
||||
help=_("Specify whether the video can be accessed publicly by learners."),
|
||||
display_name=_("Public Access"),
|
||||
scope=Scope.settings,
|
||||
default=False
|
||||
)
|
||||
|
||||
@@ -24,7 +24,6 @@ from xmodule.x_module import (
|
||||
ResourceTemplates,
|
||||
shim_xmodule_js,
|
||||
XModuleMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
@@ -49,7 +48,6 @@ class WordCloudBlock( # pylint: disable=abstract-method
|
||||
EmptyDataRawMixin,
|
||||
XmlMixin,
|
||||
EditingMixin,
|
||||
XModuleDescriptorToXBlockMixin,
|
||||
XModuleToXBlockMixin,
|
||||
HTMLSnippet,
|
||||
ResourceTemplates,
|
||||
|
||||
@@ -1104,99 +1104,8 @@ class ResourceTemplates:
|
||||
return template
|
||||
|
||||
|
||||
class XModuleDescriptorToXBlockMixin:
|
||||
"""
|
||||
Common code needed by XModuleDescriptor and XBlocks converted from XModules.
|
||||
"""
|
||||
# VS[compat]. Backwards compatibility code that can go away after
|
||||
# importing 2012 courses.
|
||||
# A set of metadata key conversions that we want to make
|
||||
metadata_translations = {
|
||||
'slug': 'url_name',
|
||||
'name': 'display_name',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _translate(cls, key):
|
||||
return cls.metadata_translations.get(key, key)
|
||||
|
||||
# ================================= XML PARSING ============================
|
||||
@classmethod
|
||||
def parse_xml(cls, node, runtime, keys, id_generator): # lint-amnesty, pylint: disable=unused-argument
|
||||
"""
|
||||
Interpret the parsed XML in `node`, creating an XModuleDescriptor.
|
||||
"""
|
||||
# It'd be great to not reserialize and deserialize the xml
|
||||
xml = etree.tostring(node).decode('utf-8')
|
||||
block = cls.from_xml(xml, runtime, id_generator)
|
||||
return block
|
||||
|
||||
@classmethod
|
||||
def parse_xml_new_runtime(cls, node, runtime, keys):
|
||||
"""
|
||||
This XML lives within Blockstore and the new runtime doesn't need this
|
||||
legacy XModule code. Use the "normal" XBlock parsing code.
|
||||
"""
|
||||
try:
|
||||
return super().parse_xml_new_runtime(node, runtime, keys)
|
||||
except AttributeError:
|
||||
return super().parse_xml(node, runtime, keys, id_generator=None)
|
||||
|
||||
@classmethod
|
||||
def from_xml(cls, xml_data, system, id_generator):
|
||||
"""
|
||||
Creates an instance of this descriptor from the supplied xml_data.
|
||||
This may be overridden by subclasses.
|
||||
|
||||
Args:
|
||||
xml_data (str): A string of xml that will be translated into data and children
|
||||
for this module
|
||||
|
||||
system (:class:`.XMLParsingSystem):
|
||||
|
||||
id_generator (:class:`xblock.runtime.IdGenerator`): Used to generate the
|
||||
usage_ids and definition_ids when loading this xml
|
||||
|
||||
"""
|
||||
raise NotImplementedError('Modules must implement from_xml to be parsable from xml')
|
||||
|
||||
def add_xml_to_node(self, node):
|
||||
"""
|
||||
Export this :class:`XModuleDescriptor` as XML, by setting attributes on the provided
|
||||
`node`.
|
||||
"""
|
||||
xml_string = self.export_to_xml(self.runtime.export_fs)
|
||||
exported_node = etree.fromstring(xml_string)
|
||||
node.tag = exported_node.tag
|
||||
node.text = exported_node.text
|
||||
node.tail = exported_node.tail
|
||||
|
||||
for key, value in exported_node.items():
|
||||
if key == 'url_name' and value == 'course' and key in node.attrib:
|
||||
# if url_name is set in ExportManager then do not override it here.
|
||||
continue
|
||||
node.set(key, value)
|
||||
|
||||
node.extend(list(exported_node))
|
||||
|
||||
def export_to_xml(self, resource_fs):
|
||||
"""
|
||||
Returns an xml string representing this module, and all modules
|
||||
underneath it. May also write required resources out to resource_fs.
|
||||
|
||||
Assumes that modules have single parentage (that no module appears twice
|
||||
in the same course), and that it is thus safe to nest modules as xml
|
||||
children as appropriate.
|
||||
|
||||
The returned XML should be able to be parsed back into an identical
|
||||
XModuleDescriptor using the from_xml method with the same system, org,
|
||||
and course
|
||||
"""
|
||||
raise NotImplementedError('Modules must implement export_to_xml to enable xml export')
|
||||
|
||||
|
||||
@XBlock.needs("i18n")
|
||||
class XModuleDescriptor(XModuleDescriptorToXBlockMixin, HTMLSnippet, ResourceTemplates, XModuleMixin): # lint-amnesty, pylint: disable=abstract-method
|
||||
class XModuleDescriptor(HTMLSnippet, ResourceTemplates, XModuleMixin): # lint-amnesty, pylint: disable=abstract-method
|
||||
"""
|
||||
An XModuleDescriptor is a specification for an element of a course. This
|
||||
could be a problem, an organizational element (a group of content), or a
|
||||
@@ -2004,6 +1913,19 @@ class ModuleSystemShim:
|
||||
if replace_urls_service:
|
||||
return partial(replace_urls_service.replace_urls)
|
||||
|
||||
@property
|
||||
def filestore(self):
|
||||
"""
|
||||
A filestore ojbect. Defaults to an instance of OSFS based at settings.DATA_DIR.
|
||||
|
||||
Deprecated in favor of runtime.resources_fs property.
|
||||
"""
|
||||
warnings.warn(
|
||||
'runtime.filestore is deprecated. Please use the runtime.resources_fs service instead.',
|
||||
DeprecationWarning, stacklevel=3,
|
||||
)
|
||||
return self.resources_fs
|
||||
|
||||
|
||||
class ModuleSystem(MetricsMixin, ConfigurableFragmentWrapper, ModuleSystemShim, Runtime):
|
||||
"""
|
||||
@@ -2020,9 +1942,8 @@ class ModuleSystem(MetricsMixin, ConfigurableFragmentWrapper, ModuleSystemShim,
|
||||
|
||||
def __init__(
|
||||
self, static_url, track_function, get_module,
|
||||
descriptor_runtime, filestore=None,
|
||||
debug=False, hostname="", publish=None, node_path="",
|
||||
course_id=None, error_descriptor_class=None,
|
||||
descriptor_runtime, debug=False, hostname="", publish=None,
|
||||
node_path="", course_id=None, error_descriptor_class=None,
|
||||
field_data=None, rebind_noauth_module_to_user=None,
|
||||
**kwargs):
|
||||
"""
|
||||
@@ -2039,9 +1960,6 @@ class ModuleSystem(MetricsMixin, ConfigurableFragmentWrapper, ModuleSystemShim,
|
||||
module instance object. If the current user does not have
|
||||
access to that location, returns None.
|
||||
|
||||
filestore - A filestore ojbect. Defaults to an instance of OSFS based
|
||||
at settings.DATA_DIR.
|
||||
|
||||
descriptor_runtime - A `DescriptorSystem` to use for loading xblocks by id
|
||||
|
||||
course_id - the course_id containing this module
|
||||
@@ -2064,7 +1982,6 @@ class ModuleSystem(MetricsMixin, ConfigurableFragmentWrapper, ModuleSystemShim,
|
||||
|
||||
self.STATIC_URL = static_url
|
||||
self.track_function = track_function
|
||||
self.filestore = filestore
|
||||
self.get_module = get_module
|
||||
self.DEBUG = self.debug = debug
|
||||
self.HOSTNAME = self.hostname = hostname
|
||||
|
||||
@@ -112,21 +112,6 @@ class XmlParserMixin:
|
||||
xml_attributes = Dict(help="Map of unhandled xml attributes, used only for storage between import and export",
|
||||
default={}, scope=Scope.settings)
|
||||
|
||||
# VS[compat]. Backwards compatibility code that can go away after
|
||||
# importing 2012 courses.
|
||||
# A set of metadata key conversions that we want to make
|
||||
metadata_translations = {
|
||||
'slug': 'url_name',
|
||||
'name': 'display_name',
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _translate(cls, key):
|
||||
"""
|
||||
VS[compat]
|
||||
"""
|
||||
return cls.metadata_translations.get(key, key)
|
||||
|
||||
# The attributes will be removed from the definition xml passed
|
||||
# to definition_from_xml, and from the xml returned by definition_to_xml
|
||||
|
||||
@@ -275,8 +260,6 @@ class XmlParserMixin:
|
||||
"""
|
||||
metadata = {'xml_attributes': {}}
|
||||
for attr, val in xml_object.attrib.items():
|
||||
# VS[compat]. Remove after all key translations done
|
||||
attr = cls._translate(attr)
|
||||
|
||||
if attr in cls.metadata_to_strip:
|
||||
# don't load these
|
||||
@@ -295,7 +278,6 @@ class XmlParserMixin:
|
||||
through the attrmap. Updates the metadata dict in place.
|
||||
"""
|
||||
for attr, value in policy.items():
|
||||
attr = cls._translate(attr)
|
||||
if attr not in cls.fields:
|
||||
# Store unknown attributes coming from policy.json
|
||||
# in such a way that they will export to xml unchanged
|
||||
@@ -561,9 +543,9 @@ class XmlMixin(XmlParserMixin): # lint-amnesty, pylint: disable=abstract-method
|
||||
Interpret the parsed XML in `node`, creating an XModuleDescriptor.
|
||||
"""
|
||||
if cls.from_xml != XmlMixin.from_xml:
|
||||
# Skip the parse_xml from XmlParserMixin to get the shim parse_xml
|
||||
# from XModuleDescriptor, which actually calls `from_xml`.
|
||||
return super(XmlParserMixin, cls).parse_xml(node, runtime, keys, id_generator) # pylint: disable=bad-super-call
|
||||
xml = etree.tostring(node).decode('utf-8')
|
||||
block = cls.from_xml(xml, runtime, id_generator)
|
||||
return block
|
||||
else:
|
||||
return super().parse_xml(node, runtime, keys, id_generator)
|
||||
|
||||
@@ -605,9 +587,19 @@ class XmlMixin(XmlParserMixin): # lint-amnesty, pylint: disable=abstract-method
|
||||
`node`.
|
||||
"""
|
||||
if self.export_to_xml != XmlMixin.export_to_xml: # lint-amnesty, pylint: disable=comparison-with-callable
|
||||
# Skip the add_xml_to_node from XmlParserMixin to get the shim add_xml_to_node
|
||||
# from XModuleDescriptor, which actually calls `export_to_xml`.
|
||||
super(XmlParserMixin, self).add_xml_to_node(node) # pylint: disable=bad-super-call
|
||||
xml_string = self.export_to_xml(self.runtime.export_fs)
|
||||
exported_node = etree.fromstring(xml_string)
|
||||
node.tag = exported_node.tag
|
||||
node.text = exported_node.text
|
||||
node.tail = exported_node.tail
|
||||
|
||||
for key, value in exported_node.items():
|
||||
if key == 'url_name' and value == 'course' and key in node.attrib:
|
||||
# if url_name is set in ExportManager then do not override it here.
|
||||
continue
|
||||
node.set(key, value)
|
||||
|
||||
node.extend(list(exported_node))
|
||||
else:
|
||||
super().add_xml_to_node(node)
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
/**
|
||||
* Used to ellipsize a section of arbitrary HTML after a specified number of words.
|
||||
*
|
||||
* Note: this will modify the DOM structure of root in place.
|
||||
* To keep the original around, you may want to save the result of cloneNode(true) before calling this method.
|
||||
*
|
||||
* Known bug: This method will ignore any special whitespace in the source and simply output single spaces.
|
||||
* Which means that will not be respected. This is not considered worth solving at time of writing.
|
||||
*
|
||||
* Returns how many words remain (or a negative number if the content got clamped)
|
||||
*/
|
||||
function clampHtmlByWords(root, wordsLeft) {
|
||||
'use strict';
|
||||
|
||||
if (root.nodeName === 'SCRIPT' || root.nodeName === 'STYLE') {
|
||||
return wordsLeft; // early exit and ignore
|
||||
}
|
||||
|
||||
var remaining = wordsLeft;
|
||||
var nodes = Array.from(root.childNodes ? root.childNodes : []);
|
||||
var words, chopped;
|
||||
|
||||
// First, cut short any text in our node, as necessary
|
||||
if (root.nodeName === '#text' && root.data) {
|
||||
// split on words, ignoring any resulting empty strings
|
||||
words = root.data.split(/\s+/).filter(Boolean);
|
||||
if (remaining < 0) {
|
||||
root.data = ''; // eslint-disable-line no-param-reassign
|
||||
} else if (remaining > words.length) {
|
||||
remaining -= words.length;
|
||||
} else {
|
||||
// OK, let's add an ellipsis and cut some of root.data
|
||||
chopped = words.slice(0, remaining).join(' ') + '…';
|
||||
// But be careful to get any preceding space too
|
||||
if (root.data.match(/^\s/)) {
|
||||
chopped = ' ' + chopped;
|
||||
}
|
||||
root.data = chopped; // eslint-disable-line no-param-reassign
|
||||
remaining = -1;
|
||||
}
|
||||
}
|
||||
|
||||
// Now do the same for any child nodes
|
||||
nodes.forEach(function(node) {
|
||||
if (remaining < 0) {
|
||||
root.removeChild(node);
|
||||
} else {
|
||||
remaining = clampHtmlByWords(node, remaining);
|
||||
}
|
||||
});
|
||||
|
||||
return remaining;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
clampHtmlByWords: clampHtmlByWords
|
||||
};
|
||||
@@ -1,38 +0,0 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import { clampHtmlByWords } from './clamp-html';
|
||||
|
||||
let container;
|
||||
const scriptTag = '<script src="/asset-v1:edX+testX+1T2021+type@asset+block/script.js">const ignore = "me here"; alert("BAD");</script>';
|
||||
const styleTag = '<style>h1 {color: orange;}</style>';
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
document.body.removeChild(container);
|
||||
container = null;
|
||||
});
|
||||
|
||||
describe('ClampHtml', () => {
|
||||
test.each([
|
||||
['', 0, ''],
|
||||
['a b', 0, '…'],
|
||||
['a b', 1, 'a…'],
|
||||
['a b c', 2, 'a b…'],
|
||||
['a <i>aa ab</i> b', 2, 'a <i>aa…</i>'],
|
||||
['a <i>aa ab</i> <em>ac</em>', 2, 'a <i>aa…</i>'],
|
||||
['a <i>aa <em>aaa</em></i>', 2, 'a <i>aa…</i>'],
|
||||
['a <i>aa <em>aaa</em> ab</i>', 3, 'a <i>aa <em>aaa…</em></i>'],
|
||||
['a <i>aa ab</i> b c', 4, 'a <i>aa ab</i> b…'],
|
||||
[scriptTag + 'a b c', 2, scriptTag + 'a b…'],
|
||||
[styleTag + 'a b c', 2, styleTag + 'a b…'],
|
||||
[scriptTag + styleTag + 'a b c', 2, scriptTag + styleTag + 'a b…'],
|
||||
])('clamps by words: %s, %i', (input, wordsLeft, expected) => {
|
||||
const div = ReactDOM.render(<div dangerouslySetInnerHTML={{ __html: input }} />, container);
|
||||
clampHtmlByWords(div, wordsLeft);
|
||||
expect(div.innerHTML).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -477,12 +477,6 @@
|
||||
<span class="icon fa fa-star" aria-hidden="true"></span><%- gettext("follow this post") %>
|
||||
</span>
|
||||
</label>
|
||||
<% if (allow_anonymous) { %>
|
||||
<label for="anonymous" class="field-label label-inline">
|
||||
<input id="anonymous" type="checkbox" name="anonymous" class="field-input input-checkbox">
|
||||
<span class="field-input-label"><%- gettext("post anonymously") %></span>
|
||||
</label>
|
||||
<% } %>
|
||||
<% if (allow_anonymous_to_peers) { %>
|
||||
<label for="anonymous_to_peers" class="field-label label-inline">
|
||||
<input id="anonymous_to_peers" type="checkbox" name="anonymous_to_peers" class="field-input input-checkbox">
|
||||
|
||||
@@ -56,23 +56,6 @@
|
||||
{% endfilter %}
|
||||
{% include "ace_common/edx_ace/common/return_to_course_cta.html" with course_cta_text=course_cta_text course_cta_url=proctoring_requirements_url %}
|
||||
|
||||
{% if idv_required %}
|
||||
<p style="color: rgba(0,0,0,.75);">
|
||||
{% filter force_escape %}
|
||||
{% blocktrans %}
|
||||
Before taking a graded proctored exam, you must have approved ID verification photos.
|
||||
{% endblocktrans %}
|
||||
{% endfilter %}
|
||||
<a href="{{ id_verification_url }}">
|
||||
{% filter force_escape %}
|
||||
{% blocktrans %}
|
||||
You can submit ID verification photos here.
|
||||
{% endblocktrans %}
|
||||
{% endfilter %}
|
||||
</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
|
||||
<p>
|
||||
{% trans "Thank you," as tmsg %}{{ tmsg | force_escape }}
|
||||
<br />
|
||||
|
||||
@@ -8,10 +8,6 @@
|
||||
|
||||
{% blocktrans %}To view proctoring instructions and requirements, please visit: {{ proctoring_requirements_url }}{% endblocktrans %}
|
||||
|
||||
{% if idv_required %}
|
||||
{% blocktrans %}Before taking a graded proctored exam, you must have approved ID verification photos. You can submit ID verification photos here: {{ id_verification_url }}{% endblocktrans %}
|
||||
{% endif %}
|
||||
|
||||
{% trans "Thank you," %}
|
||||
{% blocktrans %}The {{ platform_name }} Team {% endblocktrans %}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<course name="Conditional Course" org="edX" course="cond_test" graceperiod="1 day 5 hours 59 minutes 59 seconds"
|
||||
slug="2012_Fall" start="2012-07-17T12:00">
|
||||
<chapter name="Problems with Condition">
|
||||
<course display_name="Conditional Course" org="edX" course="cond_test" graceperiod="1 day 5 hours 59 minutes 59 seconds"
|
||||
url_name="2012_Fall" start="2012-07-17T12:00">
|
||||
<chapter display_name="Problems with Condition">
|
||||
<!-- In order for the conditional to reference modules via "show",
|
||||
they must be defined elsewhere in the course. Therefore, add them to a
|
||||
non-released sequential. -->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
Take note of this name exactly, you'll need to use it everywhere. -->
|
||||
<course>
|
||||
<chapter url_name="Staff"/>
|
||||
<chapter name="Problems with Condition">
|
||||
<chapter display_name="Problems with Condition">
|
||||
<sequential>
|
||||
<problem url_name="choiceprob" />
|
||||
<conditional url_name="condone"/>
|
||||
|
||||
@@ -1 +1 @@
|
||||
<course org="edX" course="course_ignore" slug="2014_Fall"/>
|
||||
<course org="edX" course="course_ignore" url_name="2014_Fall"/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<problem attempts="10" weight="null" max_attempts="null" markdown="null">
|
||||
<problem weight="null" max_attempts="10" markdown="null">
|
||||
<script type="loncapa/python">
|
||||
|
||||
def two_d_grader(expect,ans):
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<problem attempts="10" weight="null" max_attempts="null" markdown="null">
|
||||
<problem weight="null" max_attempts="10" markdown="null">
|
||||
<script type="loncapa/python">
|
||||
|
||||
def two_d_grader(expect,ans):
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<course name="A Simple Course" org="edX" course="simple" graceperiod="1 day 5 hours 59 minutes 59 seconds" slug="2012_Fall">
|
||||
<chapter name="Overview">
|
||||
<course display_name="A Simple Course" org="edX" course="simple" graceperiod="1 day 5 hours 59 minutes 59 seconds" url_name="2012_Fall">
|
||||
<chapter display_name="Overview">
|
||||
<video name="Welcome" youtube_id_0_75="izygArpw-Qo" youtube_id_1_0="p2Q6BrNhdh8" youtube_id_1_25="1EeWXzPdhSA" youtube_id_1_5="rABDYkeK0x8"/>
|
||||
<sequential format="Lecture Sequence" name="A simple sequence">
|
||||
<html name="toylab" filename="toylab"/>
|
||||
<sequential format="Lecture Sequence" display_name="A simple sequence">
|
||||
<html display_name="toylab" filename="toylab"/>
|
||||
<video name="S0V1: Video Resources" youtube_id_0_75="EuzkdzfR0i8" youtube_id_1_0="1bK-WdDi6Qw" youtube_id_1_25="0v1VzoDVUTM" youtube_id_1_5="Bxk_-ZJb240"/>
|
||||
</sequential>
|
||||
<sequential name="Lecture 2">>
|
||||
<sequential display_name="Lecture 2">
|
||||
<video youtube_id_1_0="TBvX7HzxexQ"/>
|
||||
<problem name="L1 Problem 1" points="1" type="lecture" showanswer="attempted" filename="L1_Problem_1" rerandomize="never"/>
|
||||
<problem display_name="L1 Problem 1" points="1" type="lecture" showanswer="attempted" filename="L1_Problem_1" rerandomize="never"/>
|
||||
</sequential>
|
||||
</chapter>
|
||||
<chapter name="Chapter 2" url_name='chapter_2'>
|
||||
<sequential name="Problem Set 1">
|
||||
<problem type="lecture" showanswer="attempted" rerandomize="true" display_name="A simple coding problem" name="Simple coding problem" filename="ps01-simple" url_name="ps01-simple"/>
|
||||
<chapter display_name="Chapter 2" url_name='chapter_2'>
|
||||
<sequential display_name="Problem Set 1">
|
||||
<problem type="lecture" showanswer="attempted" rerandomize="true" display_name="Simple coding problem" filename="ps01-simple" url_name="ps01-simple"/>
|
||||
</sequential>
|
||||
<video name="Lost Video" youtube_id_1_0="TBvX7HzxexQ"/>
|
||||
<sequential format="Lecture Sequence" url_name='test_sequence'>
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<course name="A Simple Course" org="edX" course="simple_with_draft" graceperiod="1 day 5 hours 59 minutes 59 seconds" slug="2012_Fall">
|
||||
<chapter name="Overview">
|
||||
<course display_name="A Simple Course" org="edX" course="simple_with_draft" graceperiod="1 day 5 hours 59 minutes 59 seconds" url_name="2012_Fall">
|
||||
<chapter display_name="Overview">
|
||||
<video name="Welcome" youtube_id_0_75="izygArpw-Qo" youtube_id_1_0="p2Q6BrNhdh8" youtube_id_1_25="1EeWXzPdhSA" youtube_id_1_5="rABDYkeK0x8"/>
|
||||
<sequential format="Lecture Sequence" name="A simple sequence">
|
||||
<html name="toylab" filename="toylab"/>
|
||||
<sequential format="Lecture Sequence" display_name="A simple sequence">
|
||||
<html display_name="toylab" filename="toylab"/>
|
||||
<video name="S0V1: Video Resources" youtube_id_0_75="EuzkdzfR0i8" youtube_id_1_0="1bK-WdDi6Qw" youtube_id_1_25="0v1VzoDVUTM" youtube_id_1_5="Bxk_-ZJb240"/>
|
||||
</sequential>
|
||||
<sequential name="Lecture 2">
|
||||
<sequential display_name="Lecture 2">
|
||||
<video youtube_id_1_0="TBvX7HzxexQ"/>
|
||||
<problem name="L1 Problem 1" points="1" type="lecture" showanswer="attempted" filename="L1_Problem_1" rerandomize="never"/>
|
||||
<problem display_name="L1 Problem 1" points="1" type="lecture" showanswer="attempted" filename="L1_Problem_1" rerandomize="never"/>
|
||||
</sequential>
|
||||
</chapter>
|
||||
<chapter name="Chapter 2" url_name='chapter_2'>
|
||||
<sequential name="Problem Set 1">
|
||||
<problem type="lecture" showanswer="attempted" rerandomize="true" display_name="A simple coding problem" name="Simple coding problem" filename="ps01-simple" url_name="ps01-simple"/>
|
||||
<chapter display_name="Chapter 2" url_name='chapter_2'>
|
||||
<sequential display_name="Problem Set 1">
|
||||
<problem type="lecture" showanswer="attempted" rerandomize="true" display_name="Simple coding problem" filename="ps01-simple" url_name="ps01-simple"/>
|
||||
</sequential>
|
||||
<video name="Lost Video" youtube_id_1_0="TBvX7HzxexQ"/>
|
||||
<sequential format="Lecture Sequence" url_name='test_sequence'>
|
||||
|
||||
@@ -5,12 +5,12 @@
|
||||
<split_test url_name="split1" user_partition_id="0" group_id_to_child='{"0": "i4x://split_test/split_test/vertical/sample_0", "2": "i4x://split_test/split_test/vertical/sample_2"}'>
|
||||
<vertical url_name="sample_0">
|
||||
<html>Here is a prompt for group 0, please respond in the discussion.</html>
|
||||
<discussion for="split test discussion 0" id="split_test_d0" discussion_category="Lectures"/>
|
||||
<discussion discussion_target="split test discussion 0" discussion_id="split_test_d0" discussion_category="Lectures"/>
|
||||
</vertical>
|
||||
|
||||
<vertical url_name="sample_2">
|
||||
<html>Here is a prompt for group 2, please respond in the discussion.</html>
|
||||
<discussion for="split test discussion 2" id="split_test_d2" discussion_category="Lectures"/>
|
||||
<discussion discussion_target="split test discussion 2" discussion_id="split_test_d2" discussion_category="Lectures"/>
|
||||
</vertical>
|
||||
</split_test>
|
||||
</vertical>
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
<sequential>
|
||||
<sequential filename='vertical_sequential' slug='vertical_sequential' />
|
||||
<sequential filename='vertical_sequential' url_name='vertical_sequential' />
|
||||
</sequential>
|
||||
@@ -1,4 +1,4 @@
|
||||
<sequential>
|
||||
<vertical filename="vertical_test" slug="vertical_test" />
|
||||
<html slug="unicode">…</html>
|
||||
<vertical filename="vertical_test" url_name="vertical_test" />
|
||||
<html url_name="unicode">…</html>
|
||||
</sequential>
|
||||
@@ -1,3 +1,3 @@
|
||||
<sequential>
|
||||
<sequential filename='vertical_sequential' slug='vertical_sequential' />
|
||||
<sequential filename='vertical_sequential' url_name='vertical_sequential' />
|
||||
</sequential>
|
||||
@@ -1,4 +1,4 @@
|
||||
<sequential>
|
||||
<vertical filename="vertical_test" slug="vertical_test" />
|
||||
<html slug="unicode">…</html>
|
||||
<vertical filename="vertical_test" url_name="vertical_test" />
|
||||
<html url_name="unicode">…</html>
|
||||
</sequential>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user