Merge branch 'master' of github.com:openedx/edx-platform into pooja/convert-warning-back-to-html

This commit is contained in:
pkulkark
2022-04-22 16:38:29 +05:30
382 changed files with 8351 additions and 9858 deletions

View File

@@ -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

View File

@@ -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
View 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

View 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

View File

@@ -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:

View File

@@ -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

View File

@@ -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/",

View 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

View File

@@ -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 ]

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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():

View File

@@ -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,

View 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

View 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]

View File

@@ -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

View 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)
)

View File

@@ -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)),

View 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)

View File

@@ -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)

View 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

View File

@@ -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

View File

@@ -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 = []

View File

@@ -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', {}))

View File

@@ -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 = []

View File

@@ -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: {

View File

@@ -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,

View 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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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']

View File

@@ -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:

View File

@@ -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

View File

@@ -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. """

View File

@@ -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:

View File

@@ -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'))

View File

@@ -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
"""

View File

@@ -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")

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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')

View File

@@ -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-")

View File

@@ -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):

View File

@@ -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

View File

@@ -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

View File

@@ -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,

View File

@@ -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()

View File

@@ -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

View File

@@ -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,

View File

@@ -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'),

View File

@@ -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,

View File

@@ -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)

View File

@@ -29,7 +29,7 @@
.simple-editor-cheatsheet {
position: absolute;
top: 41px;
left: 70%;
@include left(70%);
width: 0;
border-left: 1px solid $gray-l2;

View File

@@ -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
"""

View File

@@ -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,
):

View File

@@ -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.

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)

View File

@@ -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:

View File

@@ -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,

View File

@@ -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,

View File

@@ -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

View File

@@ -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,

View File

@@ -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,
):

View File

@@ -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>

View File

@@ -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={

View File

@@ -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)

View File

@@ -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
)

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View File

@@ -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 &nbsp; 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
};

View File

@@ -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);
});
});

View File

@@ -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">

View File

@@ -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 />

View File

@@ -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 %}

View File

@@ -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. -->

View File

@@ -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"/>

View File

@@ -1 +1 @@
<course org="edX" course="course_ignore" slug="2014_Fall"/>
<course org="edX" course="course_ignore" url_name="2014_Fall"/>

View File

@@ -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):

View File

@@ -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):

View File

@@ -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'>

View File

@@ -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'>

View File

@@ -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>

View File

@@ -1,3 +1,3 @@
<sequential>
<sequential filename='vertical_sequential' slug='vertical_sequential' />
<sequential filename='vertical_sequential' url_name='vertical_sequential' />
</sequential>

View File

@@ -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>

View File

@@ -1,3 +1,3 @@
<sequential>
<sequential filename='vertical_sequential' slug='vertical_sequential' />
<sequential filename='vertical_sequential' url_name='vertical_sequential' />
</sequential>

View File

@@ -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