diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index 8373d63448ef..038f49cd4470 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -21,7 +21,11 @@ openedx/core/djangoapps/user_api/ @openedx/com
openedx/core/djangoapps/user_authn/ @openedx/committers-edx-platform-2u-infinity
openedx/core/djangoapps/verified_track_content/ @openedx/committers-edx-platform-2u-infinity
openedx/features/course_experience/
-xmodule/
+# The Aximprovements team is working on extracting all built-in XBlocks
+# to the external repository (xblocks-contrib). They need to be notified
+# about any changes within xmodule to stay aligned with this effort.
+# Ticket: https://github.com/openedx/edx-platform/issues/34827
+xmodule/ @farhan @irtazaakram @salman2013
# Core Extensions
lms/djangoapps/discussion/
diff --git a/.github/workflows/add-depr-ticket-to-depr-board.yml b/.github/workflows/add-depr-ticket-to-depr-board.yml
deleted file mode 100644
index 250e394abc11..000000000000
--- a/.github/workflows/add-depr-ticket-to-depr-board.yml
+++ /dev/null
@@ -1,19 +0,0 @@
-# Run the workflow that adds new tickets that are either:
-# - labelled "DEPR"
-# - title starts with "[DEPR]"
-# - body starts with "Proposal Date" (this is the first template field)
-# to the org-wide DEPR project board
-
-name: Add newly created DEPR issues to the DEPR project board
-
-on:
- issues:
- types: [opened]
-
-jobs:
- routeissue:
- uses: openedx/.github/.github/workflows/add-depr-ticket-to-depr-board.yml@master
- secrets:
- GITHUB_APP_ID: ${{ secrets.GRAPHQL_AUTH_APP_ID }}
- GITHUB_APP_PRIVATE_KEY: ${{ secrets.GRAPHQL_AUTH_APP_PEM }}
- SLACK_BOT_TOKEN: ${{ secrets.SLACK_ISSUE_BOT_TOKEN }}
diff --git a/.github/workflows/add-remove-label-on-comment.yml b/.github/workflows/add-remove-label-on-comment.yml
deleted file mode 100644
index 0f369db7d293..000000000000
--- a/.github/workflows/add-remove-label-on-comment.yml
+++ /dev/null
@@ -1,20 +0,0 @@
-# This workflow runs when a comment is made on the ticket
-# If the comment starts with "label: " it tries to apply
-# the label indicated in rest of comment.
-# If the comment starts with "remove label: ", it tries
-# to remove the indicated label.
-# Note: Labels are allowed to have spaces and this script does
-# not parse spaces (as often a space is legitimate), so the command
-# "label: really long lots of words label" will apply the
-# label "really long lots of words label"
-
-name: Allows for the adding and removing of labels via comment
-
-on:
- issue_comment:
- types: [created]
-
-jobs:
- add_remove_labels:
- uses: openedx/.github/.github/workflows/add-remove-label-on-comment.yml@master
-
diff --git a/.github/workflows/check-consistent-dependencies.yml b/.github/workflows/check-consistent-dependencies.yml
index c3f35d92a03b..46f801179e84 100644
--- a/.github/workflows/check-consistent-dependencies.yml
+++ b/.github/workflows/check-consistent-dependencies.yml
@@ -7,6 +7,7 @@ name: Consistent Python dependencies
on:
pull_request:
+ merge_group:
defaults:
run:
@@ -18,26 +19,31 @@ jobs:
runs-on: ubuntu-24.04
steps:
+ # Always checkout the code because we don't always have a PR url.
+ - uses: actions/checkout@v5
+
# Only run remaining steps if there are changes to requirements/**
+ # We do this instead of using path based short-circuiting.
+ # see https://stackoverflow.com/questions/77996177/how-can-i-handle-a-required-check-that-isnt-always-triggered
+ # for some more details.
- name: "Decide whether to short-circuit"
- env:
- GH_TOKEN: "${{ github.token }}"
- PR_URL: "${{ github.event.pull_request.html_url }}"
run: |
- paths=$(gh pr diff "$PR_URL" --name-only)
- echo $'Paths touched in PR:\n'"$paths"
+ if [[ "${{ github.event_name }}" == "pull_request" ]]; then
+ BASE_SHA="${{ github.event.pull_request.base.sha }}"
+ else
+ BASE_SHA="${{ github.event.merge_group.base_sha }}"
+ fi
+
+ # Fetch the base sha so we can compare to it. It's not checked out by
+ # default.
+ git fetch origin "$BASE_SHA"
# The ^"? is because git may quote weird file paths
- matched="$(echo "$paths" | grep -P '^"?((requirements/)|(scripts/.*?/requirements/))' || true)"
- echo $'Relevant paths:\n'"$matched"
- if [[ -n "$matched" ]]; then
- echo "RELEVANT=true" >> "$GITHUB_ENV"
+ if git diff --name-only "$BASE_SHA" | grep -P '^"?((requirements/)|(scripts/.*?/requirements/))'; then
+ echo "RELEVANT=true" >> "$GITHUB_ENV"
fi
- - uses: actions/checkout@v5
- if: ${{ env.RELEVANT == 'true' }}
-
- - uses: actions/setup-python@v5
+ - uses: actions/setup-python@v6
if: ${{ env.RELEVANT == 'true' }}
with:
python-version: '3.11'
diff --git a/.github/workflows/check-for-tutorial-prs.yml b/.github/workflows/check-for-tutorial-prs.yml
index 1dc4d3860956..e3969a1920e9 100644
--- a/.github/workflows/check-for-tutorial-prs.yml
+++ b/.github/workflows/check-for-tutorial-prs.yml
@@ -26,7 +26,7 @@ jobs:
uses: actions/checkout@v5
- name: Comment PR
- uses: thollander/actions-comment-pull-request@v2
+ uses: thollander/actions-comment-pull-request@v3
with:
message: |
Thank you for your pull request! Congratulations on completing the Open edX tutorial! A team member will be by to take a look shortly.
diff --git a/.github/workflows/check_python_dependencies.yml b/.github/workflows/check_python_dependencies.yml
index 7b93a545cd4b..f215880f0ef4 100644
--- a/.github/workflows/check_python_dependencies.yml
+++ b/.github/workflows/check_python_dependencies.yml
@@ -2,6 +2,7 @@ name: Check Python Dependencies
on:
pull_request:
+ merge_group:
jobs:
check_dependencies:
@@ -16,7 +17,7 @@ jobs:
uses: actions/checkout@v5
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
diff --git a/.github/workflows/ci-static-analysis.yml b/.github/workflows/ci-static-analysis.yml
index d2513ba2104f..deb2853899f4 100644
--- a/.github/workflows/ci-static-analysis.yml
+++ b/.github/workflows/ci-static-analysis.yml
@@ -1,6 +1,8 @@
name: Static analysis
-on: pull_request
+on:
+ pull_request:
+ merge_group:
jobs:
tests:
@@ -15,7 +17,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml
index fec11d6c259b..03b0c6c1336e 100644
--- a/.github/workflows/commitlint.yml
+++ b/.github/workflows/commitlint.yml
@@ -3,7 +3,8 @@
name: Lint Commit Messages
on:
- - pull_request
+ pull_request:
+ merge_group:
jobs:
commitlint:
diff --git a/.github/workflows/compile-python-requirements.yml b/.github/workflows/compile-python-requirements.yml
index 8673cc3c234c..9b4eb7f79753 100644
--- a/.github/workflows/compile-python-requirements.yml
+++ b/.github/workflows/compile-python-requirements.yml
@@ -24,7 +24,7 @@ jobs:
ref: "${{ inputs.branch }}"
- name: Set up Python environment
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: "3.11"
diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml
index 94a1368e96a5..1bb821daeb6d 100644
--- a/.github/workflows/js-tests.yml
+++ b/.github/workflows/js-tests.yml
@@ -2,9 +2,10 @@ name: Javascript tests
on:
pull_request:
+ merge_group:
push:
branches:
- - master
+ - release-ulmo
jobs:
run_tests:
@@ -23,7 +24,7 @@ jobs:
run: git fetch --depth=1 origin master
- name: Setup Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
@@ -43,7 +44,7 @@ jobs:
run: sudo apt-get update && sudo apt-get install libxmlsec1-dev ubuntu-restricted-extras xvfb
- name: Setup Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
diff --git a/.github/workflows/lint-imports.yml b/.github/workflows/lint-imports.yml
index baf914298be2..ea6424807799 100644
--- a/.github/workflows/lint-imports.yml
+++ b/.github/workflows/lint-imports.yml
@@ -2,9 +2,10 @@ name: Lint Python Imports
on:
pull_request:
+ merge_group:
push:
branches:
- - master
+ - release-ulmo
jobs:
lint-imports:
@@ -16,7 +17,7 @@ jobs:
uses: actions/checkout@v5
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: "3.11"
diff --git a/.github/workflows/lockfileversion-check.yml b/.github/workflows/lockfileversion-check.yml
index 736f1f98de13..22baf1d80ab0 100644
--- a/.github/workflows/lockfileversion-check.yml
+++ b/.github/workflows/lockfileversion-check.yml
@@ -5,8 +5,9 @@ name: Lockfile Version check
on:
push:
branches:
- - master
+ - release-ulmo
pull_request:
+ merge_group:
jobs:
version-check:
diff --git a/.github/workflows/migrations-check.yml b/.github/workflows/migrations-check.yml
index cd4d09589c12..5da69916235f 100644
--- a/.github/workflows/migrations-check.yml
+++ b/.github/workflows/migrations-check.yml
@@ -3,9 +3,10 @@ name: Check Django Migrations
on:
workflow_dispatch:
pull_request:
+ merge_group:
push:
branches:
- - master
+ - release-ulmo
jobs:
check_migrations:
@@ -73,7 +74,7 @@ jobs:
uses: actions/checkout@v5
- name: Setup Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml
index abc51eb91b74..717200c3f6c9 100644
--- a/.github/workflows/pylint-checks.yml
+++ b/.github/workflows/pylint-checks.yml
@@ -2,9 +2,10 @@ name: Pylint Checks
on:
pull_request:
+ merge_group:
push:
branches:
- - master
+ - release-ulmo
jobs:
run-pylint:
@@ -37,7 +38,7 @@ jobs:
run: sudo apt-get update && sudo apt-get install libxmlsec1-dev
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: 3.11
diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml
index 3f4cbeeb4df9..dabb9c5c2cc6 100644
--- a/.github/workflows/quality-checks.yml
+++ b/.github/workflows/quality-checks.yml
@@ -2,10 +2,10 @@ name: Quality checks
on:
pull_request:
+ merge_group:
push:
branches:
- - master
- - open-release/lilac.master
+ - release-ulmo
jobs:
run_tests:
@@ -30,12 +30,12 @@ jobs:
run: sudo apt-get update && sudo apt-get install libxmlsec1-dev
- name: Setup Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
- name: Setup Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml
index 520cd23a678b..e0ab53dcd426 100644
--- a/.github/workflows/semgrep.yml
+++ b/.github/workflows/semgrep.yml
@@ -7,9 +7,10 @@ name: Semgrep code quality
on:
pull_request:
+ merge_group:
push:
branches:
- - master
+ - release-ulmo
jobs:
run_semgrep:
@@ -26,7 +27,7 @@ jobs:
with:
fetch-depth: 1
- - uses: actions/setup-python@v5
+ - uses: actions/setup-python@v6
with:
python-version: "${{ matrix.python-version }}"
diff --git a/.github/workflows/shellcheck.yml b/.github/workflows/shellcheck.yml
index 2e5b04bcc2ff..93bcb2378af9 100644
--- a/.github/workflows/shellcheck.yml
+++ b/.github/workflows/shellcheck.yml
@@ -7,9 +7,10 @@ name: ShellCheck
on:
pull_request:
+ merge_group:
push:
branches:
- - master
+ - release-ulmo
permissions:
contents: read
diff --git a/.github/workflows/static-assets-check.yml b/.github/workflows/static-assets-check.yml
index 43cb597c16d7..3cb66927e5f4 100644
--- a/.github/workflows/static-assets-check.yml
+++ b/.github/workflows/static-assets-check.yml
@@ -2,9 +2,10 @@ name: static assets check for lms and cms
on:
pull_request:
+ merge_group:
push:
branches:
- - master
+ - release-ulmo
jobs:
static_assets_check:
@@ -38,7 +39,7 @@ jobs:
uses: actions/checkout@v5
- name: Setup Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
@@ -48,7 +49,7 @@ jobs:
sudo apt-get install libxmlsec1-dev pkg-config
- name: Setup Node
- uses: actions/setup-node@v4
+ uses: actions/setup-node@v5
with:
node-version: ${{ matrix.node-version }}
diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json
index 827366365fa8..cb9beeb3c6bf 100644
--- a/.github/workflows/unit-test-shards.json
+++ b/.github/workflows/unit-test-shards.json
@@ -239,7 +239,6 @@
"cms/djangoapps/course_creators/",
"cms/djangoapps/export_course_metadata/",
"cms/djangoapps/modulestore_migrator/",
- "cms/djangoapps/maintenance/",
"cms/djangoapps/models/",
"cms/djangoapps/pipeline_js/",
"cms/djangoapps/xblock_config/",
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index 05e5f47d1aae..ea0be609b9a4 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -1,10 +1,13 @@
name: unit-tests
+permissions:
+ contents: read
on:
pull_request:
+ merge_group:
push:
branches:
- - master
+ - release-ulmo
concurrency:
# We only need to be running tests for the latest commit on each PR
@@ -73,6 +76,12 @@ jobs:
run: |
sudo apt-get update && sudo apt-get install libmysqlclient-dev libxmlsec1-dev lynx
+ - name: Upgrade Docker
+ run: |
+ sudo apt-get update
+ sudo apt-get install --only-upgrade docker-ce docker-ce-cli containerd.io
+ docker --version
+
# We pull this image a lot, and Dockerhub will rate limit us if we pull too often.
# This is an attempt to cache the image for better performance and to work around that.
# It will cache all pulled images, so if we add new images to this we'll need to update the key.
@@ -82,12 +91,13 @@ jobs:
key: docker-${{ runner.os }}-mongo-${{ matrix.mongo-version }}
- name: Start MongoDB
- uses: supercharge/mongodb-github-action@1.12.0
- with:
- mongodb-version: ${{ matrix.mongo-version }}
+ run: |
+ docker run -d -p 27017:27017 --name mongodb mongo:${{ matrix.mongo-version }}
+ sleep 10
+ docker ps
- name: Setup Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
@@ -123,26 +133,26 @@ jobs:
shell: bash
run: |
cd test_root/log
- mv pytest_warnings.json pytest_warnings_${{ matrix.shard_name }}.json
+ mv pytest_warnings.json pytest_warnings_${{ matrix.shard_name }}_${{ matrix.python-version }}_${{ matrix.django-version }}_${{ matrix.mongo-version }}_${{ matrix.os-version }}.json
- name: save pytest warnings json file
if: success()
uses: actions/upload-artifact@v4
with:
- name: pytest-warnings-json-${{ matrix.shard_name }}
+ name: pytest-warnings-json-${{ matrix.shard_name }}-${{ matrix.python-version }}-${{ matrix.django-version }}-${{ matrix.mongo-version }}-${{ matrix.os-version }}
path: |
test_root/log/pytest_warnings*.json
overwrite: true
- name: Renaming coverage data file
run: |
- mv reports/.coverage reports/${{ matrix.shard_name }}.coverage
+ mv reports/.coverage reports/${{ matrix.shard_name }}_${{ matrix.python-version }}_${{ matrix.django-version }}_${{ matrix.mongo-version }}_${{ matrix.os-version }}.coverage
- name: Upload coverage
uses: actions/upload-artifact@v4
with:
- name: coverage-${{ matrix.shard_name }}
- path: reports/${{ matrix.shard_name }}.coverage
+ name: coverage-${{ matrix.shard_name }}-${{ matrix.python-version }}-${{ matrix.django-version }}-${{ matrix.mongo-version }}-${{ matrix.os-version }}
+ path: reports/${{ matrix.shard_name }}_${{ matrix.python-version }}_${{ matrix.django-version }}_${{ matrix.mongo-version }}_${{ matrix.os-version }}.coverage
overwrite: true
collect-and-verify:
@@ -150,7 +160,7 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Setup Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: 3.11
@@ -280,7 +290,7 @@ jobs:
uses: actions/checkout@v5
- name: Setup Python ${{ matrix.python-version }}
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
diff --git a/.github/workflows/units-test-scripts-structures-pruning.yml b/.github/workflows/units-test-scripts-structures-pruning.yml
index 14a01b592308..f2cb24301262 100644
--- a/.github/workflows/units-test-scripts-structures-pruning.yml
+++ b/.github/workflows/units-test-scripts-structures-pruning.yml
@@ -2,9 +2,10 @@ name: units-test-scripts-common
on:
pull_request:
+ merge_group:
push:
branches:
- - master
+ - release-ulmo
jobs:
test:
@@ -20,7 +21,7 @@ jobs:
uses: actions/checkout@v5
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
diff --git a/.github/workflows/units-test-scripts-user-retirement.yml b/.github/workflows/units-test-scripts-user-retirement.yml
index 889c43a64a48..70f78099da68 100644
--- a/.github/workflows/units-test-scripts-user-retirement.yml
+++ b/.github/workflows/units-test-scripts-user-retirement.yml
@@ -2,9 +2,10 @@ name: units-test-scripts-user-retirement
on:
pull_request:
+ merge_group:
push:
branches:
- - master
+ - release-ulmo
jobs:
test:
@@ -20,7 +21,7 @@ jobs:
uses: actions/checkout@v5
- name: Set up Python
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: ${{ matrix.python-version }}
diff --git a/.github/workflows/update-geolite-database.yml b/.github/workflows/update-geolite-database.yml
index 484fa167a371..d9ad767ce57e 100644
--- a/.github/workflows/update-geolite-database.yml
+++ b/.github/workflows/update-geolite-database.yml
@@ -8,7 +8,7 @@ on:
branch:
description: "Target branch against which to create PR"
required: false
- default: "master"
+ default: "release-ulmo"
env:
MAXMIND_URL: "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key=${{ secrets.MAXMIND_LICENSE_KEY }}&suffix=tar.gz"
@@ -69,7 +69,7 @@ jobs:
- name: Create a branch, commit the code and make a PR
id: create-pr
run: |
- BRANCH="${{ github.actor }}/geoip2-bot-update-country-database-$(echo "${{ github.sha }}" | cut -c 1-7)"
+ BRANCH="${{ github.actor }}/geoip2-bot-update-country-database-${{ github.run_id }}"
git checkout -b $BRANCH
git add .
git status
@@ -79,13 +79,13 @@ jobs:
--title "Update GeoLite Database" \
--body "PR generated by workflow `${{ github.workflow }}` on behalf of @${{ github.actor }}." \
--head $BRANCH \
- --base 'master' \
- --reviewer 'feanil' \
+ --base 'release-ulmo' \
+ --reviewer 'edx/orbi-bom' \
| grep -o 'https://github.com/.*/pull/[0-9]*')
echo "PR Created: ${PR_URL}"
echo "pull-request-url=$PR_URL" >> $GITHUB_OUTPUT
env:
- GH_TOKEN: ${{ github.token }}
+ GH_TOKEN: ${{ secrets.GH_PAT_WITH_ORG }}
- name: Job summary
run: |
diff --git a/.github/workflows/upgrade-one-python-dependency.yml b/.github/workflows/upgrade-one-python-dependency.yml
index 3f9678593c25..4ba459f3791e 100644
--- a/.github/workflows/upgrade-one-python-dependency.yml
+++ b/.github/workflows/upgrade-one-python-dependency.yml
@@ -6,7 +6,7 @@ on:
branch:
description: "Target branch to create requirements PR against"
required: true
- default: "master"
+ default: "release-ulmo"
type: string
package:
description: "Name of package to upgrade"
@@ -37,7 +37,7 @@ jobs:
ref: "${{ inputs.branch }}"
- name: Set up Python environment
- uses: actions/setup-python@v5
+ uses: actions/setup-python@v6
with:
python-version: "3.11"
diff --git a/.github/workflows/upgrade-python-requirements.yml b/.github/workflows/upgrade-python-requirements.yml
index cbb70b06b79d..90c6be00744c 100644
--- a/.github/workflows/upgrade-python-requirements.yml
+++ b/.github/workflows/upgrade-python-requirements.yml
@@ -8,7 +8,7 @@ on:
branch:
description: "Target branch to create requirements PR against"
required: true
- default: "master"
+ default: "release-ulmo"
jobs:
call-upgrade-python-requirements-workflow:
# Don't run the weekly upgrade job on forks -- it will send a weekly failure email.
diff --git a/.github/workflows/verify-dunder-init.yml b/.github/workflows/verify-dunder-init.yml
index c3248def2f33..f7517914c6e1 100644
--- a/.github/workflows/verify-dunder-init.yml
+++ b/.github/workflows/verify-dunder-init.yml
@@ -2,8 +2,10 @@ name: Verify Dunder __init__.py Files
on:
pull_request:
+ merge_group:
+ push:
branches:
- - master
+ - release-ulmo
jobs:
verify_dunder_init:
diff --git a/.gitignore b/.gitignore
index a5d5252de705..e6664e0beace 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,10 @@ requirements/edx/private.in
requirements/edx/private.txt
lms/envs/private.py
cms/envs/private.py
+.venv/
+CLAUDE.md
+.claude/
+AGENTS.md
# end-noclean
### Python artifacts
@@ -153,3 +157,7 @@ pii_report
# Pyenv Python version file
.python-version
+
+## AI Tools
+.claude/
+CLAUDE.md
diff --git a/Makefile b/Makefile
index 6c525a57b67e..7b5cf2643173 100644
--- a/Makefile
+++ b/Makefile
@@ -58,7 +58,8 @@ pull_translations: clean_translations ## pull translations via atlas
make pull_plugin_translations
atlas pull $(ATLAS_OPTIONS) \
translations/edx-platform/conf/locale:conf/locale \
- translations/studio-frontend/src/i18n/messages:conf/plugins-locale/studio-frontend
+ translations/studio-frontend/src/i18n/messages:conf/plugins-locale/studio-frontend \
+ $(ATLAS_EXTRA_SOURCES)
python manage.py lms compilemessages
python manage.py lms compilejsi18n
python manage.py cms compilejsi18n
@@ -117,7 +118,7 @@ $(COMMON_CONSTRAINTS_TXT):
printf "$(COMMON_CONSTRAINTS_TEMP_COMMENT)" | cat - $(@) > temp && mv temp $(@)
compile-requirements: export CUSTOM_COMPILE_COMMAND=make upgrade
-compile-requirements: pre-requirements $(COMMON_CONSTRAINTS_TXT) ## Re-compile *.in requirements to *.txt
+compile-requirements: pre-requirements ## Re-compile *.in requirements to *.txt
@# Bootstrapping: Rebuild pip and pip-tools first, and then install them
@# so that if there are any failures we'll know now, rather than the next
@# time someone tries to use the outputs.
@@ -140,7 +141,7 @@ compile-requirements: pre-requirements $(COMMON_CONSTRAINTS_TXT) ## Re-compile *
export REBUILD=''; \
done
-upgrade: ## update the pip requirements files to use the latest releases satisfying our constraints
+upgrade: $(COMMON_CONSTRAINTS_TXT) ## update the pip requirements files to use the latest releases satisfying our constraints
$(MAKE) compile-requirements COMPILE_OPTS="--upgrade"
upgrade-package: ## update just one package to the latest usable release
diff --git a/README.rst b/README.rst
index dcd6e32c4998..a67e281f041b 100644
--- a/README.rst
+++ b/README.rst
@@ -74,7 +74,7 @@ OS:
* Ubuntu 24.04
-Interperters/Tools:
+Interpreters/Tools:
* Python 3.11
diff --git a/cms/djangoapps/cms_user_tasks/signals.py b/cms/djangoapps/cms_user_tasks/signals.py
index 40bfd5781825..e6ddba747d5f 100644
--- a/cms/djangoapps/cms_user_tasks/signals.py
+++ b/cms/djangoapps/cms_user_tasks/signals.py
@@ -12,6 +12,7 @@
from cms.djangoapps.contentstore.toggles import bypass_olx_failure_enabled
from cms.djangoapps.contentstore.utils import course_import_olx_validation_is_enabled
+from openedx.core.djangoapps.content_libraries.api import is_library_backup_task, is_library_restore_task
from .tasks import send_task_complete_email
@@ -64,6 +65,28 @@ def get_olx_validation_from_artifact():
if olx_artifact and not bypass_olx_failure_enabled():
return olx_artifact.text
+ def should_skip_end_of_task_email(task_name) -> bool:
+ """
+ Studio tasks generally send an email when finished, but not always.
+
+ Some tasks can last many minutes, e.g. course import/export. For these
+ tasks, there is a high chance that the user has navigated away and will
+ want to check back in later. Yet email notification is unnecessary and
+ distracting for things like the Library restore task, which is
+ relatively quick and cannot be resumed (i.e. if you navigate away, you
+ have to upload again).
+
+ The task_name passed in will be lowercase.
+ """
+ # We currently have to pattern match on the name to differentiate
+ # between tasks. A better long term solution would be to add a separate
+ # task type identifier field to Django User Tasks.
+ return (
+ is_library_content_update(task_name) or
+ is_library_backup_task(task_name) or
+ is_library_restore_task(task_name)
+ )
+
status = kwargs['status']
# Only send email when the entire task is complete, should only send when
@@ -72,7 +95,7 @@ def get_olx_validation_from_artifact():
task_name = status.name.lower()
# Also suppress emails on library content XBlock updates (too much like spam)
- if is_library_content_update(task_name):
+ if should_skip_end_of_task_email(task_name):
LOGGER.info(f"Suppressing end-of-task email on task {task_name}")
return
diff --git a/cms/djangoapps/contentstore/admin.py b/cms/djangoapps/contentstore/admin.py
index 67bb39b7a32a..ac11ea42d0a4 100644
--- a/cms/djangoapps/contentstore/admin.py
+++ b/cms/djangoapps/contentstore/admin.py
@@ -100,6 +100,7 @@ class ComponentLinkAdmin(admin.ModelAdmin):
"upstream_context_key",
"downstream_usage_key",
"downstream_context_key",
+ "top_level_parent",
"version_synced",
"version_declined",
"created",
@@ -139,6 +140,7 @@ class ContainerLinkAdmin(admin.ModelAdmin):
"upstream_context_key",
"downstream_usage_key",
"downstream_context_key",
+ "top_level_parent",
"version_synced",
"version_declined",
"created",
diff --git a/cms/djangoapps/contentstore/api/tests/test_validation.py b/cms/djangoapps/contentstore/api/tests/test_validation.py
index 4e0a9bbce666..4928d31dc1f2 100644
--- a/cms/djangoapps/contentstore/api/tests/test_validation.py
+++ b/cms/djangoapps/contentstore/api/tests/test_validation.py
@@ -2,9 +2,11 @@
Tests for the course import API views
"""
-
+import factory
from datetime import datetime
+from django.conf import settings
+import ddt
from django.test.utils import override_settings
from django.urls import reverse
from rest_framework import status
@@ -12,10 +14,13 @@
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
+from common.djangoapps.course_modes.models import CourseMode
+from common.djangoapps.course_modes.tests.factories import CourseModeFactory
from common.djangoapps.student.tests.factories import StaffFactory
from common.djangoapps.student.tests.factories import UserFactory
+@ddt.ddt
@override_settings(PROCTORING_BACKENDS={'DEFAULT': 'proctortrack', 'proctortrack': {}})
class CourseValidationViewTest(SharedModuleStoreTestCase, APITestCase):
"""
@@ -82,39 +87,54 @@ def test_student_fails(self):
resp = self.client.get(self.get_url(self.course_key))
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
- def test_staff_succeeds(self):
- self.client.login(username=self.staff.username, password=self.password)
- resp = self.client.get(self.get_url(self.course_key), {'all': 'true'})
- self.assertEqual(resp.status_code, status.HTTP_200_OK)
- expected_data = {
- 'assignments': {
- 'total_number': 1,
- 'total_visible': 1,
- 'assignments_with_dates_before_start': [],
- 'assignments_with_dates_after_end': [],
- 'assignments_with_ora_dates_after_end': [],
- 'assignments_with_ora_dates_before_start': [],
- },
- 'dates': {
- 'has_start_date': True,
- 'has_end_date': False,
- },
- 'updates': {
- 'has_update': True,
- },
- 'certificates': {
- 'is_enabled': False,
- 'is_activated': False,
- 'has_certificate': False,
- },
- 'grades': {
- 'has_grading_policy': False,
- 'sum_of_weights': 1.0,
- },
- 'proctoring': {
- 'needs_proctoring_escalation_email': True,
- 'has_proctoring_escalation_email': True,
- },
- 'is_self_paced': True,
- }
- self.assertDictEqual(resp.data, expected_data)
+ @ddt.data(
+ (False, False),
+ (True, False),
+ (False, True),
+ (True, True),
+ )
+ @ddt.unpack
+ def test_staff_succeeds(self, certs_html_view, with_modes):
+ features = dict(settings.FEATURES, CERTIFICATES_HTML_VIEW=certs_html_view)
+ with override_settings(FEATURES=features):
+ if with_modes:
+ CourseModeFactory.create_batch(
+ 2,
+ course_id=self.course.id,
+ mode_slug=factory.Iterator([CourseMode.AUDIT, CourseMode.VERIFIED]),
+ )
+ self.client.login(username=self.staff.username, password=self.password)
+ resp = self.client.get(self.get_url(self.course_key), {'all': 'true'})
+ self.assertEqual(resp.status_code, status.HTTP_200_OK)
+ expected_data = {
+ 'assignments': {
+ 'total_number': 1,
+ 'total_visible': 1,
+ 'assignments_with_dates_before_start': [],
+ 'assignments_with_dates_after_end': [],
+ 'assignments_with_ora_dates_after_end': [],
+ 'assignments_with_ora_dates_before_start': [],
+ },
+ 'dates': {
+ 'has_start_date': True,
+ 'has_end_date': False,
+ },
+ 'updates': {
+ 'has_update': True,
+ },
+ 'certificates': {
+ 'is_enabled': with_modes,
+ 'is_activated': False,
+ 'has_certificate': False,
+ },
+ 'grades': {
+ 'has_grading_policy': False,
+ 'sum_of_weights': 1.0,
+ },
+ 'proctoring': {
+ 'needs_proctoring_escalation_email': True,
+ 'has_proctoring_escalation_email': True,
+ },
+ 'is_self_paced': True,
+ }
+ self.assertDictEqual(resp.data, expected_data)
diff --git a/cms/djangoapps/contentstore/asset_storage_handlers.py b/cms/djangoapps/contentstore/asset_storage_handlers.py
index 02857b11deac..2489be61bae3 100644
--- a/cms/djangoapps/contentstore/asset_storage_handlers.py
+++ b/cms/djangoapps/contentstore/asset_storage_handlers.py
@@ -19,7 +19,6 @@
from opaque_keys.edx.keys import AssetKey, CourseKey
from pymongo import ASCENDING, DESCENDING
-from common.djangoapps.edxmako.shortcuts import render_to_response
from common.djangoapps.student.auth import has_course_author_access
from common.djangoapps.util.date_utils import get_default_time_display
from common.djangoapps.util.json_request import JsonResponse
@@ -34,8 +33,7 @@
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
from .exceptions import AssetNotFoundException, AssetSizeTooLargeException
-from .utils import reverse_course_url, get_files_uploads_url, get_response_format, request_response_format_is_json
-from .toggles import use_new_files_uploads_page
+from .utils import get_files_uploads_url, get_response_format, request_response_format_is_json
REQUEST_DEFAULTS = {
@@ -169,22 +167,8 @@ def _get_asset_usage_path(course_key, assets):
def _asset_index(request, course_key):
'''
Display an editable asset library.
-
- Supports start (0-based index into the list of assets) and max query parameters.
'''
- course_block = modulestore().get_course(course_key)
-
- if use_new_files_uploads_page(course_key):
- return redirect(get_files_uploads_url(course_key))
-
- return render_to_response('asset_index.html', {
- 'language_code': request.LANGUAGE_CODE,
- 'context_course': course_block,
- 'max_file_size_in_mbs': settings.MAX_ASSET_UPLOAD_FILE_SIZE_IN_MB,
- 'chunk_size_in_mbs': settings.UPLOAD_CHUNK_SIZE_IN_MB,
- 'max_file_size_redirect_url': settings.MAX_ASSET_UPLOAD_FILE_SIZE_URL,
- 'asset_callback_url': reverse_course_url('assets_handler', course_key)
- })
+ return redirect(get_files_uploads_url(course_key))
def _assets_json(request, course_key):
diff --git a/cms/djangoapps/contentstore/audio_description_storage_handlers.py b/cms/djangoapps/contentstore/audio_description_storage_handlers.py
new file mode 100644
index 000000000000..ca5c5ffad938
--- /dev/null
+++ b/cms/djangoapps/contentstore/audio_description_storage_handlers.py
@@ -0,0 +1,121 @@
+"""
+Storage handlers for audio description (AD) files.
+
+Files are saved via edx-val's Django storage abstraction
+(FileSystemStorage locally, S3Boto3Storage in production).
+edx-val owns the file record and URL generation; this module
+handles validation, sanitisation, and delegates to edx-val's API.
+"""
+
+import logging
+import os
+import re
+
+from django.conf import settings
+from django.core.files.base import ContentFile
+
+try:
+ from edxval.api import (
+ create_or_update_video_audio_description,
+ delete_video_audio_description,
+ get_video_audio_description_url,
+ )
+except ImportError:
+ create_or_update_video_audio_description = None
+ delete_video_audio_description = None
+ get_video_audio_description_url = None
+
+log = logging.getLogger(__name__)
+
+
+class AudioDescriptionUploadError(Exception):
+ """Raised when an AD upload request is invalid or cannot be fulfilled."""
+
+
+_CONTENT_TYPE_TO_FORMAT = {
+ 'audio/mpeg': 'mp3',
+ 'audio/mp4': 'm4a',
+ 'audio/x-m4a': 'm4a',
+ 'audio/wav': 'wav',
+ 'audio/aac': 'aac',
+}
+
+ALLOWED_FORMATS = {'mp3', 'm4a', 'wav', 'aac'}
+
+
+def _sanitize_file_name(file_name):
+ """
+ Strip path components and any characters outside a safe subset.
+ """
+ base = os.path.basename(file_name or '')
+ if not base:
+ raise AudioDescriptionUploadError('file_name is required')
+
+ try:
+ base.encode('ascii')
+ except UnicodeEncodeError as exc:
+ raise AudioDescriptionUploadError(
+ f'The file name for {base} must contain only ASCII characters.'
+ ) from exc
+
+ return re.sub(r'[^A-Za-z0-9._-]', '_', base)
+
+
+def _resolve_format(content_type, file_name):
+ """
+ Pick the canonical file_format string for the given content type,
+ falling back to the file extension.
+ """
+ fmt = _CONTENT_TYPE_TO_FORMAT.get(content_type)
+ if fmt:
+ return fmt
+ ext = os.path.splitext(file_name or '')[1].lstrip('.').lower()
+ if ext in ALLOWED_FORMATS:
+ return ext
+ raise AudioDescriptionUploadError(
+ f'Unsupported audio description content type: {content_type}'
+ )
+
+
+def upload_audio_description(edx_video_id, file_name, content_type, file_data):
+ """
+ Validate and save an audio description file via edx-val.
+
+ Returns the storage URL for the saved file.
+ """
+ if not edx_video_id:
+ raise AudioDescriptionUploadError('edx_video_id is required')
+
+ safe_name = _sanitize_file_name(file_name)
+ file_format = _resolve_format(content_type, safe_name)
+
+ max_bytes = getattr(settings, 'VIDEO_AUDIO_DESCRIPTION_SETTINGS', {}).get(
+ 'VIDEO_AUDIO_DESCRIPTION_MAX_BYTES', 0
+ )
+ if max_bytes and hasattr(file_data, 'size') and file_data.size > max_bytes:
+ raise AudioDescriptionUploadError(
+ f'Audio description file exceeds maximum allowed size of {max_bytes} bytes'
+ )
+
+ content = file_data if isinstance(file_data, ContentFile) else ContentFile(file_data.read())
+
+ return create_or_update_video_audio_description(
+ video_id=edx_video_id,
+ metadata={'file_name': safe_name, 'file_format': file_format},
+ file_data=content,
+ )
+
+
+def delete_audio_description(edx_video_id):
+ """
+ Delete the AD record and file from storage.
+ Returns True if a record was deleted.
+ """
+ return delete_video_audio_description(edx_video_id)
+
+
+def get_audio_description_url(edx_video_id):
+ """
+ Return the download URL for the audio description, or None.
+ """
+ return get_video_audio_description_url(edx_video_id)
diff --git a/cms/djangoapps/contentstore/exams.py b/cms/djangoapps/contentstore/exams.py
index 6b25147c6abc..8a4ddc09425e 100644
--- a/cms/djangoapps/contentstore/exams.py
+++ b/cms/djangoapps/contentstore/exams.py
@@ -74,13 +74,13 @@ def register_exams(course_key):
# Exams in courses not using an LTI based proctoring provider should use the original definition of due_date
# from contentstore/proctoring.py. These exams are powered by the edx-proctoring plugin and not the edx-exams
# microservice.
+ is_instructor_paced = not course.self_paced
if course.proctoring_provider == 'lti_external':
- due_date = (
- timed_exam.due.isoformat() if timed_exam.due
- else (course.end.isoformat() if course.end else None)
- )
+ due_date_source = timed_exam.due if is_instructor_paced else course.end
else:
- due_date = timed_exam.due if not course.self_paced else None
+ due_date_source = timed_exam.due if is_instructor_paced else None
+
+ due_date = due_date_source.isoformat() if due_date_source else None
exams_list.append({
'course_id': str(course_key),
diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py
index 2bdbabc7df8c..c5890b5b818b 100644
--- a/cms/djangoapps/contentstore/helpers.py
+++ b/cms/djangoapps/contentstore/helpers.py
@@ -529,7 +529,7 @@ def _import_xml_node_to_parent(
node_copied_version = node.attrib.get('copied_from_version', None)
# Modulestore's IdGenerator here is SplitMongoIdManager which is assigned
- # by CachingDescriptorSystem Runtime and since we need our custom ImportIdGenerator
+ # by SplitModuleStoreRuntime and since we need our custom ImportIdGenerator
# here we are temporaraliy swtiching it.
original_id_generator = runtime.id_generator
@@ -566,7 +566,8 @@ def _import_xml_node_to_parent(
else:
# We have to handle the children ourselves, because there are lots of complex interactions between
# * the vanilla XBlock parse_xml() method, and its lack of API for "create and save a new XBlock"
- # * the XmlMixin version of parse_xml() which only works with ImportSystem, not modulestore or the v2 runtime
+ # * the XmlMixin version of parse_xml() which only works with XMLImportingModuleStoreRuntime,
+ # not modulestore or the v2 runtime
# * the modulestore APIs for creating and saving a new XBlock, which work but don't support XML parsing.
# We can safely assume that if the XBLock class supports children, every child node will be the XML
# serialization of a child block, in order. For blocks that don't support children, their XML content/nodes
@@ -745,10 +746,10 @@ def _import_file_into_course(
if thumbnail_content is not None:
content.thumbnail_location = thumbnail_location
contentstore().save(content)
- return True, {clipboard_file_path: f"static/{import_path}"}
+ return True, {clipboard_file_path: filename if not import_path else f"static/{import_path}"}
elif current_file.content_digest == file_data_obj.md5_hash:
- # The file already exists and matches exactly, so no action is needed except substitutions
- return None, {clipboard_file_path: f"static/{import_path}"}
+ # The file already exists and matches exactly, so no action is needed
+ return None, {}
else:
# There is a conflict with some other file that has the same name.
return False, {}
diff --git a/cms/djangoapps/contentstore/migrations/0014_remove_componentlink_downstream_is_modified_and_more.py b/cms/djangoapps/contentstore/migrations/0014_remove_componentlink_downstream_is_modified_and_more.py
new file mode 100644
index 000000000000..30949bcd272f
--- /dev/null
+++ b/cms/djangoapps/contentstore/migrations/0014_remove_componentlink_downstream_is_modified_and_more.py
@@ -0,0 +1,43 @@
+# Generated by Django 5.2.7 on 2025-10-27 14:07
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contentstore', '0013_componentlink_downstream_is_modified_and_more'),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name='componentlink',
+ name='downstream_is_modified',
+ ),
+ migrations.RemoveField(
+ model_name='containerlink',
+ name='downstream_is_modified',
+ ),
+ migrations.AddField(
+ model_name='componentlink',
+ name='downstream_customized',
+ field=models.JSONField(
+ default=list,
+ help_text=(
+ 'Names of the fields which have values set on the upstream block yet have been explicitly'
+ ' overridden on this downstream block'
+ ),
+ ),
+ ),
+ migrations.AddField(
+ model_name='containerlink',
+ name='downstream_customized',
+ field=models.JSONField(
+ default=list,
+ help_text=(
+ 'Names of the fields which have values set on the upstream block yet have been explicitly'
+ ' overridden on this downstream block'
+ ),
+ ),
+ ),
+ ]
diff --git a/cms/djangoapps/contentstore/models.py b/cms/djangoapps/contentstore/models.py
index 47a450cde867..a4f2ce3c6119 100644
--- a/cms/djangoapps/contentstore/models.py
+++ b/cms/djangoapps/contentstore/models.py
@@ -7,12 +7,12 @@
from config_models.models import ConfigurationModel
from django.db import models
-from django.db.models import QuerySet, OuterRef, Case, When, Exists, Value, ExpressionWrapper
-from django.db.models.fields import IntegerField, TextField, BooleanField
+from django.db.models import Case, Exists, ExpressionWrapper, OuterRef, Q, QuerySet, Value, When
+from django.db.models.fields import BooleanField, IntegerField, TextField
from django.db.models.functions import Coalesce
from django.db.models.lookups import GreaterThan
from django.utils.translation import gettext_lazy as _
-from opaque_keys.edx.django.models import CourseKeyField, ContainerKeyField, UsageKeyField
+from opaque_keys.edx.django.models import ContainerKeyField, CourseKeyField, UsageKeyField
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.locator import LibraryContainerLocator
from openedx_learning.api.authoring import get_published_version
@@ -23,7 +23,6 @@
manual_date_time_field,
)
-
logger = logging.getLogger(__name__)
@@ -108,7 +107,13 @@ class EntityLinkBase(models.Model):
top_level_parent = models.ForeignKey("ContainerLink", on_delete=models.SET_NULL, null=True, blank=True)
version_synced = models.IntegerField()
version_declined = models.IntegerField(null=True, blank=True)
- downstream_is_modified = models.BooleanField(default=False)
+ downstream_customized = models.JSONField(
+ default=list,
+ help_text=(
+ 'Names of the fields which have values set on the upstream block yet have been explicitly'
+ ' overridden on this downstream block'
+ ),
+ )
created = manual_date_time_field()
updated = manual_date_time_field()
@@ -258,7 +263,7 @@ def update_or_create(
version_synced: int,
top_level_parent_usage_key: UsageKey | None = None,
version_declined: int | None = None,
- downstream_is_modified: bool = False,
+ downstream_customized: list[str] | None = None,
created: datetime | None = None,
) -> "ComponentLink":
"""
@@ -283,7 +288,7 @@ def update_or_create(
'version_synced': version_synced,
'version_declined': version_declined,
'top_level_parent': top_level_parent,
- 'downstream_is_modified': downstream_is_modified,
+ 'downstream_customized': downstream_customized,
}
if upstream_block:
new_values['upstream_block'] = upstream_block
@@ -385,7 +390,7 @@ def filter_links(
cls.objects.filter(**link_filter).select_related(*RELATED_FIELDS),
)
if ready_to_sync is not None:
- result = result.filter(ready_to_sync=ready_to_sync)
+ result = result.filter(Q(ready_to_sync=ready_to_sync) | Q(ready_to_sync_from_children=ready_to_sync))
# Handle top-level parents logic
if use_top_level_parents:
@@ -430,6 +435,11 @@ def _annotate_query_with_ready_to_sync(cls, query_set: QuerySet["EntityLinkBase"
),
then=1
),
+ # If upstream block was deleted, set ready_to_sync = True
+ When(
+ Q(upstream_container__publishable_entity__published__version__version_num__isnull=True),
+ then=1
+ ),
default=0,
output_field=models.IntegerField()
)
@@ -451,6 +461,11 @@ def _annotate_query_with_ready_to_sync(cls, query_set: QuerySet["EntityLinkBase"
),
then=1
),
+ # If upstream block was deleted, set ready_to_sync = True
+ When(
+ Q(upstream_block__publishable_entity__published__version__version_num__isnull=True),
+ then=1
+ ),
default=0,
output_field=models.IntegerField()
)
@@ -485,7 +500,7 @@ def update_or_create(
version_synced: int,
top_level_parent_usage_key: UsageKey | None = None,
version_declined: int | None = None,
- downstream_is_modified: bool = False,
+ downstream_customized: list[str] | None = None,
created: datetime | None = None,
) -> "ContainerLink":
"""
@@ -510,7 +525,7 @@ def update_or_create(
'version_synced': version_synced,
'version_declined': version_declined,
'top_level_parent': top_level_parent,
- 'downstream_is_modified': downstream_is_modified,
+ 'downstream_customized': downstream_customized,
}
if upstream_container_id:
new_values['upstream_container_id'] = upstream_container_id
diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py
index 3efb7b6226d4..fa1ed97fa9b7 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/course_waffle_flags.py
@@ -11,6 +11,7 @@ class CourseWaffleFlagsSerializer(serializers.Serializer):
"""
Serializer for course waffle flags
"""
+
use_new_home_page = serializers.SerializerMethodField()
use_new_custom_pages = serializers.SerializerMethodField()
use_new_schedule_details_page = serializers.SerializerMethodField()
@@ -31,6 +32,9 @@ class CourseWaffleFlagsSerializer(serializers.Serializer):
use_react_markdown_editor = serializers.SerializerMethodField()
use_video_gallery_flow = serializers.SerializerMethodField()
enable_course_optimizer_check_prev_run_links = serializers.SerializerMethodField()
+ enable_unit_expanded_view = serializers.SerializerMethodField()
+ enable_outline_component_creation = serializers.SerializerMethodField()
+ enable_audio_description = serializers.SerializerMethodField()
def get_course_key(self):
"""
@@ -40,9 +44,15 @@ def get_course_key(self):
def get_use_new_home_page(self, obj):
"""
- Method to get the use_new_home_page switch
+ Method to indicate whether we should use the new home page.
+
+ This used to be based on a waffle flag but the flag is being removed so we
+ default it to true for now until we can remove the need for it from the consumers
+ of this serializer and the related APIs.
+
+ See https://github.com/openedx/edx-platform/issues/37497
"""
- return toggles.use_new_home_page()
+ return True
def get_use_new_custom_pages(self, obj):
"""
@@ -96,9 +106,11 @@ def get_use_new_export_page(self, obj):
def get_use_new_files_uploads_page(self, obj):
"""
Method to get the use_new_files_uploads_page switch
+
+ Always true, because the switch is being removed an the new experience
+ should alawys be on.
"""
- course_key = self.get_course_key()
- return toggles.use_new_files_uploads_page(course_key)
+ return True
def get_use_new_video_uploads_page(self, obj):
"""
@@ -110,9 +122,12 @@ def get_use_new_video_uploads_page(self, obj):
def get_use_new_course_outline_page(self, obj):
"""
Method to get the use_new_course_outline_page switch
+
+ Always true, because the switch is being removed and the new experience
+ should always be on. This function will be removed in
+ https://github.com/openedx/edx-platform/issues/37497
"""
- course_key = self.get_course_key()
- return toggles.use_new_course_outline_page(course_key)
+ return True
def get_use_new_unit_page(self, obj):
"""
@@ -175,3 +190,24 @@ def get_enable_course_optimizer_check_prev_run_links(self, obj):
"""
course_key = self.get_course_key()
return toggles.enable_course_optimizer_check_prev_run_links(course_key)
+
+ def get_enable_unit_expanded_view(self, obj):
+ """
+ Method to get the enable_unit_expanded_view waffle flag
+ """
+ course_key = self.get_course_key()
+ return toggles.enable_unit_expanded_view(course_key)
+
+ def get_enable_outline_component_creation(self, obj):
+ """
+ Method to get the enable_outline_component_creation waffle flag
+ """
+ course_key = self.get_course_key()
+ return toggles.enable_outline_component_creation(course_key)
+
+ def get_enable_audio_description(self, obj):
+ """
+ Method to get the enable_audio_description waffle flag.
+ """
+ course_key = self.get_course_key()
+ return toggles.audio_description_enabled(course_key)
diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py
index bbc45ddf9a37..8ee8cb035478 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py
@@ -28,26 +28,11 @@ class LibraryViewSerializer(serializers.Serializer):
org = serializers.CharField()
number = serializers.CharField()
can_edit = serializers.BooleanField()
- is_migrated = serializers.SerializerMethodField()
- migrated_to_title = serializers.CharField(
- source="migrations__target__title",
- required=False
- )
- migrated_to_key = serializers.CharField(
- source="migrations__target__key",
- required=False
- )
- migrated_to_collection_key = serializers.CharField(
- source="migrations__target_collection__key",
- required=False
- )
- migrated_to_collection_title = serializers.CharField(
- source="migrations__target_collection__title",
- required=False
- )
-
- def get_is_migrated(self, obj):
- return "migrations__target__key" in obj
+ is_migrated = serializers.BooleanField()
+ migrated_to_title = serializers.CharField(required=False)
+ migrated_to_key = serializers.CharField(required=False)
+ migrated_to_collection_key = serializers.CharField(required=False)
+ migrated_to_collection_title = serializers.CharField(required=False)
class CourseHomeTabSerializer(serializers.Serializer):
diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py
index 2283036faf24..eb4f333e170a 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py
@@ -124,7 +124,7 @@ class UpstreamLinkSerializer(serializers.Serializer):
version_declined = serializers.IntegerField(allow_null=True)
error_message = serializers.CharField(allow_null=True)
ready_to_sync = serializers.BooleanField()
- is_modified = serializers.BooleanField()
+ downstream_customized = serializers.ListField(child=serializers.CharField(), allow_empty=True)
has_top_level_parent = serializers.BooleanField()
ready_to_sync_children = UpstreamChildrenInfoSerializer(many=True, required=False)
@@ -177,7 +177,22 @@ class ContainerChildrenSerializer(serializers.Serializer):
Serializer for representing a vertical container with state and children.
"""
+ class UpstreamReadyToSyncChildrenInfoSerializer(serializers.Serializer):
+ """
+ Serializer used for the `upstream_ready_to_sync_children_info` field
+ """
+ id = serializers.CharField()
+ name = serializers.CharField()
+ upstream = serializers.CharField()
+ block_type = serializers.CharField()
+ downstream_customized = serializers.ListField(child=serializers.CharField(), allow_empty=True)
+
children = ContainerChildSerializer(many=True)
is_published = serializers.BooleanField()
can_paste_component = serializers.BooleanField()
display_name = serializers.CharField()
+ upstream_ready_to_sync_children_info = UpstreamReadyToSyncChildrenInfoSerializer(
+ many=True,
+ required=False,
+ help_text="List of dictionaries describing upstream child components readiness to sync."
+ )
diff --git a/cms/djangoapps/contentstore/rest_api/v1/urls.py b/cms/djangoapps/contentstore/rest_api/v1/urls.py
index 685a81d778ce..8a94f0b0e040 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/urls.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/urls.py
@@ -25,6 +25,7 @@
HomePageView,
ProctoredExamSettingsView,
ProctoringErrorsView,
+ UnitComponentsView,
VideoDownloadView,
VideoUsageView,
vertical_container_children_redirect_view,
@@ -144,6 +145,11 @@
CourseWaffleFlagsView.as_view(),
name="course_waffle_flags"
),
+ re_path(
+ fr'^unit_handler/{settings.USAGE_KEY_PATTERN}$',
+ UnitComponentsView.as_view(),
+ name="unit_components"
+ ),
# Authoring API
# Do not use under v1 yet (Nov. 23). The Authoring API is still experimental and the v0 versions should be used
diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py
index d4fcfd5f2e3f..25c0157904e9 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/views/__init__.py
@@ -14,9 +14,6 @@
from .proctoring import ProctoredExamSettingsView, ProctoringErrorsView
from .settings import CourseSettingsView
from .textbooks import CourseTextbooksView
+from .unit_handler import UnitComponentsView
from .vertical_block import ContainerHandlerView, vertical_container_children_redirect_view
-from .videos import (
- CourseVideosView,
- VideoDownloadView,
- VideoUsageView,
-)
+from .videos import CourseVideosView, VideoDownloadView, VideoUsageView
diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py
index 7ab14028cd50..42b5b1e9d78d 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_index.py
@@ -8,6 +8,7 @@
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework.views import APIView
+from rest_framework.fields import BooleanField
from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES
from cms.djangoapps.contentstore.rest_api.v1.mixins import ContainerHandlerMixin
@@ -129,6 +130,11 @@ class ContainerChildrenView(APIView, ContainerHandlerMixin):
apidocs.ParameterLocation.PATH,
description="Container usage key",
),
+ apidocs.string_parameter(
+ "get_upstream_info",
+ apidocs.ParameterLocation.QUERY,
+ description="Gets the info of all ready to sync children",
+ ),
],
responses={
200: ContainerChildrenSerializer,
@@ -210,6 +216,7 @@ def get(self, request: Request, usage_key_string: str):
"version_available": 49,
"error_message": null,
"ready_to_sync": true,
+ "is_ready_to_sync_individually": true,
},
"actions": {
"can_copy": true,
@@ -231,11 +238,19 @@ def get(self, request: Request, usage_key_string: str):
"is_published": false,
"can_paste_component": true,
"display_name": "Vertical block 1"
+ "upstream_ready_to_sync_children_info": [{
+ "name": "Text",
+ "upstream": "lb:org:mylib:html:abcd",
+ 'block_type': "html",
+ 'downstream_customized': ["display_name"],
+ 'id': "block-v1:org+101+101+type@html+block@3e3fa1f88adb4a108cd14e9002143690",
+ }]
}
```
"""
usage_key = self.get_object(usage_key_string)
current_xblock = get_xblock(usage_key, request.user)
+ get_upstream_info = BooleanField().to_internal_value(request.GET.get("get_upstream_info", False))
is_course = current_xblock.scope_ids.usage_id.context_key.is_course
with modulestore().bulk_operations(usage_key.course_key):
@@ -274,10 +289,17 @@ def get(self, request: Request, usage_key_string: str):
except ItemNotFoundError:
logging.error('Could not find any changes for block [%s]', usage_key)
+ upstream_ready_to_sync_children_info = []
+ if current_xblock.upstream and get_upstream_info:
+ upstream_link = UpstreamLink.get_for_block(current_xblock)
+ upstream_link_data = upstream_link.to_json(include_child_info=True)
+ upstream_ready_to_sync_children_info = upstream_link_data["ready_to_sync_children"]
+
container_data = {
"children": children,
"is_published": is_published,
"can_paste_component": is_course,
+ "upstream_ready_to_sync_children_info": upstream_ready_to_sync_children_info,
"display_name": current_xblock.display_name_with_default,
}
serializer = ContainerChildrenSerializer(container_data)
diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/course_waffle_flags.py b/cms/djangoapps/contentstore/rest_api/v1/views/course_waffle_flags.py
index 69b2898912aa..76372788e209 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/views/course_waffle_flags.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/views/course_waffle_flags.py
@@ -63,7 +63,8 @@ def get(self, request, course_id=None):
"use_new_textbooks_page": true,
"use_new_group_configurations_page": true,
"use_react_markdown_editor": true,
- "use_video_gallery_flow": true
+ "use_video_gallery_flow": true,
+ "enable_audio_description": false
}
```
"""
diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/home.py b/cms/djangoapps/contentstore/rest_api/v1/views/home.py
index a4e93de9caff..95723020c11f 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/views/home.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/views/home.py
@@ -236,7 +236,7 @@ def get(self, request: Request):
"number": "CPSPR",
"can_edit": true
}
- ], }
+ ],
```
"""
diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py
index f45cc48810d6..cd705eb700c2 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_waffle_flags.py
@@ -3,6 +3,7 @@
"""
from django.urls import reverse
+from edx_toggles.toggles.testutils import override_waffle_flag
from cms.djangoapps.contentstore import toggles
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
@@ -38,6 +39,9 @@ class CourseWaffleFlagsViewTest(CourseTestCase):
"use_react_markdown_editor": False,
"use_video_gallery_flow": False,
"enable_course_optimizer_check_prev_run_links": False,
+ "enable_unit_expanded_view": False,
+ "enable_outline_component_creation": False,
+ "enable_audio_description": False,
}
def setUp(self):
@@ -69,3 +73,14 @@ def test_course_override(self):
"enable_course_optimizer": True,
"enable_course_optimizer_check_prev_run_links": True,
}
+
+ @override_waffle_flag(toggles.ENABLE_AUDIO_DESCRIPTION, active=True)
+ def test_audio_description_upload_flag_enabled(self):
+ """
+ When the global AD upload flag is on, the serializer should
+ report it as True regardless of which course (or no course) is
+ in the request.
+ """
+ url = reverse("cms.djangoapps.contentstore:v1:course_waffle_flags")
+ response = self.client.get(url)
+ assert response.data["enable_audio_description"] is True
diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py
index cd7592c46629..72d58fa00dfa 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py
@@ -18,7 +18,6 @@
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from cms.djangoapps.modulestore_migrator import api as migrator_api
from cms.djangoapps.modulestore_migrator.data import CompositionLevel, RepeatHandlingStrategy
-from cms.djangoapps.modulestore_migrator.tests.factories import ModulestoreSourceFactory
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangoapps.content_libraries import api as lib_api
@@ -253,8 +252,9 @@ class HomePageLibrariesViewTest(LibraryTestCase):
def setUp(self):
super().setUp()
- # Create an additional legacy library
+ # Create an two additional legacy libaries
self.lib_key_1 = self._create_library(library="lib1")
+ self.lib_key_2 = self._create_library(library="lib2")
self.organization = OrganizationFactory()
# Create a new v2 library
@@ -269,7 +269,6 @@ def setUp(self):
library = lib_api.ContentLibrary.objects.get(slug=self.lib_key_v2.slug)
learning_package = library.learning_package
# Create a migration source for the legacy library
- self.source = ModulestoreSourceFactory(key=self.lib_key_1)
self.url = reverse("cms.djangoapps.contentstore:v1:libraries")
# Create a collection to migrate this library to
collection_key = "test-collection"
@@ -280,20 +279,32 @@ def setUp(self):
created_by=self.user.id,
)
- # Migrate self.lib_key_1 to self.lib_key_v2
+ # Migrate both lib_key_1 and lib_key_2 to v2
+ # Only make lib_key_1 a "forwarding" migration.
migrator_api.start_migration_to_library(
user=self.user,
- source_key=self.source.key,
+ source_key=self.lib_key_1,
target_library_key=self.lib_key_v2,
target_collection_slug=collection_key,
- composition_level=CompositionLevel.Component.value,
- repeat_handling_strategy=RepeatHandlingStrategy.Skip.value,
+ composition_level=CompositionLevel.Component,
+ repeat_handling_strategy=RepeatHandlingStrategy.Skip,
+ preserve_url_slugs=True,
+ forward_source_to_target=True,
+ )
+ migrator_api.start_migration_to_library(
+ user=self.user,
+ source_key=self.lib_key_2,
+ target_library_key=self.lib_key_v2,
+ target_collection_slug=collection_key,
+ composition_level=CompositionLevel.Component,
+ repeat_handling_strategy=RepeatHandlingStrategy.Skip,
preserve_url_slugs=True,
forward_source_to_target=False,
)
def test_home_page_libraries_response(self):
- """Check successful response content"""
+ """Check sucessful response content"""
+ self.maxDiff = None
response = self.client.get(self.url)
expected_response = {
@@ -322,6 +333,17 @@ def test_home_page_libraries_response(self):
'migrated_to_collection_key': 'test-collection',
'migrated_to_collection_title': 'Test Collection',
},
+ # Third library was migrated, but not with forwarding.
+ # So, it appears just like the unmigrated library.
+ {
+ 'display_name': 'Test Library',
+ 'library_key': 'library-v1:org+lib2',
+ 'url': '/library/library-v1:org+lib2',
+ 'org': 'org',
+ 'number': 'lib2',
+ 'can_edit': True,
+ 'is_migrated': False,
+ },
]
}
@@ -366,6 +388,15 @@ def test_home_page_libraries_response(self):
'can_edit': True,
'is_migrated': False,
},
+ {
+ 'display_name': 'Test Library',
+ 'library_key': 'library-v1:org+lib2',
+ 'url': '/library/library-v1:org+lib2',
+ 'org': 'org',
+ 'number': 'lib2',
+ 'can_edit': True,
+ 'is_migrated': False,
+ },
],
}
diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py
index 5dea51b91d71..a7cf3a452627 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py
@@ -11,6 +11,7 @@
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from openedx.core.djangoapps.content_tagging.toggles import DISABLE_TAGGING_FEATURE
+from openedx.core.djangoapps.content_libraries.tests import ContentLibrariesRestApiTest
from xmodule.partitions.partitions import (
ENROLLMENT_TRACK_PARTITION_ID,
Group,
@@ -27,7 +28,7 @@
) # lint-amnesty, pylint: disable=wrong-import-order
-class BaseXBlockContainer(CourseTestCase):
+class BaseXBlockContainer(CourseTestCase, ContentLibrariesRestApiTest):
"""
Base xBlock container handler.
@@ -48,6 +49,20 @@ def setup_xblock(self):
This method creates XBlock objects representing a course structure with chapters,
sequentials, verticals and others.
"""
+ self.lib = self._create_library(
+ slug="containers",
+ title="Container Test Library",
+ description="Units and more",
+ )
+ self.unit = self._create_container(self.lib["id"], "unit", display_name="Unit", slug=None)
+ self.html_block = self._add_block_to_library(self.lib["id"], "html", "Html1", can_stand_alone=False)
+ self._set_library_block_olx(
+ self.html_block["id"],
+ 'updated content upstream 1'
+ )
+ # Set version of html to 2
+ self._publish_library_block(self.html_block["id"])
+
self.chapter = self.create_block(
parent=self.course.location,
category="chapter",
@@ -60,7 +75,13 @@ def setup_xblock(self):
display_name="Lesson 1",
)
- self.vertical = self.create_block(self.sequential.location, "vertical", "Unit")
+ self.vertical = self.create_block(
+ self.sequential.location,
+ "vertical",
+ "Unit",
+ upstream=self.unit["id"],
+ upstream_version=1,
+ )
self.html_unit_first = self.create_block(
parent=self.vertical.location,
@@ -72,8 +93,8 @@ def setup_xblock(self):
parent=self.vertical.location,
category="html",
display_name="Html Content 2",
- upstream="lb:FakeOrg:FakeLib:html:FakeBlock",
- upstream_version=5,
+ upstream=self.html_block["id"],
+ upstream_version=1,
)
def create_block(self, parent, category, display_name, **kwargs):
@@ -209,6 +230,27 @@ def test_success_response(self):
self.assertFalse(data["is_published"])
self.assertTrue(data["can_paste_component"])
self.assertEqual(data["display_name"], "Unit")
+ self.assertEqual(data["upstream_ready_to_sync_children_info"], [])
+
+ def test_success_response_with_upstream_info(self):
+ """
+ Check that endpoint returns valid response data using `get_upstream_info` query param
+ """
+ url = self.get_reverse_url(self.vertical.location)
+ response = self.client.get(f"{url}?get_upstream_info=true")
+ self.assertEqual(response.status_code, status.HTTP_200_OK)
+ data = response.json()
+ self.assertEqual(len(data["children"]), 2)
+ self.assertFalse(data["is_published"])
+ self.assertTrue(data["can_paste_component"])
+ self.assertEqual(data["display_name"], "Unit")
+ self.assertEqual(data["upstream_ready_to_sync_children_info"], [{
+ "id": str(self.html_unit_second.usage_key),
+ "upstream": self.html_block["id"],
+ "block_type": "html",
+ "downstream_customized": [],
+ "name": "Html Content 2",
+ }])
def test_xblock_is_published(self):
"""
@@ -275,14 +317,14 @@ def test_children_content(self):
"can_manage_tags": True,
},
"upstream_link": {
- "upstream_ref": "lb:FakeOrg:FakeLib:html:FakeBlock",
- "version_synced": 5,
- "version_available": None,
+ "upstream_ref": self.html_block["id"],
+ "version_synced": 1,
+ "version_available": 2,
"version_declined": None,
- "error_message": "Linked upstream library block was not found in the system",
- "ready_to_sync": False,
+ "error_message": None,
+ "ready_to_sync": True,
"has_top_level_parent": False,
- "is_modified": False,
+ "downstream_customized": [],
},
"user_partition_info": expected_user_partition_info,
"user_partitions": expected_user_partitions,
diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/unit_handler.py b/cms/djangoapps/contentstore/rest_api/v1/views/unit_handler.py
new file mode 100644
index 000000000000..0740152e4fab
--- /dev/null
+++ b/cms/djangoapps/contentstore/rest_api/v1/views/unit_handler.py
@@ -0,0 +1,129 @@
+"""API Views for unit components handler"""
+
+import logging
+
+import edx_api_doc_tools as apidocs
+from django.http import HttpResponseBadRequest
+from opaque_keys.edx.keys import UsageKey
+from rest_framework.request import Request
+from rest_framework.response import Response
+from rest_framework.views import APIView
+
+from cms.djangoapps.contentstore.rest_api.v1.mixins import ContainerHandlerMixin
+from openedx.core.lib.api.view_utils import view_auth_classes
+from xmodule.modulestore.django import modulestore
+from xmodule.modulestore.exceptions import ItemNotFoundError
+
+log = logging.getLogger(__name__)
+
+
+@view_auth_classes(is_authenticated=True)
+class UnitComponentsView(APIView, ContainerHandlerMixin):
+ """
+ View to get all components in a unit by usage key.
+ """
+
+ @apidocs.schema(
+ parameters=[
+ apidocs.string_parameter(
+ "usage_key_string",
+ apidocs.ParameterLocation.PATH,
+ description="Unit usage key",
+ ),
+ ],
+ responses={
+ 200: "List of components in the unit",
+ 400: "Invalid usage key or unit not found.",
+ 401: "The requester is not authenticated.",
+ 404: "The requested unit does not exist.",
+ },
+ )
+ def get(self, request: Request, usage_key_string: str):
+ """
+ Get all components in a unit.
+
+ **Example Request**
+
+ GET /api/contentstore/v1/unit_handler/{usage_key_string}
+
+ **Response Values**
+
+ If the request is successful, an HTTP 200 "OK" response is returned.
+
+ The HTTP 200 response contains a dict with a list of all components
+ in the unit, including their display names, block types, and block IDs.
+
+ **Example Response**
+
+ ```json
+ {
+ "unit_id": "block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_id",
+ "display_name": "My Unit",
+ "components": [
+ {
+ "block_id": "block-v1:edX+DemoX+Demo_Course+type@video+block@video_id",
+ "block_type": "video",
+ "display_name": "Introduction Video"
+ },
+ {
+ "block_id": "block-v1:edX+DemoX+Demo_Course+type@html+block@html_id",
+ "block_type": "html",
+ "display_name": "Text Content"
+ },
+ {
+ "block_id": "block-v1:edX+DemoX+Demo_Course+type@problem+block@problem_id",
+ "block_type": "problem",
+ "display_name": "Practice Problem"
+ }
+ ]
+ }
+ ```
+ """
+ try:
+ usage_key = UsageKey.from_string(usage_key_string)
+ except Exception as e: # pylint: disable=broad-exception-caught
+ log.error(f"Invalid usage key: {usage_key_string}, error: {str(e)}")
+ return HttpResponseBadRequest("Invalid usage key format")
+
+ try:
+ # Get the unit xblock
+ unit_xblock = modulestore().get_item(usage_key)
+
+ # Verify it's a vertical (unit)
+ if unit_xblock.category != "vertical":
+ return HttpResponseBadRequest(
+ "The provided usage key is not a unit (vertical)"
+ )
+
+ components = []
+
+ # Get all children (components) of the unit
+ if unit_xblock.has_children:
+ for child_usage_key in unit_xblock.children:
+ try:
+ child_xblock = modulestore().get_item(child_usage_key)
+ components.append(
+ {
+ "block_id": str(child_xblock.location),
+ "block_type": child_xblock.category,
+ "display_name": child_xblock.display_name_with_default,
+ }
+ )
+ except ItemNotFoundError:
+ log.warning(f"Child block not found: {child_usage_key}")
+ continue
+
+ response_data = {
+ "unit_id": str(usage_key),
+ "display_name": unit_xblock.display_name_with_default,
+ "components": components,
+ }
+
+ return Response(response_data)
+
+ except ItemNotFoundError:
+ log.error(f"Unit not found: {usage_key_string}")
+ return HttpResponseBadRequest("Unit not found")
+ except Exception as e: # pylint: disable=broad-exception-caught
+ log.error(f"Error retrieving unit components: {str(e)}")
+ return HttpResponseBadRequest(f"Error retrieving unit components: {str(e)}")
diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py
index e5cd063e8181..9733f9878210 100644
--- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py
+++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstream_sync_integration.py
@@ -11,15 +11,14 @@
from freezegun import freeze_time
from openedx.core.djangoapps.content_libraries.tests import ContentLibrariesRestApiTest
-from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_block_key_dict
+from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_block_key_string
from xmodule.modulestore.django import modulestore
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, ImmediateOnCommitMixin
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
-from xmodule.xml_block import serialize_field
@ddt.ddt
-class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ModuleStoreTestCase):
+class CourseToLibraryTestCase(ContentLibrariesRestApiTest, ImmediateOnCommitMixin, ModuleStoreTestCase):
"""
Tests that involve syncing content from libraries to courses.
"""
@@ -197,7 +196,7 @@ def test_problem_sync(self):
'version_declined': None,
'ready_to_sync': False,
'error_message': None,
- 'is_modified': False,
+ 'downstream_customized': [],
# 'upstream_link': 'http://course-authoring-mfe/library/lib:CL-TEST:testlib/components?usageKey=...'
})
assert status["upstream_link"].startswith("http://course-authoring-mfe/library/")
@@ -254,7 +253,7 @@ def test_problem_sync(self):
'version_declined': None,
'ready_to_sync': True, # <--- updated
'error_message': None,
- 'is_modified': True,
+ 'downstream_customized': ['display_name'],
})
# 3️⃣ Now, sync and check the resulting OLX of the downstream
@@ -296,9 +295,9 @@ def test_unit_sync(self):
parent_usage_key=str(self.course_subsection.usage_key),
upstream_key=self.upstream_unit["id"],
)
- downstream_unit_block_key = serialize_field(get_block_key_dict(
+ downstream_unit_block_key = get_block_key_string(
UsageKey.from_string(downstream_unit["locator"]),
- )).replace('"', '"')
+ )
status = self._get_sync_status(downstream_unit["locator"])
self.assertDictContainsEntries(status, {
'upstream_ref': self.upstream_unit["id"], # e.g. 'lct:CL-TEST:testlib:unit:u1'
@@ -307,7 +306,7 @@ def test_unit_sync(self):
'version_declined': None,
'ready_to_sync': False,
'error_message': None,
- 'is_modified': False,
+ 'downstream_customized': [],
# 'upstream_link': 'http://course-authoring-mfe/library/lib:CL-TEST:testlib/units/...'
})
assert status["upstream_link"].startswith("http://course-authoring-mfe/library/")
@@ -384,7 +383,7 @@ def test_unit_sync(self):
'updated': date_format,
'upstream_key': self.upstream_html1["id"],
'upstream_type': 'component',
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'id': 2,
@@ -402,7 +401,7 @@ def test_unit_sync(self):
'updated': date_format,
'upstream_key': self.upstream_problem1["id"],
'upstream_type': 'component',
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'id': 3,
@@ -420,7 +419,7 @@ def test_unit_sync(self):
'updated': date_format,
'upstream_key': self.upstream_problem2["id"],
'upstream_type': 'component',
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'id': 1,
@@ -438,7 +437,7 @@ def test_unit_sync(self):
'updated': date_format,
'upstream_key': self.upstream_unit["id"],
'upstream_type': 'container',
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
}
]
data = downstreams.json()
@@ -460,7 +459,7 @@ def test_unit_sync(self):
'version_declined': None,
'ready_to_sync': True, # <--- It's the top-level parent of the block
'error_message': None,
- 'is_modified': False,
+ 'downstream_customized': [],
})
# Check the upstream/downstream status of [one of] the children
@@ -472,7 +471,7 @@ def test_unit_sync(self):
'version_declined': None,
'ready_to_sync': False, # <-- It has top-level parent, the parent is the one who must synchronize
'error_message': None,
- 'is_modified': False,
+ 'downstream_customized': [],
})
# Sync and check the resulting OLX of the downstream
@@ -537,7 +536,7 @@ def test_unit_sync(self):
'updated': date_format,
'upstream_key': self.upstream_html1["id"],
'upstream_type': 'component',
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'id': 2,
@@ -555,7 +554,7 @@ def test_unit_sync(self):
'updated': date_format,
'upstream_key': self.upstream_problem1["id"],
'upstream_type': 'component',
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'id': 3,
@@ -573,7 +572,7 @@ def test_unit_sync(self):
'updated': date_format,
'upstream_key': self.upstream_problem2["id"],
'upstream_type': 'component',
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'id': 1,
@@ -591,7 +590,7 @@ def test_unit_sync(self):
'updated': date_format,
'upstream_key': self.upstream_unit["id"],
'upstream_type': 'container',
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
}
]
data = downstreams.json()
@@ -621,7 +620,7 @@ def test_unit_sync(self):
'version_declined': None,
'ready_to_sync': True,
'error_message': None,
- 'is_modified': False,
+ 'downstream_customized': [],
})
# Sync and check the resulting OLX of the downstream
@@ -689,7 +688,7 @@ def test_unit_sync(self):
'updated': date_format,
'upstream_key': self.upstream_html1["id"],
'upstream_type': 'component',
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'id': 2,
@@ -707,7 +706,7 @@ def test_unit_sync(self):
'updated': date_format,
'upstream_key': self.upstream_problem1["id"],
'upstream_type': 'component',
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'id': 4,
@@ -725,7 +724,7 @@ def test_unit_sync(self):
'updated': date_format,
'upstream_key': upstream_problem3["id"],
'upstream_type': 'component',
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'id': 1,
@@ -743,7 +742,7 @@ def test_unit_sync(self):
'updated': date_format,
'upstream_key': self.upstream_unit["id"],
'upstream_type': 'container',
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
}
]
data = downstreams.json()
@@ -822,7 +821,7 @@ def test_unit_sync(self):
'updated': date_format,
'upstream_key': self.upstream_html1["id"],
'upstream_type': 'component',
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'id': 2,
@@ -840,7 +839,7 @@ def test_unit_sync(self):
'updated': date_format,
'upstream_key': self.upstream_problem1["id"],
'upstream_type': 'component',
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'id': 4,
@@ -858,7 +857,7 @@ def test_unit_sync(self):
'updated': date_format,
'upstream_key': upstream_problem3["id"],
'upstream_type': 'component',
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'id': 1,
@@ -876,7 +875,7 @@ def test_unit_sync(self):
'updated': date_format,
'upstream_key': self.upstream_unit["id"],
'upstream_type': 'container',
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
}
]
data = downstreams.json()
@@ -898,9 +897,9 @@ def test_unit_sync_with_modified_downstream(self):
parent_usage_key=str(self.course_subsection.usage_key),
upstream_key=self.upstream_unit["id"],
)
- downstream_unit_block_key = serialize_field(get_block_key_dict(
+ downstream_unit_block_key = get_block_key_string(
UsageKey.from_string(downstream_unit["locator"]),
- )).replace('"', '"')
+ )
status = self._get_sync_status(downstream_unit["locator"])
self.assertDictContainsEntries(status, {
'upstream_ref': self.upstream_unit["id"], # e.g. 'lct:CL-TEST:testlib:unit:u1'
@@ -909,7 +908,7 @@ def test_unit_sync_with_modified_downstream(self):
'version_declined': None,
'ready_to_sync': False,
'error_message': None,
- 'is_modified': False,
+ 'downstream_customized': [],
# 'upstream_link': 'http://course-authoring-mfe/library/lib:CL-TEST:testlib/units/...'
})
assert status["upstream_link"].startswith("http://course-authoring-mfe/library/")
@@ -984,7 +983,7 @@ def test_unit_sync_with_modified_downstream(self):
'version_declined': None,
'ready_to_sync': True, # <--- It's the top-level parent of the block
'error_message': None,
- 'is_modified': False,
+ 'downstream_customized': [],
})
# Check the upstream/downstream status of [one of] the children
@@ -996,7 +995,7 @@ def test_unit_sync_with_modified_downstream(self):
'version_declined': None,
'ready_to_sync': False, # <-- It has top-level parent, the parent is the one who must synchronize
'error_message': None,
- 'is_modified': False,
+ 'downstream_customized': [],
})
self.assertDictContainsEntries(self._get_sync_status(downstream_html1), {
@@ -1006,7 +1005,7 @@ def test_unit_sync_with_modified_downstream(self):
'version_declined': None,
'ready_to_sync': False, # <-- It has top-level parent, the parent is the one who must synchronize
'error_message': None,
- 'is_modified': False,
+ 'downstream_customized': [],
})
# Now let's modify course html block
@@ -1077,7 +1076,7 @@ def test_modified_html_copy_paste(self):
'version_declined': None,
'ready_to_sync': False,
'error_message': None,
- 'is_modified': False,
+ 'downstream_customized': [],
# 'upstream_link': 'http://course-authoring-mfe/library/lib:CL-TEST:testlib/components?usageKey=...'
})
assert status["upstream_link"].startswith("http://course-authoring-mfe/library/")
@@ -1117,7 +1116,7 @@ def test_modified_html_copy_paste(self):
'updated': date_format,
'upstream_key': self.upstream_html1["id"],
'upstream_type': 'component',
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
]
data = downstreams.json()
@@ -1156,7 +1155,7 @@ def test_modified_html_copy_paste(self):
'version_declined': None,
'ready_to_sync': True, # <--- updated
'error_message': None,
- 'is_modified': True, # <--- updated
+ 'downstream_customized': ['display_name'],
})
downstreams = self._get_downstream_links(
@@ -1179,7 +1178,7 @@ def test_modified_html_copy_paste(self):
'updated': date_format,
'upstream_key': self.upstream_html1["id"],
'upstream_type': 'component',
- 'downstream_is_modified': True, # <--- updated
+ 'downstream_customized': ["display_name"], # <--- updated
},
]
data = downstreams.json()
@@ -1259,9 +1258,9 @@ def test_unit_decline_sync(self):
parent_usage_key=str(self.course_subsection.usage_key),
upstream_key=self.upstream_unit["id"],
)
- downstream_unit_block_key = serialize_field(get_block_key_dict(
+ downstream_unit_block_key = get_block_key_string(
UsageKey.from_string(downstream_unit["locator"]),
- )).replace('"', '"')
+ )
children_downstream_keys = self._get_course_block_children(downstream_unit["locator"])
downstream_problem1 = children_downstream_keys[1]
assert "type@problem" in downstream_problem1
diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py
index b8f2c8f41057..b33d980732fa 100644
--- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py
+++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py
@@ -16,14 +16,14 @@
from cms.djangoapps.contentstore.helpers import StaticFileNotices
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from cms.djangoapps.contentstore.xblock_storage_handlers import view_handlers as xblock_view_handlers
-from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_block_key_dict
+from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import get_block_key_string
from cms.lib.xblock.upstream_sync import BadUpstream, UpstreamLink
from common.djangoapps.student.auth import add_users
from common.djangoapps.student.roles import CourseStaffRole
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.content_libraries import api as lib_api
from xmodule.modulestore.django import modulestore
-from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
+from xmodule.modulestore.tests.django_utils import ImmediateOnCommitMixin, SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
from .. import downstreams as downstreams_views
@@ -32,6 +32,7 @@
URL_PREFIX = '/api/libraries/v2/'
URL_LIB_CREATE = URL_PREFIX
URL_LIB_BLOCKS = URL_PREFIX + '{lib_key}/blocks/'
+URL_LIB_BLOCK = URL_PREFIX + 'blocks/{block_key}/'
URL_LIB_BLOCK_PUBLISH = URL_PREFIX + 'blocks/{block_key}/publish/'
URL_LIB_BLOCK_OLX = URL_PREFIX + 'blocks/{block_key}/olx/'
URL_LIB_CONTAINER = URL_PREFIX + 'containers/{container_key}/' # Get a container in this library
@@ -48,7 +49,7 @@ def _get_upstream_link_good_and_syncable(downstream):
version_available=(downstream.upstream_version or 0) + 1,
version_declined=downstream.upstream_version_declined,
error_message=None,
- is_modified=False,
+ downstream_customized=[],
has_top_level_parent=False,
)
@@ -157,7 +158,7 @@ def setUp(self):
parent=self.top_level_downstream_unit,
upstream=self.html_lib_id_2,
upstream_version=1,
- top_level_downstream_parent_key=get_block_key_dict(
+ top_level_downstream_parent_key=get_block_key_string(
self.top_level_downstream_unit.usage_key,
)
).usage_key
@@ -171,7 +172,7 @@ def setUp(self):
parent=self.top_level_downstream_chapter,
upstream=self.top_level_subsection_id,
upstream_version=1,
- top_level_downstream_parent_key=get_block_key_dict(
+ top_level_downstream_parent_key=get_block_key_string(
self.top_level_downstream_chapter.usage_key,
),
)
@@ -180,7 +181,7 @@ def setUp(self):
parent=self.top_level_downstream_sequential,
upstream=self.top_level_unit_id_2,
upstream_version=1,
- top_level_downstream_parent_key=get_block_key_dict(
+ top_level_downstream_parent_key=get_block_key_string(
self.top_level_downstream_chapter.usage_key,
),
)
@@ -189,7 +190,7 @@ def setUp(self):
parent=self.top_level_downstream_unit_2,
upstream=self.video_lib_id_2,
upstream_version=1,
- top_level_downstream_parent_key=get_block_key_dict(
+ top_level_downstream_parent_key=get_block_key_string(
self.top_level_downstream_chapter.usage_key,
)
).usage_key
@@ -277,6 +278,10 @@ def _create_container(self, lib_key, container_type, slug: str | None, display_n
data["slug"] = slug
return self._api('post', URL_LIB_CONTAINERS.format(lib_key=lib_key), data, expect_response)
+ def _delete_component(self, block_key, expect_response=200):
+ """ Publish all changes in the specified container + children """
+ return self._api('delete', URL_LIB_BLOCK.format(block_key=block_key), None, expect_response)
+
class SharedErrorTestCases(_BaseDownstreamViewTestMixin):
"""
@@ -406,7 +411,7 @@ def test_400(self, sync: str):
assert video_after.upstream is None
-class DeleteDownstreamViewTest(SharedErrorTestCases, SharedModuleStoreTestCase):
+class DeleteDownstreamViewTest(SharedErrorTestCases, ImmediateOnCommitMixin, SharedModuleStoreTestCase):
"""
Test that `DELETE /api/v2/contentstore/downstreams/...` severs a downstream's link to an upstream.
"""
@@ -455,17 +460,14 @@ def test_unlink_parent_should_update_children_top_level_parent(self):
unit = modulestore().get_item(self.top_level_downstream_unit_2.usage_key)
# The sequential is the top-level parent for the unit
- assert unit.top_level_downstream_parent_key == {
- "id": str(self.top_level_downstream_sequential.usage_key.block_id),
- "type": str(self.top_level_downstream_sequential.usage_key.block_type),
- }
+ sequential_block_key = get_block_key_string(
+ self.top_level_downstream_sequential.usage_key
+ )
+ assert unit.top_level_downstream_parent_key == sequential_block_key
video = modulestore().get_item(self.top_level_downstream_video_key)
# The sequential is the top-level parent for the video
- assert video.top_level_downstream_parent_key == {
- "id": str(self.top_level_downstream_sequential.usage_key.block_id),
- "type": str(self.top_level_downstream_sequential.usage_key.block_type),
- }
+ assert video.top_level_downstream_parent_key == sequential_block_key
all_downstreams = self.client.get(
"/api/contentstore/v2/downstreams/",
@@ -599,6 +601,7 @@ def test_204(self, mock_decline_sync):
@ddt.ddt
class GetUpstreamViewTest(
_BaseDownstreamViewTestMixin,
+ ImmediateOnCommitMixin,
SharedModuleStoreTestCase,
):
"""
@@ -646,6 +649,8 @@ def test_200_single_upstream_container(self):
self.assertDictEqual(data['ready_to_sync_children'][0], {
'name': html_block.display_name,
'upstream': str(self.html_lib_id_2),
+ 'block_type': 'html',
+ 'downstream_customized': [],
'id': str(html_block.usage_key),
})
@@ -675,7 +680,7 @@ def test_200_all_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -693,7 +698,7 @@ def test_200_all_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -711,7 +716,7 @@ def test_200_all_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': str(self.top_level_downstream_unit.usage_key),
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -729,7 +734,7 @@ def test_200_all_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key),
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -747,7 +752,7 @@ def test_200_all_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -765,7 +770,7 @@ def test_200_all_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -783,7 +788,7 @@ def test_200_all_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -801,7 +806,7 @@ def test_200_all_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -819,7 +824,7 @@ def test_200_all_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -837,7 +842,7 @@ def test_200_all_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key),
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -855,7 +860,7 @@ def test_200_all_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key),
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
]
self.assertListEqual(data["results"], expected)
@@ -895,7 +900,7 @@ def test_200_component_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -913,7 +918,7 @@ def test_200_component_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -931,7 +936,7 @@ def test_200_component_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': str(self.top_level_downstream_unit.usage_key),
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -949,7 +954,7 @@ def test_200_component_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key),
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
]
self.assertListEqual(data["results"], expected)
@@ -984,7 +989,7 @@ def test_200_container_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -1002,7 +1007,7 @@ def test_200_container_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -1020,7 +1025,7 @@ def test_200_container_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -1038,7 +1043,7 @@ def test_200_container_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -1056,7 +1061,7 @@ def test_200_container_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -1074,7 +1079,7 @@ def test_200_container_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key),
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -1092,7 +1097,7 @@ def test_200_container_downstreams_for_a_course(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': str(self.top_level_downstream_chapter.usage_key),
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
]
self.assertListEqual(data["results"], expected)
@@ -1192,7 +1197,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_components(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -1210,7 +1215,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_components(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -1228,7 +1233,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_components(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -1246,11 +1251,9 @@ def test_200_get_ready_to_sync_top_level_parents_with_components(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
]
- print(data["results"])
- print(expected)
self.assertListEqual(data["results"], expected)
def test_200_get_ready_to_sync_top_level_parents_with_containers(self):
@@ -1293,7 +1296,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_containers(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -1311,7 +1314,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_containers(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -1329,7 +1332,7 @@ def test_200_get_ready_to_sync_top_level_parents_with_containers(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
]
self.assertListEqual(data["results"], expected)
@@ -1383,7 +1386,7 @@ def test_200_get_ready_to_sync_duplicated_top_level_parents(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -1401,7 +1404,7 @@ def test_200_get_ready_to_sync_duplicated_top_level_parents(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
{
'created': date_format,
@@ -1419,7 +1422,7 @@ def test_200_get_ready_to_sync_duplicated_top_level_parents(self):
'version_declined': None,
'version_synced': 1,
'top_level_parent_usage_key': None,
- 'downstream_is_modified': False,
+ 'downstream_customized': [],
},
]
self.assertListEqual(data["results"], expected)
@@ -1427,6 +1430,7 @@ def test_200_get_ready_to_sync_duplicated_top_level_parents(self):
class GetDownstreamSummaryViewTest(
_BaseDownstreamViewTestMixin,
+ ImmediateOnCommitMixin,
SharedModuleStoreTestCase,
):
"""
@@ -1504,3 +1508,109 @@ def test_200_summary(self):
'last_published_at': self.now.strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
}]
self.assertListEqual(data, expected)
+
+
+class GetDownstreamDeletedUpstream(
+ _BaseDownstreamViewTestMixin,
+ ImmediateOnCommitMixin,
+ SharedModuleStoreTestCase,
+):
+ """
+ Test that parent container is marked ready_to_sync when even when the only change is a deleted component under it
+ """
+ def call_api(
+ self,
+ course_id: str | None = None,
+ ready_to_sync: bool | None = None,
+ upstream_key: str | None = None,
+ item_type: str | None = None,
+ use_top_level_parents: bool | None = None,
+ ):
+ data = {}
+ if course_id is not None:
+ data["course_id"] = str(course_id)
+ if ready_to_sync is not None:
+ data["ready_to_sync"] = str(ready_to_sync)
+ if upstream_key is not None:
+ data["upstream_key"] = str(upstream_key)
+ if item_type is not None:
+ data["item_type"] = str(item_type)
+ if use_top_level_parents is not None:
+ data["use_top_level_parents"] = str(use_top_level_parents)
+ return self.client.get("/api/contentstore/v2/downstreams/", data=data)
+
+ def test_delete_component_should_be_ready_to_sync(self):
+ """
+ Test deleting a component from library should mark the entire section container ready to sync
+ """
+ # Create blocks
+ section_id = self._create_container(self.library_id, "section", "section-12", "Section 12")["id"]
+ subsection_id = self._create_container(self.library_id, "subsection", "subsection-12", "Subsection 12")["id"]
+ unit_id = self._create_container(self.library_id, "unit", "unit-12", "Unit 12")["id"]
+ video_id = self._add_block_to_library(self.library_id, "video", "video-bar-13")["id"]
+ section_key = ContainerKey.from_string(section_id)
+ subsection_key = ContainerKey.from_string(subsection_id)
+ unit_key = ContainerKey.from_string(unit_id)
+ video_key = LibraryUsageLocatorV2.from_string(video_id)
+
+ # Set children
+ lib_api.update_container_children(section_key, [subsection_key], None)
+ lib_api.update_container_children(subsection_key, [unit_key], None)
+ lib_api.update_container_children(unit_key, [video_key], None)
+ self._publish_container(unit_id)
+ self._publish_container(subsection_id)
+ self._publish_container(section_id)
+ self._publish_library_block(video_id)
+ course = CourseFactory.create(display_name="Course New")
+ add_users(self.superuser, CourseStaffRole(course.id), self.course_user)
+ chapter = BlockFactory.create(
+ category='chapter', parent=course, upstream=section_id, upstream_version=2,
+ )
+ sequential = BlockFactory.create(
+ category='sequential',
+ parent=chapter,
+ upstream=subsection_id,
+ upstream_version=2,
+ top_level_downstream_parent_key=get_block_key_string(chapter.usage_key),
+ )
+ vertical = BlockFactory.create(
+ category='vertical',
+ parent=sequential,
+ upstream=unit_id,
+ upstream_version=2,
+ top_level_downstream_parent_key=get_block_key_string(chapter.usage_key),
+ )
+ BlockFactory.create(
+ category='video',
+ parent=vertical,
+ upstream=video_id,
+ upstream_version=1,
+ top_level_downstream_parent_key=get_block_key_string(chapter.usage_key),
+ )
+ self._delete_component(video_id)
+ self._publish_container(unit_id)
+ response = self.call_api(course_id=course.id, ready_to_sync=True, use_top_level_parents=True)
+ assert response.status_code == 200
+ data = response.json()['results']
+ assert len(data) == 1
+ date_format = self.now.isoformat().split("+")[0] + 'Z'
+ expected_results = {
+ 'created': date_format,
+ 'downstream_context_key': str(course.id),
+ 'downstream_usage_key': str(chapter.usage_key),
+ 'downstream_customized': [],
+ 'id': 8,
+ 'ready_to_sync': False,
+ 'ready_to_sync_from_children': True,
+ 'top_level_parent_usage_key': None,
+ 'updated': date_format,
+ 'upstream_context_key': self.library_id,
+ 'upstream_context_title': self.library_title,
+ 'upstream_key': section_id,
+ 'upstream_type': 'container',
+ 'upstream_version': 2,
+ 'version_declined': None,
+ 'version_synced': 2,
+ }
+
+ self.assertDictEqual(data[0], expected_results)
diff --git a/cms/djangoapps/contentstore/signals/handlers.py b/cms/djangoapps/contentstore/signals/handlers.py
index ebf47f527f5f..e28cbf313acb 100644
--- a/cms/djangoapps/contentstore/signals/handlers.py
+++ b/cms/djangoapps/contentstore/signals/handlers.py
@@ -221,6 +221,7 @@ def handle_item_deleted(**kwargs):
id_list.add(block.location)
ComponentLink.objects.filter(downstream_usage_key__in=id_list).delete()
+ ContainerLink.objects.filter(downstream_usage_key__in=id_list).delete()
@receiver(GRADING_POLICY_CHANGED)
diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py
index 71b86acca201..13c72ff3391a 100644
--- a/cms/djangoapps/contentstore/tasks.py
+++ b/cms/djangoapps/contentstore/tasks.py
@@ -92,6 +92,7 @@
from xmodule.modulestore.xml_exporter import export_course_to_xml, export_library_to_xml
from xmodule.modulestore.xml_importer import CourseImportException, import_course_from_xml, import_library_from_xml
from xmodule.tabs import StaticTab
+from xmodule.util.keys import BlockKey
from .models import ComponentLink, ContainerLink, LearningContextLinksStatus, LearningContextLinksStatusChoices
from .outlines import update_outline_from_modulestore
@@ -1649,10 +1650,11 @@ def handle_create_xblock_upstream_link(usage_key):
if not xblock.upstream or not xblock.upstream_version:
return
if xblock.top_level_downstream_parent_key is not None:
+ block_key = BlockKey.from_string(xblock.top_level_downstream_parent_key)
top_level_parent_usage_key = BlockUsageLocator(
xblock.course_id,
- xblock.top_level_downstream_parent_key.get('type'),
- xblock.top_level_downstream_parent_key.get('id'),
+ block_key.type,
+ block_key.id,
)
try:
ContainerLink.get_by_downstream_usage_key(top_level_parent_usage_key)
@@ -1675,7 +1677,7 @@ def handle_update_xblock_upstream_link(usage_key):
except (ItemNotFoundError, InvalidKeyError):
LOGGER.exception(f'Could not find item for given usage_key: {usage_key}')
return
- if not xblock.upstream or not xblock.upstream_version:
+ if not xblock.upstream or xblock.upstream_version is None:
return
create_or_update_xblock_upstream_link(xblock)
diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py
index 8b6aa6d2bba5..a8721d629c76 100644
--- a/cms/djangoapps/contentstore/tests/test_contentstore.py
+++ b/cms/djangoapps/contentstore/tests/test_contentstore.py
@@ -16,11 +16,11 @@
from uuid import uuid4
import ddt
-import lxml.html
from django.conf import settings
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.test import TestCase
from django.test.utils import override_settings
+from django.urls import reverse
from edx_toggles.toggles.testutils import override_waffle_switch, override_waffle_flag
from edxval.api import create_video, get_videos_for_course
from fs.osfs import OSFS
@@ -1388,17 +1388,6 @@ def assert_course_permission_denied(self):
resp = self.client.ajax_post('/course/', self.course_data)
self.assertEqual(resp.status_code, 403)
- @override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True)
- def test_course_index_view_with_no_courses(self):
- """Test viewing the index page with no courses"""
- resp = self.client.get_html('/home/')
- self.assertContains(
- resp,
- f'
{settings.STUDIO_SHORT_NAME} Home
',
- status_code=200,
- html=True
- )
-
def test_course_factory(self):
"""Test that the course factory works correctly."""
course = CourseFactory.create()
@@ -1410,33 +1399,6 @@ def test_item_factory(self):
item = BlockFactory.create(parent_location=course.location)
self.assertIsInstance(item, SequenceBlock)
- @override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True)
- def test_course_overview_view_with_course(self):
- """Test viewing the course overview page with an existing course"""
- course = CourseFactory.create()
- resp = self._show_course_overview(course.id)
-
- # course_handler raise 404 for old mongo course
- if course.id.deprecated:
- self.assertEqual(resp.status_code, 404)
- return
-
- assets_url = reverse_course_url(
- 'assets_handler',
- course.location.course_key
- )
-
- self.assertContains(
- resp,
- ''.format( # lint-amnesty, pylint: disable=line-too-long
- locator=str(course.location),
- course_key=str(course.id),
- assets_url=assets_url,
- ),
- status_code=200,
- html=True
- )
-
def test_create_block(self):
"""Test creating a new xblock instance."""
course = CourseFactory.create()
@@ -1510,8 +1472,7 @@ def test_get_html(handler):
)
course_key = course_items[0].id
- with override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True):
- resp = self._show_course_overview(course_key)
+ resp = self._show_course_overview(course_key)
# course_handler raise 404 for old mongo course
if course_key.deprecated:
@@ -1530,8 +1491,6 @@ def test_get_html(handler):
test_get_html('course_team_handler')
with override_waffle_flag(toggles.LEGACY_STUDIO_UPDATES, True):
test_get_html('course_info_handler')
- with override_waffle_flag(toggles.LEGACY_STUDIO_FILES_UPLOADS, True):
- test_get_html('assets_handler')
with override_waffle_flag(toggles.LEGACY_STUDIO_CUSTOM_PAGES, True):
test_get_html('tabs_handler')
with override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True):
@@ -1755,7 +1714,8 @@ def _show_course_overview(self, course_key):
"""
Show the course overview page.
"""
- resp = self.client.get_html(get_url('course_handler', course_key, 'course_key_string'))
+ resp = self.client.get(get_url('course_handler', course_key, 'course_key_string'),
+ content_type='application/json')
return resp
def test_wiki_slug(self):
@@ -1879,17 +1839,21 @@ def assertInCourseListing(self, course_key):
"""
Asserts that the given course key is NOT in the unsucceeded course action section of the html.
"""
- with override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True):
- course_listing = lxml.html.fromstring(self.client.get_html('/home/').content)
- self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 0)
+ response = self.client.get(reverse('cms.djangoapps.contentstore:v2:courses'))
+ assert str(course_key) not in [
+ course["course_key"]
+ for course in response.json()["results"]["in_process_course_actions"]
+ ]
def assertInUnsucceededCourseActions(self, course_key):
"""
Asserts that the given course key is in the unsucceeded course action section of the html.
"""
- with override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True):
- course_listing = lxml.html.fromstring(self.client.get_html('/home/').content)
- self.assertEqual(len(self.get_unsucceeded_course_action_elements(course_listing, course_key)), 1)
+ response = self.client.get(reverse('cms.djangoapps.contentstore:v2:courses'))
+ assert str(course_key) in [
+ course["course_key"]
+ for course in response.json()["results"]["in_process_course_actions"]
+ ]
def verify_rerun_course(self, source_course_key, destination_course_key, destination_display_name):
"""
diff --git a/cms/djangoapps/contentstore/tests/test_course_listing.py b/cms/djangoapps/contentstore/tests/test_course_listing.py
index e46b493b7b39..990eff83c922 100644
--- a/cms/djangoapps/contentstore/tests/test_course_listing.py
+++ b/cms/djangoapps/contentstore/tests/test_course_listing.py
@@ -8,12 +8,9 @@
import ddt
from ccx_keys.locator import CCXLocator
-from django.conf import settings
from django.test import RequestFactory
-from edx_toggles.toggles.testutils import override_waffle_flag
from opaque_keys.edx.locations import CourseLocator
-from cms.djangoapps.contentstore import toggles
from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient
from cms.djangoapps.contentstore.utils import delete_course
from cms.djangoapps.contentstore.views.course import (
@@ -24,8 +21,10 @@
get_courses_accessible_to_user
)
from common.djangoapps.course_action_state.models import CourseRerunState
+from common.djangoapps.student.models.user import CourseAccessRole
from common.djangoapps.student.roles import (
CourseInstructorRole,
+ CourseLimitedStaffRole,
CourseStaffRole,
GlobalStaff,
OrgInstructorRole,
@@ -89,15 +88,6 @@ def tearDown(self):
self.client.logout()
ModuleStoreTestCase.tearDown(self) # pylint: disable=non-parent-method-called
- @override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True)
- def test_empty_course_listing(self):
- """
- Test on empty course listing, studio name is properly displayed
- """
- message = f"Are you staff on an existing {settings.STUDIO_SHORT_NAME} course?"
- response = self.client.get('/home')
- self.assertContains(response, message)
-
def test_get_course_list(self):
"""
Test getting courses with new access group format e.g. 'instructor_edx.course.run'
@@ -188,6 +178,48 @@ def test_staff_course_listing(self):
with self.assertNumQueries(2):
list(_accessible_courses_summary_iter(self.request))
+ def test_course_limited_staff_course_listing(self):
+ # Setup a new course
+ course_location = self.store.make_course_key('Org', 'CreatedCourse', 'Run')
+ CourseFactory.create(
+ org=course_location.org,
+ number=course_location.course,
+ run=course_location.run
+ )
+ course = CourseOverviewFactory.create(id=course_location, org=course_location.org)
+
+ # Add the user as a course_limited_staff on the course
+ CourseLimitedStaffRole(course.id).add_users(self.user)
+ self.assertTrue(CourseLimitedStaffRole(course.id).has_user(self.user))
+
+ # Fetch accessible courses list & verify their count
+ courses_list_by_staff, __ = get_courses_accessible_to_user(self.request)
+
+ # Limited Course Staff should not be able to list courses in Studio
+ assert len(list(courses_list_by_staff)) == 0
+
+ def test_org_limited_staff_course_listing(self):
+
+ # Setup a new course
+ course_location = self.store.make_course_key('Org', 'CreatedCourse', 'Run')
+ CourseFactory.create(
+ org=course_location.org,
+ number=course_location.course,
+ run=course_location.run
+ )
+ course = CourseOverviewFactory.create(id=course_location, org=course_location.org)
+
+ # Add a user as course_limited_staff on the org
+ # This is not possible using the course roles classes but is possible via Django admin so we
+ # insert a row into the model directly to test that scenario.
+ CourseAccessRole.objects.create(user=self.user, org=course_location.org, role=CourseLimitedStaffRole.ROLE)
+
+ # Fetch accessible courses list & verify their count
+ courses_list_by_staff, __ = get_courses_accessible_to_user(self.request)
+
+ # Limited Course Staff should not be able to list courses in Studio
+ assert len(list(courses_list_by_staff)) == 0
+
def test_get_course_list_with_invalid_course_location(self):
"""
Test getting courses with invalid course location (course deleted from modulestore).
diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py
index 4e79ba70993a..9afba26b32e5 100644
--- a/cms/djangoapps/contentstore/tests/test_course_settings.py
+++ b/cms/djangoapps/contentstore/tests/test_course_settings.py
@@ -168,7 +168,6 @@ def test_discussion_fields_available(self, is_pages_and_resources_enabled,
@override_waffle_flag(toggles.LEGACY_STUDIO_EXPORT, True)
@override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_TEAM, True)
@override_waffle_flag(toggles.LEGACY_STUDIO_UPDATES, True)
- @override_waffle_flag(toggles.LEGACY_STUDIO_FILES_UPLOADS, True)
@override_waffle_flag(toggles.LEGACY_STUDIO_CUSTOM_PAGES, True)
@override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True)
@override_waffle_flag(toggles.LEGACY_STUDIO_GRADING, True)
@@ -188,7 +187,6 @@ def test_disable_advanced_settings_feature(self, disable_advanced_settings):
'export_handler',
'course_team_handler',
'course_info_handler',
- 'assets_handler',
'tabs_handler',
'settings_handler',
'grading_handler',
diff --git a/cms/djangoapps/contentstore/tests/test_exams.py b/cms/djangoapps/contentstore/tests/test_exams.py
index 798b5e51fd80..823038957714 100644
--- a/cms/djangoapps/contentstore/tests/test_exams.py
+++ b/cms/djangoapps/contentstore/tests/test_exams.py
@@ -65,16 +65,21 @@ def _get_exam_due_date(self, course, sequential):
Return the expected exam due date for the exam, based on the selected course proctoring provider and the
exam due date or the course end date.
+ This is a copy of the due date computation logic in register_exams function.
+
Arguments:
* course: the course that the exam subsection is in; may have a course.end attribute
* sequential: the exam subsection; may have a sequential.due attribute
"""
+ is_instructor_paced = not course.self_paced
if course.proctoring_provider == 'lti_external':
- return sequential.due.isoformat() if sequential.due else (course.end.isoformat() if course.end else None)
- elif course.self_paced:
- return None
+ due_date_source = sequential.due if is_instructor_paced else course.end
else:
- return sequential.due
+ due_date_source = sequential.due if is_instructor_paced else None
+
+ due_date = due_date_source.isoformat() if due_date_source else None
+
+ return due_date
@ddt.data(*(tuple(base) + (extra,) for base, extra in itertools.product(
[
@@ -185,14 +190,13 @@ def test_feature_flag_off(self, mock_patch_course_exams):
def test_no_due_dates(self, is_self_paced, course_end_date, proctoring_provider, mock_patch_course_exams):
"""
Test that the the correct due date is registered for the exam when the subsection does not have a due date,
- depending on the proctoring provider.
+ depending on the proctoring provider and course pacing type.
* lti_external
- * The course end date is registered as the due date when the subsection does not have a due date for both
- self-paced and instructor-paced exams.
+ * If the course is instructor-paced, the exam due date is the subsection due date if it exists, else None.
+ * If the course is self-paced, the exam due date is the course end date if it exists, else None.
* not lti_external
- * None is registered as the due date when the subsection does not have a due date for both
- self-paced and instructor-paced exams.
+ * The exam due date is always the subsection due date if it exists, else None.
"""
self.course.self_paced = is_self_paced
self.course.end = course_end_date
@@ -222,25 +226,17 @@ def test_no_due_dates(self, is_self_paced, course_end_date, proctoring_provider,
@ddt.data(*itertools.product((True, False), ('lti_external', 'null')))
@ddt.unpack
@freeze_time('2024-01-01')
- def test_subsection_due_date_prioritized(self, is_self_paced, proctoring_provider, mock_patch_course_exams):
+ def test_subsection_due_date_prioritized_instructor_paced(
+ self,
+ is_self_paced,
+ proctoring_provider,
+ mock_patch_course_exams
+ ):
"""
- Test that the subsection due date is registered as the due date when both the subsection has a due date and the
- course has an end date for both self-paced and instructor-paced exams.
-
- Test that the the correct due date is registered for the exam when the subsection has a due date, depending on
- the proctoring provider.
-
- * lti_external
- * The subsection due date is registered as the due date when both the subsection has a due date and the
- course has an end date for both self-paced and instructor-paced exams
- * not lti_external
- * None is registered as the due date when both the subsection has a due date and the course has an end date
- for self-paced exams.
- * The subsection due date is registered as the due date when both the subsection has a due date and the
- course has an end date for instructor-paced exams.
+ Test that exam due date is computed correctly.
"""
self.course.self_paced = is_self_paced
- self.course.end = datetime(2035, 1, 1, 0, 0)
+ self.course.end = datetime(2035, 1, 1, 0, 0, tzinfo=timezone.utc)
self.course.proctoring_provider = proctoring_provider
self.course = self.update_course(self.course, 1)
@@ -260,7 +256,7 @@ def test_subsection_due_date_prioritized(self, is_self_paced, proctoring_provide
)
listen_for_course_publish(self, self.course.id)
- called_exams, called_course = mock_patch_course_exams.call_args[0]
+ called_exams, _ = mock_patch_course_exams.call_args[0]
expected_due_date = self._get_exam_due_date(self.course, sequence)
diff --git a/cms/djangoapps/contentstore/tests/test_i18n.py b/cms/djangoapps/contentstore/tests/test_i18n.py
index 6201253babf4..3ee991493196 100644
--- a/cms/djangoapps/contentstore/tests/test_i18n.py
+++ b/cms/djangoapps/contentstore/tests/test_i18n.py
@@ -2,10 +2,9 @@
Tests for validate Internationalization and XBlock i18n service.
"""
import gettext
-from unittest import mock, skip
+from unittest import mock
from django.utils import translation
-from edx_toggles.toggles.testutils import override_waffle_flag
from django.utils.translation import get_language
from xblock.core import XBlock
@@ -14,10 +13,7 @@
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
from xmodule.tests.test_export import PureXBlock
-from cms.djangoapps.contentstore import toggles
-from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient
from cms.djangoapps.contentstore.views.preview import _prepare_runtime_for_preview
-from common.djangoapps.student.tests.factories import UserFactory
class FakeTranslations(XBlockI18nService):
@@ -166,101 +162,3 @@ def test_i18n_service_callable(self):
Test: i18n service should be callable in studio.
"""
self.assertTrue(callable(self.block.runtime._services.get('i18n'))) # pylint: disable=protected-access
-
-
-class InternationalizationTest(ModuleStoreTestCase):
- """
- Tests to validate Internationalization.
- """
-
- CREATE_USER = False
-
- def setUp(self):
- """
- These tests need a user in the DB so that the django Test Client
- can log them in.
- They inherit from the ModuleStoreTestCase class so that the mongodb collection
- will be cleared out before each test case execution and deleted
- afterwards.
- """
- super().setUp()
-
- self.uname = 'testuser'
- self.email = 'test+courses@edx.org'
- self.password = self.TEST_PASSWORD
-
- # Create the use so we can log them in.
- self.user = UserFactory.create(username=self.uname, email=self.email, password=self.password)
-
- # Note that we do not actually need to do anything
- # for registration if we directly mark them active.
- self.user.is_active = True
- # Staff has access to view all courses
- self.user.is_staff = True
- self.user.save()
-
- self.course_data = {
- 'org': 'MITx',
- 'number': '999',
- 'display_name': 'Robot Super Course',
- }
-
- @override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True)
- def test_course_plain_english(self):
- """Test viewing the index page with no courses"""
- self.client = AjaxEnabledTestClient() # lint-amnesty, pylint: disable=attribute-defined-outside-init
- self.client.login(username=self.uname, password=self.password)
-
- resp = self.client.get_html('/home/')
- self.assertContains(resp,
- '
',
- status_code=200,
- html=True)
-
- # ****
- # NOTE:
- # ****
- #
- # This test will break when we replace this fake 'test' language
- # with actual Esperanto. This test will need to be updated with
- # actual Esperanto at that time.
- # Test temporarily disable since it depends on creation of dummy strings
- @skip
- def test_course_with_accents(self):
- """Test viewing the index page with no courses"""
- self.client = AjaxEnabledTestClient() # lint-amnesty, pylint: disable=attribute-defined-outside-init
- self.client.login(username=self.uname, password=self.password)
-
- resp = self.client.get_html(
- '/home/',
- {},
- HTTP_ACCEPT_LANGUAGE='eo'
- )
-
- TEST_STRING = (
- '
'
- 'My \xc7\xf6\xfcrs\xe9s L#'
- '
'
- )
-
- self.assertContains(resp,
- TEST_STRING,
- status_code=200,
- html=True)
diff --git a/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py b/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py
index 90fec8471651..5c3ba8386480 100644
--- a/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py
+++ b/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py
@@ -14,7 +14,7 @@
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangolib.testing.utils import skip_unless_cms
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, ImmediateOnCommitMixin
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory
from ..models import ContainerLink, LearningContextLinksStatus, LearningContextLinksStatusChoices, ComponentLink
@@ -265,7 +265,12 @@ def test_call_for_nonexistent_course(self):
@skip_unless_cms
-class TestUpstreamLinksEvents(ModuleStoreTestCase, OpenEdxEventsTestMixin, BaseUpstreamLinksHelpers):
+class TestUpstreamLinksEvents(
+ ImmediateOnCommitMixin,
+ ModuleStoreTestCase,
+ OpenEdxEventsTestMixin,
+ BaseUpstreamLinksHelpers,
+):
"""
Test signals related to managing upstream->downstream links.
"""
diff --git a/cms/djangoapps/contentstore/tests/test_video_audio_description_handler.py b/cms/djangoapps/contentstore/tests/test_video_audio_description_handler.py
new file mode 100644
index 000000000000..617bc90d5132
--- /dev/null
+++ b/cms/djangoapps/contentstore/tests/test_video_audio_description_handler.py
@@ -0,0 +1,199 @@
+"""
+Tests for the studio_audio_description XBlock handler and its
+contentstore.enable_audio_description waffle flag gate.
+
+The handler itself delegates to the storage helpers in
+cms.djangoapps.contentstore.audio_description_storage_handlers; these
+tests focus on the handler's gating behavior and dispatch logic, not on
+the storage helpers themselves.
+
+These tests live in CMS-test land (rather than alongside the rest of
+the LMS-side video handler tests in
+lms/djangoapps/courseware/tests/test_video_handlers.py) because
+cms.djangoapps.contentstore.toggles transitively imports the
+Studio-only search-api and so cannot be loaded under LMS test settings.
+"""
+
+import importlib
+from unittest.mock import Mock, patch
+
+from django.test import TestCase
+from edx_toggles.toggles.testutils import override_waffle_flag
+from opaque_keys.edx.locator import CourseLocator
+from webob import Request
+
+from cms.djangoapps.contentstore.toggles import ENABLE_AUDIO_DESCRIPTION
+from xmodule.video_block.video_block import VideoBlock
+
+
+class StudioAudioDescriptionHandlerTest(TestCase):
+ """
+ The XBlock @handler decorator does not wrap the function -- it just
+ sets _is_xblock_handler = True -- so we can call
+ VideoBlock.studio_audio_description as a plain function with a Mock
+ standing in for `self`. The handler only touches self.edx_video_id
+ and self.audio_description, both of which the Mock can carry.
+ """
+
+ def setUp(self):
+ super().setUp()
+
+ importlib.import_module("cms.djangoapps.contentstore.views")
+ self.storage_handlers = importlib.import_module(
+ "cms.djangoapps.contentstore.audio_description_storage_handlers"
+ )
+
+ def _build_block_mock(self, edx_video_id="video-1", audio_description=""):
+ """
+ Return a minimal Mock that satisfies the attribute contract
+ expected by the studio_audio_description handler.
+ """
+ block = Mock(
+ spec_set=[
+ "edx_video_id",
+ "audio_description",
+ "audio_description_video_id",
+ "course_id",
+ ]
+ )
+ block.edx_video_id = edx_video_id
+ block.audio_description = audio_description
+ block.audio_description_video_id = ""
+ block.course_id = CourseLocator(org="test", course="test", run="test")
+ return block
+
+ def _call(self, block, method, body=None, request=None):
+ """
+ Build a WebOb Request and invoke the handler directly,
+ bypassing the XBlock runtime dispatch machinery.
+ """
+ if request is not None:
+ return VideoBlock.studio_audio_description(block, request=request)
+ kwargs = {"method": method}
+ if body is not None:
+ kwargs["body"] = body
+ request = Request.blank("", **kwargs)
+ return VideoBlock.studio_audio_description(block, request=request)
+
+ @override_waffle_flag(ENABLE_AUDIO_DESCRIPTION, active=False)
+ def test_handler_returns_404_when_flag_disabled(self):
+ """
+ When the upload flag is off, every HTTP method on the handler
+ must return 404 so the endpoint looks non-existent to clients.
+ """
+ block = self._build_block_mock()
+ for method in ("GET", "POST", "DELETE"):
+ response = self._call(block, method)
+ self.assertEqual(response.status_code, 404, msg=f"method={method}")
+
+ @override_waffle_flag(ENABLE_AUDIO_DESCRIPTION, active=True)
+ def test_post_uploads_file_and_returns_url(self):
+ """
+ With the flag on, a POST request carrying a file should reach
+ upload_audio_description and return {file_name, url} with 201.
+ """
+ block = self._build_block_mock(edx_video_id="video-1")
+
+ file_mock = Mock()
+ file_mock.name = "bar.mp3"
+ file_mock.type = "audio/mpeg"
+ file_mock.file = Mock()
+
+ request = Mock()
+ request.method = "POST"
+ request.POST = {
+ "file": file_mock,
+ "file_name": "bar.mp3",
+ "content_type": "audio/mpeg",
+ }
+
+ with patch.object(
+ self.storage_handlers, "upload_audio_description"
+ ) as mock_upload:
+ mock_upload.return_value = "https://s3.example/bar.mp3"
+ response = self._call(block, "POST", request=request)
+
+ self.assertEqual(response.status_code, 201)
+ self.assertEqual(
+ response.json,
+ {"file_name": "bar.mp3", "url": "https://s3.example/bar.mp3"},
+ )
+ mock_upload.assert_called_once_with(
+ edx_video_id="video-1",
+ file_name="bar.mp3",
+ content_type="audio/mpeg",
+ file_data=file_mock.file,
+ )
+
+ @override_waffle_flag(ENABLE_AUDIO_DESCRIPTION, active=True)
+ def test_post_returns_400_when_file_missing(self):
+ """
+ With the flag on but no file in the POST body, the handler must
+ return 400 with an error message.
+ """
+ block = self._build_block_mock(edx_video_id="video-1")
+
+ request = Mock()
+ request.method = "POST"
+ request.POST = {} # no 'file' key
+
+ response = self._call(block, "POST", request=request)
+
+ self.assertEqual(response.status_code, 400)
+ self.assertIn("error", response.json)
+
+ @override_waffle_flag(ENABLE_AUDIO_DESCRIPTION, active=True)
+ def test_get_returns_404_when_no_url(self):
+ """
+ With the flag on but no AD record on the block, the GET branch
+ should return 404 (the storage helper returns None).
+ """
+ block = self._build_block_mock()
+
+ with patch.object(self.storage_handlers, "get_audio_description_url") as mock_url:
+ mock_url.return_value = None
+ response = self._call(block, "GET")
+
+ self.assertEqual(response.status_code, 404)
+
+ @override_waffle_flag(ENABLE_AUDIO_DESCRIPTION, active=True)
+ def test_get_returns_url_when_present(self):
+ """
+ With the flag on and a ready AD record, the GET branch returns
+ a JSON body containing the helper's pre-signed URL plus the
+ block's stored filename.
+ """
+ block = self._build_block_mock(audio_description="bar.mp3")
+
+ with patch.object(self.storage_handlers, "get_audio_description_url") as mock_url:
+ mock_url.return_value = "https://s3.example/get-presigned"
+ response = self._call(block, "GET")
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(
+ response.json,
+ {
+ "file_name": "bar.mp3",
+ "url": "https://s3.example/get-presigned",
+ },
+ )
+
+ @override_waffle_flag(ENABLE_AUDIO_DESCRIPTION, active=True)
+ def test_delete_when_flag_enabled(self):
+ """
+ With the flag on, a DELETE request should call the storage
+ helper, clear the block's audio_description field, and return
+ 204.
+ """
+ block = self._build_block_mock(
+ edx_video_id="video-1", audio_description="bar.mp3"
+ )
+
+ with patch.object(
+ self.storage_handlers, "delete_audio_description"
+ ) as mock_delete:
+ response = self._call(block, "DELETE")
+
+ self.assertEqual(response.status_code, 204)
+ self.assertEqual(block.audio_description, "")
+ mock_delete.assert_called_once_with("video-1")
diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py
index 40b0f8ad4cbf..d151b1d58526 100644
--- a/cms/djangoapps/contentstore/tests/tests.py
+++ b/cms/djangoapps/contentstore/tests/tests.py
@@ -11,7 +11,7 @@
import datetime
import time
from unittest import mock
-from urllib.parse import quote_plus
+from urllib.parse import quote_plus, unquote
from ddt import data, ddt, unpack
from django.conf import settings
@@ -24,6 +24,7 @@
from cms.djangoapps.contentstore import toggles
from cms.djangoapps.contentstore.tests.test_course_settings import CourseTestCase
from cms.djangoapps.contentstore.tests.utils import AjaxEnabledTestClient, parse_json, registration, user
+from cms.djangoapps.contentstore.utils import get_studio_home_url
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
@@ -114,12 +115,6 @@ def setUp(self):
# clear the cache so ratelimiting won't affect these tests
cache.clear()
- def check_page_get(self, url, expected):
- resp = self.client.get_html(url)
- self.assertEqual(resp.status_code, expected)
- return resp
-
- @override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True)
def test_private_pages_auth(self):
"""Make sure pages that do require login work."""
auth_pages = (
@@ -143,7 +138,9 @@ def test_private_pages_auth(self):
print('Not logged in')
for page in auth_pages:
print(f"Checking '{page}'")
- self.check_page_get(page, expected=302)
+ resp = self.client.get_html(page)
+ assert resp.status_code == 302
+ assert resp.url == unquote(reverse("login", query={"next": page}))
# Logged in should work.
self.login(self.email, self.pw)
@@ -151,10 +148,11 @@ def test_private_pages_auth(self):
print('Logged in')
for page in simple_auth_pages:
print(f"Checking '{page}'")
- self.check_page_get(page, expected=200)
+ resp = self.client.get_html(page)
+ assert resp.status_code == 302
+ assert resp.url == get_studio_home_url()
@override_settings(SESSION_INACTIVITY_TIMEOUT_IN_SECONDS=1)
- @override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True)
def test_inactive_session_timeout(self):
"""
Verify that an inactive session times out and redirects to the
@@ -168,7 +166,8 @@ def test_inactive_session_timeout(self):
# make sure we can access courseware immediately
course_url = '/home/'
resp = self.client.get_html(course_url)
- self.assertEqual(resp.status_code, 200)
+ assert resp.status_code == 302
+ assert resp.url == get_studio_home_url()
# then wait a bit and see if we get timed out
time.sleep(2)
diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py
index c287f8c4dbec..03c374fd1b94 100644
--- a/cms/djangoapps/contentstore/toggles.py
+++ b/cms/djangoapps/contentstore/toggles.py
@@ -2,7 +2,6 @@
CMS feature toggles.
"""
from edx_toggles.toggles import SettingDictToggle, WaffleFlag
-from openedx.core.djangoapps.content.search import api as search_api
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
@@ -67,42 +66,47 @@ def bypass_olx_failure_enabled():
return BYPASS_OLX_FAILURE.is_enabled()
-# .. toggle_name: legacy_studio.exam_settings
-# .. toggle_implementation: WaffleFlag
+# .. toggle_name: contentstore.enable_audio_description
+# .. toggle_implementation: CourseWaffleFlag
# .. toggle_default: False
-# .. toggle_description: Temporarily fall back to the old proctored exam settings view.
-# .. toggle_use_cases: temporary
-# .. toggle_creation_date: 2025-03-14
-# .. toggle_target_removal_date: 2025-09-14
-# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275
-# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available.
-LEGACY_STUDIO_EXAM_SETTINGS = CourseWaffleFlag("legacy_studio.exam_settings", __name__)
+# .. toggle_description: Enables the audio description (AD) upload UI in the
+# Studio video editor and the corresponding studio_audio_description XBlock
+# handler. When disabled, course authors cannot upload, replace, or delete
+# audio description files for video blocks. Playback of AD files that were
+# already uploaded is NOT gated by this flag and continues to work in the
+# LMS -- disable the playback path with a separate flag if needed.
+# .. toggle_use_cases: open_edx
+# .. toggle_creation_date: 2026-04-07
+ENABLE_AUDIO_DESCRIPTION = CourseWaffleFlag(
+ f'{CONTENTSTORE_NAMESPACE}.enable_audio_description',
+ __name__,
+)
-def exam_setting_view_enabled(course_key):
+def audio_description_enabled(course_key):
"""
- Returns a boolean if proctoring exam setting mfe view is enabled.
+ Return True if the audio description upload UI and handler are enabled.
"""
- return not LEGACY_STUDIO_EXAM_SETTINGS.is_enabled(course_key)
+ return ENABLE_AUDIO_DESCRIPTION.is_enabled(course_key)
-# .. toggle_name: legacy_studio.text_editor
+# .. toggle_name: legacy_studio.exam_settings
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
-# .. toggle_description: Temporarily fall back to the old Text component (a.k.a. html block) editor.
+# .. toggle_description: Temporarily fall back to the old proctored exam settings view.
# .. toggle_use_cases: temporary
# .. toggle_creation_date: 2025-03-14
# .. toggle_target_removal_date: 2025-09-14
# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275
# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available.
-LEGACY_STUDIO_TEXT_EDITOR = CourseWaffleFlag("legacy_studio.text_editor", __name__)
+LEGACY_STUDIO_EXAM_SETTINGS = CourseWaffleFlag("legacy_studio.exam_settings", __name__)
-def use_new_text_editor(course_key):
+def exam_setting_view_enabled(course_key):
"""
- Returns a boolean = true if new text editor is enabled
+ Returns a boolean if proctoring exam setting mfe view is enabled.
"""
- return not LEGACY_STUDIO_TEXT_EDITOR.is_enabled(course_key)
+ return not LEGACY_STUDIO_EXAM_SETTINGS.is_enabled(course_key)
# .. toggle_name: legacy_studio.video_editor
@@ -181,25 +185,6 @@ def individualize_anonymous_user_id(course_id):
return INDIVIDUALIZE_ANONYMOUS_USER_ID.is_enabled(course_id)
-# .. toggle_name: legacy_studio.home
-# .. toggle_implementation: WaffleFlag
-# .. toggle_default: False
-# .. toggle_description: Temporarily fall back to the old Studio logged-in landing page.
-# .. toggle_use_cases: temporary
-# .. toggle_creation_date: 2025-03-14
-# .. toggle_target_removal_date: 2025-09-14
-# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275
-# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available.
-LEGACY_STUDIO_HOME = WaffleFlag('legacy_studio.home', __name__)
-
-
-def use_new_home_page():
- """
- Returns a boolean if new studio home page mfe is enabled
- """
- return not LEGACY_STUDIO_HOME.is_enabled()
-
-
# .. toggle_name: legacy_studio.custom_pages
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
@@ -351,25 +336,6 @@ def use_new_export_page(course_key):
return not LEGACY_STUDIO_EXPORT.is_enabled(course_key)
-# .. toggle_name: legacy_studio.files_uploads
-# .. toggle_implementation: WaffleFlag
-# .. toggle_default: False
-# .. toggle_description: Temporarily fall back to the old Studio Files & Uploads page.
-# .. toggle_use_cases: temporary
-# .. toggle_creation_date: 2025-03-14
-# .. toggle_target_removal_date: 2025-09-14
-# .. toggle_tickets: https://github.com/openedx/edx-platform/issues/36275
-# .. toggle_warning: In Ulmo, this toggle will be removed. Only the new (React-based) experience will be available.
-LEGACY_STUDIO_FILES_UPLOADS = CourseWaffleFlag('legacy_studio.files_uploads', __name__)
-
-
-def use_new_files_uploads_page(course_key):
- """
- Returns a boolean if new studio files and uploads mfe is enabled
- """
- return not LEGACY_STUDIO_FILES_UPLOADS.is_enabled(course_key)
-
-
# .. toggle_name: contentstore.new_studio_mfe.use_new_video_uploads_page
# .. toggle_implementation: CourseWaffleFlag
# .. toggle_default: False
@@ -402,13 +368,6 @@ def use_new_video_uploads_page(course_key):
LEGACY_STUDIO_COURSE_OUTLINE = CourseWaffleFlag('legacy_studio.course_outline', __name__)
-def use_new_course_outline_page(course_key):
- """
- Returns a boolean if new studio course outline mfe is enabled
- """
- return not LEGACY_STUDIO_COURSE_OUTLINE.is_enabled(course_key)
-
-
# .. toggle_name: legacy_studio.unit_editor
# .. toggle_implementation: WaffleFlag
# .. toggle_default: False
@@ -612,6 +571,7 @@ def libraries_v2_enabled():
Requires the ENABLE_CONTENT_LIBRARIES feature flag to be enabled, plus Meilisearch.
"""
+ from openedx.core.djangoapps.content.search import api as search_api # pylint: disable=import-outside-toplevel
return (
ENABLE_CONTENT_LIBRARIES.is_enabled() and
search_api.is_meilisearch_enabled() and
@@ -682,3 +642,45 @@ def enable_course_optimizer_check_prev_run_links(course_key):
Returns a boolean if previous run course optimizer feature is enabled for the given course.
"""
return ENABLE_COURSE_OPTIMIZER_CHECK_PREV_RUN_LINKS.is_enabled(course_key)
+
+
+# .. toggle_name: contentstore.enable_unit_expanded_view
+# .. toggle_implementation: CourseWaffleFlag
+# .. toggle_default: False
+# .. toggle_description: When enabled, the Unit Expanded View feature in the Course Outline is activated.
+# .. toggle_use_cases: temporary
+# .. toggle_creation_date: 2026-01-01
+# .. toggle_target_removal_date: 2026-06-01
+# .. toggle_tickets: TNL2-473
+ENABLE_UNIT_EXPANDED_VIEW = CourseWaffleFlag(
+ f"{CONTENTSTORE_NAMESPACE}.enable_unit_expanded_view", __name__
+)
+
+
+def enable_unit_expanded_view(course_key):
+ """
+ Returns a boolean if the Unit Expanded View feature is enabled for the given course.
+ """
+ return ENABLE_UNIT_EXPANDED_VIEW.is_enabled(course_key)
+
+
+# .. toggle_name: contentstore.enable_outline_component_creation
+# .. toggle_implementation: CourseWaffleFlag
+# .. toggle_default: False
+# .. toggle_description: When enabled, the Add Component widget is displayed inside each
+# .. expanded unit on the Course Outline page, allowing authors to add new XBlocks
+# .. directly without navigating to the unit page.
+# .. toggle_use_cases: temporary
+# .. toggle_creation_date: 2026-03-24
+# .. toggle_target_removal_date: 2026-09-24
+# .. toggle_tickets: TNL2-533
+ENABLE_OUTLINE_COMPONENT_CREATION = CourseWaffleFlag(
+ f"{CONTENTSTORE_NAMESPACE}.enable_outline_component_creation", __name__
+)
+
+
+def enable_outline_component_creation(course_key):
+ """
+ Returns a boolean if the Add Component in Outline feature is enabled for the given course.
+ """
+ return ENABLE_OUTLINE_COMPONENT_CREATION.is_enabled(course_key)
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index 3ca1d20bf564..5506d8c33e41 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -15,7 +15,7 @@
from bs4 import BeautifulSoup
from django.conf import settings
-from django.core.exceptions import ObjectDoesNotExist, ValidationError
+from django.core.exceptions import ImproperlyConfigured, ObjectDoesNotExist, ValidationError
from django.urls import reverse
from django.utils import translation
from django.utils.text import Truncator
@@ -43,26 +43,22 @@
split_library_view_on_dashboard,
use_new_advanced_settings_page,
use_new_certificates_page,
- use_new_course_outline_page,
use_new_course_team_page,
use_new_custom_pages,
use_new_export_page,
- use_new_files_uploads_page,
use_new_grading_page,
use_new_group_configurations_page,
- use_new_home_page,
use_new_import_page,
use_new_schedule_details_page,
- use_new_text_editor,
use_new_textbooks_page,
use_new_unit_page,
use_new_updates_page,
- use_new_video_editor,
use_new_video_uploads_page,
)
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
-from cms.djangoapps.modulestore_migrator.api import get_migration_info
+from cms.djangoapps.modulestore_migrator import api as migrator_api
+from cms.djangoapps.modulestore_migrator.data import ModulestoreMigration
from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError
from common.djangoapps.course_action_state.models import CourseRerunState, CourseRerunUIStateManager
from common.djangoapps.course_modes.models import CourseMode
@@ -117,6 +113,7 @@
get_all_partitions_for_course, # lint-amnesty, pylint: disable=wrong-import-order
)
from xmodule.services import ConfigurationService, SettingsService, TeamsConfigurationService
+from xmodule.util.keys import BlockKey
from .models import ComponentLink, ContainerLink
@@ -288,11 +285,10 @@ def get_editor_page_base_url(course_locator) -> str:
Gets course authoring microfrontend URL for links to the new base editors
"""
editor_url = None
- if use_new_text_editor(course_locator) or use_new_video_editor(course_locator):
- mfe_base_url = get_course_authoring_url(course_locator)
- course_mfe_url = f'{mfe_base_url}/course/{course_locator}/editor'
- if mfe_base_url:
- editor_url = course_mfe_url
+ mfe_base_url = get_course_authoring_url(course_locator)
+ course_mfe_url = f'{mfe_base_url}/course/{course_locator}/editor'
+ if mfe_base_url:
+ editor_url = course_mfe_url
return editor_url
@@ -300,12 +296,15 @@ def get_studio_home_url():
"""
Gets course authoring microfrontend URL for Studio Home view.
"""
- studio_home_url = None
- if use_new_home_page():
- mfe_base_url = settings.COURSE_AUTHORING_MICROFRONTEND_URL
- if mfe_base_url:
- studio_home_url = f'{mfe_base_url}/home'
- return studio_home_url
+ mfe_base_url = settings.COURSE_AUTHORING_MICROFRONTEND_URL
+ if mfe_base_url:
+ studio_home_url = f'{mfe_base_url}/home'
+ return studio_home_url
+
+ raise ImproperlyConfigured(
+ "The COURSE_AUTHORING_MICROFRONTEND_URL must be configured. "
+ "Please set it to the base url for your authoring MFE."
+ )
def get_schedule_details_url(course_locator) -> str:
@@ -417,11 +416,10 @@ def get_files_uploads_url(course_locator) -> str:
Gets course authoring microfrontend URL for files and uploads page view.
"""
files_uploads_url = None
- if use_new_files_uploads_page(course_locator):
- mfe_base_url = get_course_authoring_url(course_locator)
- course_mfe_url = f'{mfe_base_url}/course/{course_locator}/assets'
- if mfe_base_url:
- files_uploads_url = course_mfe_url
+ mfe_base_url = get_course_authoring_url(course_locator)
+ course_mfe_url = f'{mfe_base_url}/course/{course_locator}/assets'
+ if mfe_base_url:
+ files_uploads_url = course_mfe_url
return files_uploads_url
@@ -443,13 +441,12 @@ def get_course_outline_url(course_locator, block_to_show=None) -> str:
Gets course authoring microfrontend URL for course oultine page view.
"""
course_outline_url = None
- if use_new_course_outline_page(course_locator):
- mfe_base_url = get_course_authoring_url(course_locator)
- course_mfe_url = f'{mfe_base_url}/course/{course_locator}'
- if block_to_show:
- course_mfe_url += f'?show={quote_plus(block_to_show)}'
- if mfe_base_url:
- course_outline_url = course_mfe_url
+ mfe_base_url = get_course_authoring_url(course_locator)
+ course_mfe_url = f'{mfe_base_url}/course/{course_locator}'
+ if block_to_show:
+ course_mfe_url += f'?show={quote_plus(block_to_show)}'
+ if mfe_base_url:
+ course_outline_url = course_mfe_url
return course_outline_url
@@ -1582,13 +1579,12 @@ def request_response_format_is_json(request, response_format):
def get_library_context(request, request_is_json=False):
"""
- Utils is used to get context of course home library tab.
- It is used for both DRF and django views.
+ Utils is used to get context of course home library tab. Returned in DRF view.
"""
from cms.djangoapps.contentstore.views.course import (
_accessible_libraries_iter,
- _format_library_for_view,
_get_course_creator_status,
+ format_library_for_view,
get_allowed_organizations,
get_allowed_organizations_for_libraries,
user_can_create_organizations,
@@ -1600,21 +1596,25 @@ def get_library_context(request, request_is_json=False):
user_can_create_library,
)
+ is_migrated: bool | None # None means: do not filter on is_migrated
+ if (is_migrated_param := request.GET.get('is_migrated')) is not None:
+ is_migrated = BooleanField().to_internal_value(is_migrated_param)
+ else:
+ is_migrated = None
libraries = list(_accessible_libraries_iter(request.user) if libraries_v1_enabled() else [])
- library_keys = [lib.location.library_key for lib in libraries]
- migration_info = get_migration_info(library_keys)
- is_migrated_filter = request.GET.get('is_migrated', None)
+ migration_info: dict[LibraryLocator, ModulestoreMigration | None] = {
+ lib.id: migrator_api.get_forwarding(lib.id)
+ for lib in libraries
+ }
data = {
'libraries': [
- _format_library_for_view(
+ format_library_for_view(
lib,
request,
- migrated_to=migration_info.get(lib.location.library_key)
+ migration=migration_info[lib.id],
)
for lib in libraries
- if is_migrated_filter is None or (
- BooleanField().to_internal_value(is_migrated_filter) == (lib.location.library_key in migration_info)
- )
+ if is_migrated is None or is_migrated == bool(migration_info[lib.id])
]
}
@@ -1723,8 +1723,7 @@ def format_in_process_course_view(uca):
def get_home_context(request, no_course=False):
"""
- Utils is used to get context of course home.
- It is used for both DRF and django views.
+ Utils is used to get context of course home. Returned by DRF view.
"""
from cms.djangoapps.contentstore.views.course import (
@@ -2411,10 +2410,11 @@ def _create_or_update_component_link(created: datetime | None, xblock):
top_level_parent_usage_key = None
if xblock.top_level_downstream_parent_key is not None:
+ block_key = BlockKey.from_string(xblock.top_level_downstream_parent_key)
top_level_parent_usage_key = BlockUsageLocator(
xblock.usage_key.course_key,
- xblock.top_level_downstream_parent_key.get('type'),
- xblock.top_level_downstream_parent_key.get('id'),
+ block_key.type,
+ block_key.id,
)
ComponentLink.update_or_create(
@@ -2426,7 +2426,7 @@ def _create_or_update_component_link(created: datetime | None, xblock):
top_level_parent_usage_key=top_level_parent_usage_key,
version_synced=xblock.upstream_version,
version_declined=xblock.upstream_version_declined,
- downstream_is_modified=len(getattr(xblock, "downstream_customized", [])) > 0,
+ downstream_customized=getattr(xblock, "downstream_customized", []),
created=created,
)
@@ -2444,10 +2444,11 @@ def _create_or_update_container_link(created: datetime | None, xblock):
top_level_parent_usage_key = None
if xblock.top_level_downstream_parent_key is not None:
+ block_key = BlockKey.from_string(xblock.top_level_downstream_parent_key)
top_level_parent_usage_key = BlockUsageLocator(
xblock.usage_key.course_key,
- xblock.top_level_downstream_parent_key.get('type'),
- xblock.top_level_downstream_parent_key.get('id'),
+ block_key.type,
+ block_key.id,
)
ContainerLink.update_or_create(
@@ -2459,7 +2460,7 @@ def _create_or_update_container_link(created: datetime | None, xblock):
version_synced=xblock.upstream_version,
top_level_parent_usage_key=top_level_parent_usage_key,
version_declined=xblock.upstream_version_declined,
- downstream_is_modified=len(getattr(xblock, "downstream_customized", [])) > 0,
+ downstream_customized=getattr(xblock, "downstream_customized", []),
created=created,
)
diff --git a/cms/djangoapps/contentstore/video_storage_handlers.py b/cms/djangoapps/contentstore/video_storage_handlers.py
index 87086c9951ac..b80fbdd80ce7 100644
--- a/cms/djangoapps/contentstore/video_storage_handlers.py
+++ b/cms/djangoapps/contentstore/video_storage_handlers.py
@@ -13,12 +13,11 @@
import shutil
import pathlib
import zipfile
-
from contextlib import closing
from datetime import datetime, timedelta
from uuid import uuid4
-from boto.s3.connection import S3Connection
-from boto import s3
+
+import boto3
from django.conf import settings
from django.contrib.staticfiles.storage import staticfiles_storage
from django.http import FileResponse, HttpResponseNotFound, StreamingHttpResponse
@@ -33,8 +32,8 @@
create_video,
get_3rd_party_transcription_plans,
get_available_transcript_languages,
- get_video_transcript_url,
get_transcript_preferences,
+ get_video_transcript_url,
get_videos_for_course,
remove_transcript_preferences,
remove_video_for_course,
@@ -55,10 +54,7 @@
from common.djangoapps.util.json_request import JsonResponse
from openedx.core.djangoapps.video_config.models import VideoTranscriptEnabledFlag
from openedx.core.djangoapps.video_config.toggles import PUBLIC_VIDEO_SHARE
-from openedx.core.djangoapps.video_pipeline.config.waffle import (
- DEPRECATE_YOUTUBE,
- ENABLE_DEVSTACK_VIDEO_UPLOADS,
-)
+from openedx.core.djangoapps.video_pipeline.config.waffle import DEPRECATE_YOUTUBE
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
@@ -812,7 +808,8 @@ def videos_post(course, request):
if error:
return {'error': error}, 400
- bucket = storage_service_bucket()
+ s3_client = boto3.client('s3')
+
req_files = data['files']
resp_files = []
@@ -826,7 +823,6 @@ def videos_post(course, request):
return {'error': error_msg}, 400
edx_video_id = str(uuid4())
- key = storage_service_key(bucket, file_name=edx_video_id)
metadata_list = [
('client_video_id', file_name),
@@ -846,12 +842,15 @@ def videos_post(course, request):
if transcript_preferences is not None:
metadata_list.append(('transcript_preferences', json.dumps(transcript_preferences)))
- for metadata_name, value in metadata_list:
- key.set_metadata(metadata_name, value)
- upload_url = key.generate_url(
- KEY_EXPIRATION_IN_SECONDS,
- 'PUT',
- headers={'Content-Type': req_file['content_type']}
+ upload_url = s3_client.generate_presigned_url(
+ ClientMethod='put_object',
+ Params={
+ 'Bucket': storage_service_bucket_name(),
+ 'Key': storage_service_key_name(edx_video_id),
+ 'ContentType': req_file['content_type'],
+ 'Metadata': dict(metadata_list),
+ },
+ ExpiresIn=KEY_EXPIRATION_IN_SECONDS,
)
# persist edx_video_id in VAL
@@ -869,41 +868,21 @@ def videos_post(course, request):
return {'files': resp_files}, 200
-def storage_service_bucket():
+def storage_service_bucket_name():
"""
- Returns an S3 bucket for video upload.
+ Returns name of S3 bucket to use for video upload.
"""
- if ENABLE_DEVSTACK_VIDEO_UPLOADS.is_enabled():
- params = {
- 'aws_access_key_id': settings.AWS_ACCESS_KEY_ID,
- 'aws_secret_access_key': settings.AWS_SECRET_ACCESS_KEY,
- 'security_token': settings.AWS_SECURITY_TOKEN
-
- }
- else:
- params = {
- 'aws_access_key_id': settings.AWS_ACCESS_KEY_ID,
- 'aws_secret_access_key': settings.AWS_SECRET_ACCESS_KEY
- }
-
- conn = S3Connection(**params)
-
- # We don't need to validate our bucket, it requires a very permissive IAM permission
- # set since behind the scenes it fires a HEAD request that is equivalent to get_all_keys()
- # meaning it would need ListObjects on the whole bucket, not just the path used in each
- # environment (since we share a single bucket for multiple deployments in some configurations)
- return conn.get_bucket(settings.VIDEO_UPLOAD_PIPELINE['VEM_S3_BUCKET'], validate=False)
+ return settings.VIDEO_UPLOAD_PIPELINE['VEM_S3_BUCKET']
-def storage_service_key(bucket, file_name):
+def storage_service_key_name(file_name):
"""
- Returns an S3 key to the given file in the given bucket.
+ Returns the S3 object key to be used for a given video filename.
"""
- key_name = "{}/{}".format(
+ return "{}/{}".format(
settings.VIDEO_UPLOAD_PIPELINE.get("ROOT_PATH", ""),
file_name
)
- return s3.key.Key(bucket, key_name)
def send_video_status_update(updates):
diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py
index b57042085df2..238627bc6618 100644
--- a/cms/djangoapps/contentstore/views/block.py
+++ b/cms/djangoapps/contentstore/views/block.py
@@ -1,8 +1,10 @@
"""Views for blocks."""
import logging
+import re
from collections import OrderedDict
from functools import partial
+from urllib.parse import urlparse
from django.contrib.auth.decorators import login_required
from django.core.exceptions import PermissionDenied
@@ -305,6 +307,43 @@ def xblock_view_handler(request, usage_key_string, view_name):
return HttpResponse(status=406)
+def _get_safe_return_to(request):
+ """
+ Read and validate the ``returnTo`` query parameter for the XBlock edit view.
+
+ Returns the parameter value if it is a safe same-origin URL (i.e. an
+ absolute-path reference that starts with ``/`` but not ``//``), or ``None``
+ if the parameter is absent or fails validation. This prevents open-redirect
+ attacks via protocol-relative URLs such as ``//evil.com/path``.
+ """
+ return_to = request.GET.get('returnTo', '').strip()
+ if not return_to:
+ return None
+
+ if re.search(r'[\x00-\x1f\x7f]', return_to):
+ return None
+
+ if len(return_to) > 2048:
+ return None
+
+ parsed = urlparse(return_to)
+ if parsed.scheme or parsed.netloc:
+ request_origin = '{scheme}://{host}'.format(
+ scheme=request.scheme,
+ host=request.get_host(),
+ )
+ url_origin = '{scheme}://{host}'.format(
+ scheme=parsed.scheme,
+ host=parsed.netloc,
+ )
+ if request_origin != url_origin:
+ return None
+ elif not return_to.startswith('/') or return_to.startswith('//'):
+ return None
+
+ return return_to
+
+
@xframe_options_exempt
@require_http_methods(["GET"])
@login_required
@@ -313,6 +352,10 @@ def xblock_edit_view(request, usage_key_string):
Return rendered xblock edit view.
Allows editing of an XBlock specified by the usage key.
+
+ Supports an optional ``returnTo`` query parameter. When present and
+ pointing to a same-origin URL, the editor will redirect the browser to
+ that URL after the user saves or cancels instead of leaving the page blank.
"""
usage_key = usage_key_with_run(usage_key_string)
if not has_studio_read_access(request.user, usage_key.course_key):
@@ -333,6 +376,7 @@ def xblock_edit_view(request, usage_key_string):
container_handler_context.update({
"action_name": "edit",
"resources": list(hashed_resources.items()),
+ "return_to": _get_safe_return_to(request),
})
return render_to_response('container_editor.html', container_handler_context)
diff --git a/cms/djangoapps/contentstore/views/certificate_manager.py b/cms/djangoapps/contentstore/views/certificate_manager.py
index 429950477fdd..081afdcc0dd7 100644
--- a/cms/djangoapps/contentstore/views/certificate_manager.py
+++ b/cms/djangoapps/contentstore/views/certificate_manager.py
@@ -121,7 +121,7 @@ def is_activated(course):
along with the certificates.
"""
is_active = False
- certificates = None
+ certificates = []
if settings.FEATURES.get('CERTIFICATES_HTML_VIEW', False):
certificates = CertificateManager.get_certificates(course)
# we are assuming only one certificate in certificates collection.
diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py
index 34c1f465c566..ea0b5876135f 100644
--- a/cms/djangoapps/contentstore/views/component.py
+++ b/cms/djangoapps/contentstore/views/component.py
@@ -43,6 +43,12 @@
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
+try:
+ from games.toggles import is_games_xblock_enabled # pylint: disable=import-error
+except ImportError:
+ def is_games_xblock_enabled():
+ return False
+
__all__ = [
'container_handler',
'component_handler',
@@ -83,13 +89,29 @@
]
DEFAULT_ADVANCED_MODULES = [
+ 'annotatable',
+ 'done',
+ 'split_test',
+ 'freetextresponse',
'google-calendar',
'google-document',
+ 'imagemodal',
+ 'h5pxblock',
+ 'invideoquiz',
'lti_consumer',
+ 'oppia',
+ 'ubcpi',
'poll',
- 'split_test',
+ 'qualtricssurvey',
+ 'scorm',
+ 'edx_sga',
+ 'submit-and-compare',
'survey',
'word_cloud',
+ 'recommender',
+ 'library_content',
+ 'schoolyourself_lesson',
+ 'schoolyourself_review',
]
@@ -295,6 +317,11 @@ def create_support_legend_dict():
# by the components in the order listed in COMPONENT_TYPES.
component_types = COMPONENT_TYPES[:]
+ # Add games xblock if enabled (checked at request time)
+ if is_games_xblock_enabled():
+ component_types.append('games')
+ component_display_names['games'] = _("Games")
+
# Libraries do not support discussions, drag-and-drop, and openassessment and other libraries
component_not_supported_by_library = [
'discussion',
@@ -439,12 +466,16 @@ def create_support_legend_dict():
)
categories.add(component)
+ beta_types = BETA_COMPONENT_TYPES[:]
+ if is_games_xblock_enabled() and category == 'games':
+ beta_types.append('games')
+
component_templates.append({
"type": category,
"templates": templates_for_category,
"display_name": component_display_names[category],
"support_legend": create_support_legend_dict(),
- "beta": category in BETA_COMPONENT_TYPES,
+ "beta": category in beta_types,
})
# Libraries do not support advanced components at this time.
diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py
index fa8769dc0cb9..681ea5f9fe9b 100644
--- a/cms/djangoapps/contentstore/views/course.py
+++ b/cms/djangoapps/contentstore/views/course.py
@@ -7,14 +7,19 @@
import random
import re
import string
-from typing import Dict, NamedTuple, Optional
+from typing import Dict
import django.utils
from ccx_keys.locator import CCXLocator
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.auth.decorators import login_required
-from django.core.exceptions import FieldError, PermissionDenied, ValidationError as DjangoValidationError
+from django.core.exceptions import (
+ FieldError,
+ ImproperlyConfigured,
+ PermissionDenied,
+ ValidationError as DjangoValidationError,
+)
from django.db.models import QuerySet
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
from django.shortcuts import redirect
@@ -39,6 +44,7 @@
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
from cms.djangoapps.models.settings.encoder import CourseSettingsEncoder
+from cms.djangoapps.modulestore_migrator.data import ModulestoreMigration
from cms.djangoapps.contentstore.api.views.utils import get_bool_param
from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError
from common.djangoapps.course_action_state.models import CourseRerunState, CourseRerunUIStateManager
@@ -56,6 +62,7 @@
GlobalStaff,
UserBasedRole,
OrgStaffRole,
+ strict_role_checking,
)
from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json
from common.djangoapps.util.string_utils import _has_non_ascii_characters
@@ -85,8 +92,6 @@
from ..tasks import rerun_course as rerun_course_task
from ..toggles import (
default_enable_flexible_peer_openassessments,
- use_new_course_outline_page,
- use_new_home_page,
use_new_updates_page,
use_new_advanced_settings_page,
use_new_grading_page,
@@ -98,15 +103,12 @@
add_instructor,
get_advanced_settings_url,
get_course_grading,
- get_course_index_context,
get_course_outline_url,
get_course_rerun_context,
get_course_settings,
get_grading_url,
get_group_configurations_context,
get_group_configurations_url,
- get_home_context,
- get_library_context,
get_lms_link_for_item,
get_proctored_exam_settings_url,
get_schedule_details_url,
@@ -536,7 +538,9 @@ def filter_ccx(course_access):
return not isinstance(course_access.course_id, CCXLocator)
instructor_courses = UserBasedRole(request.user, CourseInstructorRole.ROLE).courses_with_role()
- staff_courses = UserBasedRole(request.user, CourseStaffRole.ROLE).courses_with_role()
+ with strict_role_checking():
+ staff_courses = UserBasedRole(request.user, CourseStaffRole.ROLE).courses_with_role()
+
all_courses = list(filter(filter_ccx, instructor_courses | staff_courses))
courses_list = []
course_keys = {}
@@ -652,11 +656,7 @@ def course_listing(request):
"""
List all courses and libraries available to the logged in user
"""
- if use_new_home_page():
- return redirect(get_studio_home_url())
-
- home_context = get_home_context(request)
- return render_to_response('index.html', home_context)
+ return redirect(get_studio_home_url())
@login_required
@@ -665,15 +665,28 @@ def library_listing(request):
"""
List all Libraries available to the logged in user
"""
- data = get_library_context(request)
- return render_to_response('index.html', data)
+ mfe_base_url = settings.COURSE_AUTHORING_MICROFRONTEND_URL
+ if mfe_base_url:
+ return redirect(f'{mfe_base_url}/libraries')
+
+ raise ImproperlyConfigured(
+ "The COURSE_AUTHORING_MICROFRONTEND_URL must be configured. "
+ "Please set it to the base url for your authoring MFE."
+ )
-def _format_library_for_view(library, request, migrated_to: Optional[NamedTuple]):
+def format_library_for_view(library, request, migration: ModulestoreMigration | None):
"""
Return a dict of the data which the view requires for each library
"""
-
+ migration_info = {}
+ if migration:
+ migration_info = {
+ 'migrated_to_key': migration.target_key,
+ 'migrated_to_title': migration.target_title,
+ 'migrated_to_collection_key': migration.target_collection_slug,
+ 'migrated_to_collection_title': migration.target_collection_title,
+ }
return {
'display_name': library.display_name,
'library_key': str(library.location.library_key),
@@ -681,7 +694,8 @@ def _format_library_for_view(library, request, migrated_to: Optional[NamedTuple]
'org': library.display_org_with_default,
'number': library.display_number_with_default,
'can_edit': has_studio_write_access(request.user, library.location.library_key),
- **(migrated_to._asdict() if migrated_to is not None else {}),
+ 'is_migrated': migration is not None,
+ **migration_info,
}
@@ -736,18 +750,8 @@ def course_index(request, course_key):
org, course, name: Attributes of the Location for the item to edit
"""
- if use_new_course_outline_page(course_key):
- block_to_show = request.GET.get("show")
- return redirect(get_course_outline_url(course_key, block_to_show))
- with modulestore().bulk_operations(course_key):
- # A depth of None implies the whole course. The course outline needs this in order to compute has_changes.
- # A unit may not have a draft version, but one of its components could, and hence the unit itself has changes.
- course_block = get_course_and_check_access(course_key, request.user, depth=None)
- if not course_block:
- raise Http404
- # should be under bulk_operations if course_block is passed
- course_index_context = get_course_index_context(request, course_key, course_block)
- return render_to_response('course_outline.html', course_index_context)
+ block_to_show = request.GET.get("show")
+ return redirect(get_course_outline_url(course_key, block_to_show))
@function_trace('get_courses_accessible_to_user')
@@ -1848,12 +1852,20 @@ def get_allowed_organizations_for_libraries(user):
"""
Helper method for returning the list of organizations for which the user is allowed to create libraries.
"""
+ organizations_set = set()
+
+ # This allows org-level staff to create libraries. We should re-evaluate
+ # whether this is necessary and try to normalize course and library creation
+ # authorization behavior.
if settings.FEATURES.get('ENABLE_ORGANIZATION_STAFF_ACCESS_FOR_CONTENT_LIBRARIES', False):
- return get_organizations_for_non_course_creators(user)
- elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False):
- return get_organizations(user)
- else:
- return []
+ organizations_set.update(get_organizations_for_non_course_creators(user))
+
+ # This allows people in the course creator group for an org to create
+ # libraries, which mimics course behavior.
+ if settings.FEATURES.get('ENABLE_CREATOR_GROUP', False):
+ organizations_set.update(get_organizations(user))
+
+ return sorted(organizations_set)
def user_can_create_organizations(user):
diff --git a/cms/djangoapps/contentstore/views/tests/test_assets.py b/cms/djangoapps/contentstore/views/tests/test_assets.py
index 2b13338c0dc7..e7dbcfe9f55a 100644
--- a/cms/djangoapps/contentstore/views/tests/test_assets.py
+++ b/cms/djangoapps/contentstore/views/tests/test_assets.py
@@ -12,13 +12,11 @@
from ddt import data, ddt
from django.conf import settings
from django.test.utils import override_settings
-from edx_toggles.toggles.testutils import override_waffle_flag
from opaque_keys.edx.keys import AssetKey
from opaque_keys.edx.locator import CourseLocator
from PIL import Image
from pytz import UTC
-from cms.djangoapps.contentstore import toggles
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from cms.djangoapps.contentstore.utils import reverse_course_url
from cms.djangoapps.contentstore.views import assets
@@ -87,10 +85,9 @@ class BasicAssetsTestCase(AssetsTestCase):
Test getting assets via html w/o additional args
"""
- @override_waffle_flag(toggles.LEGACY_STUDIO_FILES_UPLOADS, True)
def test_basic(self):
resp = self.client.get(self.url, HTTP_ACCEPT='text/html')
- self.assertEqual(resp.status_code, 200)
+ self.assertEqual(resp.status_code, 302)
def test_static_url_generation(self):
diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py
index 01aff3d613c1..b17c71668a78 100644
--- a/cms/djangoapps/contentstore/views/tests/test_block.py
+++ b/cms/djangoapps/contentstore/views/tests/test_block.py
@@ -76,7 +76,8 @@
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration
from openedx.core.djangoapps.content_tagging import api as tagging_api
-from ..component import component_handler, DEFAULT_ADVANCED_MODULES, get_component_templates
+from ..block import _get_safe_return_to
+from ..component import component_handler, get_component_templates
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import (
ALWAYS,
VisibilityState,
@@ -86,6 +87,7 @@
add_container_page_publishing_info,
create_xblock_info,
)
+from common.test.utils import assert_dict_contains_subset
class AsideTest(XBlockAside):
@@ -863,7 +865,8 @@ def test_duplicate_event(self):
XBLOCK_DUPLICATED.connect(event_receiver)
usage_key = self._duplicate_and_verify(self.vert_usage_key, self.seq_usage_key)
event_receiver.assert_called()
- self.assertDictContainsSubset(
+ assert_dict_contains_subset(
+ self,
{
"signal": XBLOCK_DUPLICATED,
"sender": None,
@@ -1853,7 +1856,7 @@ def setUp(self):
@XBlockAside.register_temp_plugin(AsideTest, "test_aside")
@patch(
- "xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types",
+ "xmodule.modulestore.split_mongo.runtime.SplitModuleStoreRuntime.applicable_aside_types",
lambda self, block: ["test_aside"],
)
def test_duplicate_equality_with_asides(self):
@@ -2698,8 +2701,8 @@ def test_add_groups(self):
group_id_to_child = split_test.group_id_to_child.copy()
self.assertEqual(2, len(group_id_to_child))
- # CachingDescriptorSystem is used in tests.
- # CachingDescriptorSystem doesn't have user service, that's needed for
+ # SplitModuleStoreRuntime is used in tests.
+ # SplitModuleStoreRuntime doesn't have user service, that's needed for
# SplitTestBlock. So, in this line of code we add this service manually.
split_test.runtime._services["user"] = DjangoXBlockUserService( # pylint: disable=protected-access
self.user
@@ -2974,10 +2977,23 @@ def test_basic_components(self):
self.assertGreater(len(self.get_templates_of_type("html")), 0)
self.assertGreater(len(self.get_templates_of_type("problem")), 0)
- # Check for default advanced modules
+ # Check for default advanced modules - only the ones available in test environment
advanced_templates = self.get_templates_of_type("advanced")
advanced_module_keys = [t['category'] for t in advanced_templates]
- self.assertCountEqual(advanced_module_keys, DEFAULT_ADVANCED_MODULES)
+ expected_advanced_modules = [
+ 'annotatable',
+ 'done',
+ 'google-calendar',
+ 'google-document',
+ 'lti_consumer',
+ 'poll',
+ 'split_test',
+ 'survey',
+ 'word_cloud',
+ 'recommender',
+ 'edx_sga',
+ ]
+ self.assertCountEqual(advanced_module_keys, expected_advanced_modules)
# Now fully disable video through XBlockConfiguration
XBlockConfiguration.objects.create(name="video", enabled=False)
@@ -3025,16 +3041,6 @@ def test_advanced_components(self):
"""
Test the handling of advanced component templates.
"""
- self.course.advanced_modules.append("done")
- EXPECTED_ADVANCED_MODULES_LENGTH = len(DEFAULT_ADVANCED_MODULES) + 1
- self.templates = get_component_templates(self.course)
- advanced_templates = self.get_templates_of_type("advanced")
- self.assertEqual(len(advanced_templates), EXPECTED_ADVANCED_MODULES_LENGTH)
- done_template = advanced_templates[0]
- self.assertEqual(done_template.get("category"), "done")
- self.assertEqual(done_template.get("display_name"), "Completion")
- self.assertIsNone(done_template.get("boilerplate_name", None))
-
# Verify that components are not added twice
self.course.advanced_modules.append("video")
self.course.advanced_modules.append("drag-and-drop-v2")
@@ -3045,7 +3051,6 @@ def test_advanced_components(self):
self.templates = get_component_templates(self.course)
advanced_templates = self.get_templates_of_type("advanced")
- self.assertEqual(len(advanced_templates), EXPECTED_ADVANCED_MODULES_LENGTH)
only_template = advanced_templates[0]
self.assertNotEqual(only_template.get("category"), "video")
self.assertNotEqual(only_template.get("category"), "drag-and-drop-v2")
@@ -3118,8 +3123,13 @@ def test_create_support_level_flag_off(self):
"""
XBlockStudioConfigurationFlag.objects.create(enabled=False)
self.course.advanced_modules.extend(["annotatable", "done"])
- expected_xblocks = ["Annotation", "Completion"] + self.default_advanced_modules_titles
- self._verify_advanced_xblocks(expected_xblocks, [True] * len(expected_xblocks))
+ # Get actual templates to determine count dynamically
+ templates = get_component_templates(self.course)
+ advanced_templates = templates[-1]["templates"]
+ expected_count = len(advanced_templates)
+ # Verify all advanced templates have support_level=True
+ for template in advanced_templates:
+ self.assertTrue(template["support_level"])
def test_xblock_masquerading_as_problem(self):
"""
@@ -4447,7 +4457,7 @@ def test_self_paced_item_visibility_state(self):
@patch(
- "xmodule.modulestore.split_mongo.caching_descriptor_system.CachingDescriptorSystem.applicable_aside_types",
+ "xmodule.modulestore.split_mongo.runtime.SplitModuleStoreRuntime.applicable_aside_types",
lambda self, block: ["test_aside"],
)
class TestUpdateFromSource(ModuleStoreTestCase):
@@ -4628,3 +4638,93 @@ def test_xblock_edit_view_contains_resources(self):
self.assertGreater(len(resource_links), 0, f"No CSS resources found in HTML. Found: {resource_links}")
self.assertGreater(len(script_sources), 0, f"No JS resources found in HTML. Found: {script_sources}")
+
+
+class TestGetSafeReturnTo(TestCase):
+ """
+ Tests for _get_safe_return_to validation.
+ """
+
+ def setUp(self):
+ super().setUp()
+ self.factory = RequestFactory()
+
+ def _make_request(self, return_to=None):
+ """Build a GET request with an optional returnTo query parameter."""
+ url = '/dummy'
+ if return_to is not None:
+ url = f'/dummy?returnTo={return_to}'
+ return self.factory.get(url)
+
+ # -- valid inputs --------------------------------------------------------
+
+ def test_valid_relative_path(self):
+ request = self._make_request('/course/123')
+ self.assertEqual(_get_safe_return_to(request), '/course/123')
+
+ def test_valid_root_path(self):
+ request = self._make_request('/')
+ self.assertEqual(_get_safe_return_to(request), '/')
+
+ def test_valid_relative_path_with_query_string(self):
+ request = self.factory.get('/dummy', {'returnTo': '/course/123?tab=outline'})
+ self.assertEqual(_get_safe_return_to(request), '/course/123?tab=outline')
+
+ def test_valid_absolute_url_same_origin(self):
+ request = self.factory.get('/dummy', {'returnTo': 'http://testserver/course/123'})
+ self.assertEqual(_get_safe_return_to(request), 'http://testserver/course/123')
+
+ # -- empty / missing values ----------------------------------------------
+
+ def test_missing_parameter(self):
+ request = self.factory.get('/dummy')
+ self.assertIsNone(_get_safe_return_to(request))
+
+ def test_empty_string(self):
+ request = self._make_request('')
+ self.assertIsNone(_get_safe_return_to(request))
+
+ def test_whitespace_only(self):
+ request = self.factory.get('/dummy', {'returnTo': ' '})
+ self.assertIsNone(_get_safe_return_to(request))
+
+ # -- protocol-relative / different origin --------------------------------
+
+ def test_protocol_relative_url_rejected(self):
+ request = self._make_request('//evil.com/path')
+ self.assertIsNone(_get_safe_return_to(request))
+
+ def test_absolute_url_different_origin_rejected(self):
+ request = self.factory.get('/dummy', {'returnTo': 'https://evil.com/steal'})
+ self.assertIsNone(_get_safe_return_to(request))
+
+ def test_absolute_url_different_scheme_rejected(self):
+ request = self.factory.get('/dummy', {'returnTo': 'https://testserver/course'})
+ self.assertIsNone(_get_safe_return_to(request))
+
+ # -- relative paths that don't start with / ------------------------------
+
+ def test_bare_relative_path_rejected(self):
+ request = self._make_request('course/123')
+ self.assertIsNone(_get_safe_return_to(request))
+
+ # -- control characters --------------------------------------------------
+
+ def test_null_byte_rejected(self):
+ request = self.factory.get('/dummy', {'returnTo': '/course/\x00'})
+ self.assertIsNone(_get_safe_return_to(request))
+
+ def test_newline_rejected(self):
+ request = self.factory.get('/dummy', {'returnTo': '/course/\n/path'})
+ self.assertIsNone(_get_safe_return_to(request))
+
+ def test_tab_character_rejected(self):
+ request = self.factory.get('/dummy', {'returnTo': '/course/\t/path'})
+ self.assertIsNone(_get_safe_return_to(request))
+
+ # -- length limit --------------------------------------------------------
+
+ def test_url_over_2048_chars_rejected(self):
+ long_url = '/' + 'a' * 2048
+ request = self.factory.get('/dummy', {'returnTo': long_url})
+ self.assertIsNone(_get_safe_return_to(request))
diff --git a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py
index 3fae9d996fd2..2ca03ccf892b 100644
--- a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py
+++ b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py
@@ -16,7 +16,7 @@
from openedx_tagging.core.tagging.models import Tag
from organizations.models import Organization
from xmodule.modulestore.django import contentstore, modulestore
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course
+from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course, ImmediateOnCommitMixin
from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory, LibraryFactory
from cms.djangoapps.contentstore.utils import reverse_usage_url
@@ -400,7 +400,7 @@ def test_paste_with_assets(self):
assert source_pic2_hash != dest_pic2_hash # Because there was a conflict, this file was unchanged.
-class ClipboardPasteFromV2LibraryTestCase(OpenEdxEventsTestMixin, ModuleStoreTestCase):
+class ClipboardPasteFromV2LibraryTestCase(OpenEdxEventsTestMixin, ImmediateOnCommitMixin, ModuleStoreTestCase):
"""
Test Clipboard Paste functionality with a "new" (as of Sumac) library
"""
diff --git a/cms/djangoapps/contentstore/views/tests/test_course_index.py b/cms/djangoapps/contentstore/views/tests/test_course_index.py
index a22ce637fedd..58c425c601a3 100644
--- a/cms/djangoapps/contentstore/views/tests/test_course_index.py
+++ b/cms/djangoapps/contentstore/views/tests/test_course_index.py
@@ -8,496 +8,28 @@
from unittest import mock, skip
import ddt
-import lxml
import pytz
-from django.conf import settings
from django.core.exceptions import PermissionDenied
-from django.test.utils import override_settings
from django.utils.translation import gettext as _
-from edx_toggles.toggles.testutils import override_waffle_flag
-from opaque_keys.edx.locator import CourseLocator
from search.api import perform_search
-from cms.djangoapps.contentstore import toggles
from cms.djangoapps.contentstore.courseware_index import CoursewareSearchIndexer, SearchIndexingError
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from cms.djangoapps.contentstore.utils import (
- add_instructor,
- get_proctored_exam_settings_url,
reverse_course_url,
reverse_usage_url
)
-from common.djangoapps.course_action_state.managers import CourseRerunUIStateManager
-from common.djangoapps.course_action_state.models import CourseRerunState
-from common.djangoapps.student.auth import has_course_author_access
-from common.djangoapps.student.roles import CourseStaffRole, GlobalStaff, LibraryUserRole
from common.djangoapps.student.tests.factories import UserFactory
-from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE
-from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, LibraryFactory, check_mongo_calls # lint-amnesty, pylint: disable=wrong-import-order
+from xmodule.modulestore.tests.factories import BlockFactory, check_mongo_calls # lint-amnesty, pylint: disable=wrong-import-order
from ..course import _deprecated_blocks_info, course_outline_initial_state, reindex_course_and_check_access
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import VisibilityState, create_xblock_info
-@override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True)
-@override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True)
-class TestCourseIndex(CourseTestCase):
- """
- Unit tests for getting the list of courses and the course outline.
- """
-
- MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
-
- def setUp(self):
- """
- Add a course with odd characters in the fields
- """
- super().setUp()
- # had a problem where index showed course but has_access failed to retrieve it for non-staff
- self.odd_course = CourseFactory.create(
- org='test.org_1-2',
- number='test-2.3_course',
- display_name='dotted.course.name-2',
- )
- CourseOverviewFactory.create(
- id=self.odd_course.id,
- org=self.odd_course.org,
- display_name=self.odd_course.display_name,
- )
-
- def check_courses_on_index(self, authed_client, expected_course_tab_len):
- """
- Test that the React course listing is present.
- """
- index_url = '/home/'
- index_response = authed_client.get(index_url, {}, HTTP_ACCEPT='text/html')
- parsed_html = lxml.html.fromstring(index_response.content)
- courses_tab = parsed_html.find_class('react-course-listing')
- self.assertEqual(len(courses_tab), expected_course_tab_len)
-
- def test_libraries_on_index(self):
- """
- Test that the library tab is present.
- """
- def _assert_library_tab_present(response):
- """
- Asserts there's a library tab.
- """
- parsed_html = lxml.html.fromstring(response.content)
- library_tab = parsed_html.find_class('react-library-listing')
- self.assertEqual(len(library_tab), 1)
-
- # Add a library:
- lib1 = LibraryFactory.create() # lint-amnesty, pylint: disable=unused-variable
-
- index_url = '/home/'
- index_response = self.client.get(index_url, {}, HTTP_ACCEPT='text/html')
- _assert_library_tab_present(index_response)
-
- # Make sure libraries are visible to non-staff users too
- self.client.logout()
- non_staff_user, non_staff_userpassword = self.create_non_staff_user()
- lib2 = LibraryFactory.create(user_id=non_staff_user.id)
- LibraryUserRole(lib2.location.library_key).add_users(non_staff_user)
- self.client.login(username=non_staff_user.username, password=non_staff_userpassword)
- index_response = self.client.get(index_url, {}, HTTP_ACCEPT='text/html')
- _assert_library_tab_present(index_response)
-
- def test_is_staff_access(self):
- """
- Test that people with is_staff see the courses and can navigate into them
- """
- self.check_courses_on_index(self.client, 1)
-
- def test_negative_conditions(self):
- """
- Test the error conditions for the access
- """
- outline_url = reverse_course_url('course_handler', self.course.id)
- # register a non-staff member and try to delete the course branch
- non_staff_client, _ = self.create_non_staff_authed_user_client()
- response = non_staff_client.delete(outline_url, {}, HTTP_ACCEPT='application/json')
- if self.course.id.deprecated:
- self.assertEqual(response.status_code, 404)
- else:
- self.assertEqual(response.status_code, 403)
-
- def test_course_staff_access(self):
- """
- Make and register course_staff and ensure they can access the courses
- """
- course_staff_client, course_staff = self.create_non_staff_authed_user_client()
- for course in [self.course, self.odd_course]:
- permission_url = reverse_course_url('course_team_handler', course.id, kwargs={'email': course_staff.email})
-
- self.client.post(
- permission_url,
- data=json.dumps({"role": "staff"}),
- content_type="application/json",
- HTTP_ACCEPT="application/json",
- )
-
- # test access
- self.check_courses_on_index(course_staff_client, 1)
-
- def test_json_responses(self):
-
- outline_url = reverse_course_url('course_handler', self.course.id)
- chapter = BlockFactory.create(parent_location=self.course.location, category='chapter', display_name="Week 1")
- lesson = BlockFactory.create(parent_location=chapter.location, category='sequential', display_name="Lesson 1")
- subsection = BlockFactory.create(
- parent_location=lesson.location,
- category='vertical',
- display_name='Subsection 1'
- )
- BlockFactory.create(parent_location=subsection.location, category="video", display_name="My Video")
-
- resp = self.client.get(outline_url, HTTP_ACCEPT='application/json')
-
- if self.course.id.deprecated:
- self.assertEqual(resp.status_code, 404)
- return
-
- json_response = json.loads(resp.content.decode('utf-8'))
-
- # First spot check some values in the root response
- self.assertEqual(json_response['category'], 'course')
- self.assertEqual(json_response['id'], str(self.course.location))
- self.assertEqual(json_response['display_name'], self.course.display_name)
- self.assertTrue(json_response['published'])
- self.assertIsNone(json_response['visibility_state'])
-
- # Now verify the first child
- children = json_response['child_info']['children']
- self.assertGreater(len(children), 0)
- first_child_response = children[0]
- self.assertEqual(first_child_response['category'], 'chapter')
- self.assertEqual(first_child_response['id'], str(chapter.location))
- self.assertEqual(first_child_response['display_name'], 'Week 1')
- self.assertTrue(json_response['published'])
- self.assertEqual(first_child_response['visibility_state'], VisibilityState.unscheduled)
- self.assertGreater(len(first_child_response['child_info']['children']), 0)
-
- # Finally, validate the entire response for consistency
- self.assert_correct_json_response(json_response)
-
- def test_notifications_handler_get(self):
- state = CourseRerunUIStateManager.State.FAILED
- action = CourseRerunUIStateManager.ACTION
- should_display = True
-
- # try when no notification exists
- notification_url = reverse_course_url('course_notifications_handler', self.course.id, kwargs={
- 'action_state_id': 1,
- })
-
- resp = self.client.get(notification_url, HTTP_ACCEPT='application/json')
-
- # verify that we get an empty dict out
- self.assertEqual(resp.status_code, 400)
-
- # create a test notification
- rerun_state = CourseRerunState.objects.update_state(
- course_key=self.course.id,
- new_state=state,
- allow_not_found=True
- )
- CourseRerunState.objects.update_should_display(
- entry_id=rerun_state.id,
- user=UserFactory(),
- should_display=should_display
- )
-
- # try to get information on this notification
- notification_url = reverse_course_url('course_notifications_handler', self.course.id, kwargs={
- 'action_state_id': rerun_state.id,
- })
- resp = self.client.get(notification_url, HTTP_ACCEPT='application/json')
-
- json_response = json.loads(resp.content.decode('utf-8'))
-
- self.assertEqual(json_response['state'], state)
- self.assertEqual(json_response['action'], action)
- self.assertEqual(json_response['should_display'], should_display)
-
- def test_notifications_handler_dismiss(self):
- state = CourseRerunUIStateManager.State.FAILED
- should_display = True
- rerun_course_key = CourseLocator(org='testx', course='test_course', run='test_run')
-
- # add an instructor to this course
- user2 = UserFactory()
- add_instructor(rerun_course_key, self.user, user2)
-
- # create a test notification
- rerun_state = CourseRerunState.objects.update_state(
- course_key=rerun_course_key,
- new_state=state,
- allow_not_found=True
- )
- CourseRerunState.objects.update_should_display(
- entry_id=rerun_state.id,
- user=user2,
- should_display=should_display
- )
-
- # try to get information on this notification
- notification_dismiss_url = reverse_course_url('course_notifications_handler', self.course.id, kwargs={
- 'action_state_id': rerun_state.id,
- })
- resp = self.client.delete(notification_dismiss_url)
- self.assertEqual(resp.status_code, 200)
-
- with self.assertRaises(CourseRerunState.DoesNotExist):
- # delete nofications that are dismissed
- CourseRerunState.objects.get(id=rerun_state.id)
-
- self.assertFalse(has_course_author_access(user2, rerun_course_key))
-
- def assert_correct_json_response(self, json_response):
- """
- Asserts that the JSON response is syntactically consistent
- """
- self.assertIsNotNone(json_response['display_name'])
- self.assertIsNotNone(json_response['id'])
- self.assertIsNotNone(json_response['category'])
- self.assertTrue(json_response['published'])
- if json_response.get('child_info', None):
- for child_response in json_response['child_info']['children']:
- self.assert_correct_json_response(child_response)
-
- def test_course_updates_invalid_url(self):
- """
- Tests the error conditions for the invalid course updates URL.
- """
- # Testing the response code by passing slash separated course id whose format is valid but no course
- # having this id exists.
- invalid_course_key = f'{self.course.id}_blah_blah_blah'
- course_updates_url = reverse_course_url('course_info_handler', invalid_course_key)
- response = self.client.get(course_updates_url)
- self.assertEqual(response.status_code, 404)
-
- # Testing the response code by passing split course id whose format is valid but no course
- # having this id exists.
- split_course_key = CourseLocator(org='orgASD', course='course_01213', run='Run_0_hhh_hhh_hhh')
- course_updates_url_split = reverse_course_url('course_info_handler', split_course_key)
- response = self.client.get(course_updates_url_split)
- self.assertEqual(response.status_code, 404)
-
- # Testing the response by passing split course id whose format is invalid.
- invalid_course_id = f'invalid.course.key/{split_course_key}'
- course_updates_url_split = reverse_course_url('course_info_handler', invalid_course_id)
- response = self.client.get(course_updates_url_split)
- self.assertEqual(response.status_code, 404)
-
- def test_course_index_invalid_url(self):
- """
- Tests the error conditions for the invalid course index URL.
- """
- # Testing the response code by passing slash separated course key, no course
- # having this key exists.
- invalid_course_key = f'{self.course.id}_some_invalid_run'
- course_outline_url = reverse_course_url('course_handler', invalid_course_key)
- response = self.client.get_html(course_outline_url)
- self.assertEqual(response.status_code, 404)
-
- # Testing the response code by passing split course key, no course
- # having this key exists.
- split_course_key = CourseLocator(org='invalid_org', course='course_01111', run='Run_0_invalid')
- course_outline_url_split = reverse_course_url('course_handler', split_course_key)
- response = self.client.get_html(course_outline_url_split)
- self.assertEqual(response.status_code, 404)
-
- def test_course_outline_with_display_course_number_as_none(self):
- """
- Tests course outline when 'display_coursenumber' field is none.
- """
- # Change 'display_coursenumber' field to None and update the course.
- self.course.display_coursenumber = None
- updated_course = self.update_course(self.course, self.user.id)
-
- # Assert that 'display_coursenumber' field has been changed successfully.
- self.assertEqual(updated_course.display_coursenumber, None)
-
- # Perform GET request on course outline url with the course id.
- course_outline_url = reverse_course_url('course_handler', updated_course.id)
- response = self.client.get_html(course_outline_url)
-
- # course_handler raise 404 for old mongo course
- if self.course.id.deprecated:
- self.assertEqual(response.status_code, 404)
- return
-
- # Assert that response code is 200.
- self.assertEqual(response.status_code, 200)
-
- # Assert that 'display_course_number' is being set to "" (as display_coursenumber was None).
- self.assertContains(response, 'display_course_number: ""')
-
-
-@override_waffle_flag(toggles.LEGACY_STUDIO_HOME, True)
-@ddt.ddt
-class TestCourseIndexArchived(CourseTestCase):
- """
- Unit tests for testing the course index list when there are archived courses.
- """
-
- MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
-
- NOW = datetime.datetime.now(pytz.utc)
- DAY = datetime.timedelta(days=1)
- YESTERDAY = NOW - DAY
- TOMORROW = NOW + DAY
-
- ORG = 'MyOrg'
-
- ENABLE_SEPARATE_ARCHIVED_COURSES = settings.FEATURES.copy()
- ENABLE_SEPARATE_ARCHIVED_COURSES['ENABLE_SEPARATE_ARCHIVED_COURSES'] = True
- DISABLE_SEPARATE_ARCHIVED_COURSES = settings.FEATURES.copy()
- DISABLE_SEPARATE_ARCHIVED_COURSES['ENABLE_SEPARATE_ARCHIVED_COURSES'] = False
-
- def setUp(self):
- """
- Add courses with the end date set to various values
- """
- super().setUp()
-
- # Base course has no end date (so is active)
- self.course.end = None
- self.course.display_name = 'Active Course 1'
- self.ORG = self.course.location.org
- self.save_course()
- CourseOverviewFactory.create(id=self.course.id, org=self.ORG)
-
- # Active course has end date set to tomorrow
- self.active_course = CourseFactory.create(
- display_name='Active Course 2',
- org=self.ORG,
- end=self.TOMORROW,
- )
- CourseOverviewFactory.create(
- id=self.active_course.id,
- org=self.ORG,
- end=self.TOMORROW,
- )
-
- # Archived course has end date set to yesterday
- self.archived_course = CourseFactory.create(
- display_name='Archived Course',
- org=self.ORG,
- end=self.YESTERDAY,
- )
- CourseOverviewFactory.create(
- id=self.archived_course.id,
- org=self.ORG,
- end=self.YESTERDAY,
- )
-
- # Base user has global staff access
- self.assertTrue(GlobalStaff().has_user(self.user))
-
- # Staff user just has course staff access
- self.staff, self.staff_password = self.create_non_staff_user()
- for course in (self.course, self.active_course, self.archived_course):
- CourseStaffRole(course.id).add_users(self.staff)
-
- def check_index_page_with_query_count(self, separate_archived_courses, org, mongo_queries, sql_queries):
- """
- Checks the index page, and ensures the number of database queries is as expected.
- """
- with self.assertNumQueries(sql_queries, table_ignorelist=WAFFLE_TABLES):
- with check_mongo_calls(mongo_queries):
- self.check_index_page(separate_archived_courses=separate_archived_courses, org=org)
-
- def check_index_page(self, separate_archived_courses, org):
- """
- Ensure that the index page displays the archived courses as expected.
- """
- index_url = '/home/'
- index_params = {}
- if org is not None:
- index_params['org'] = org
- index_response = self.client.get(index_url, index_params, HTTP_ACCEPT='text/html')
- self.assertEqual(index_response.status_code, 200)
-
- parsed_html = lxml.html.fromstring(index_response.content)
- course_tab = parsed_html.find_class('courses')
- self.assertEqual(len(course_tab), 1)
- archived_course_tab = parsed_html.find_class('archived-courses')
- self.assertEqual(len(archived_course_tab), 1 if separate_archived_courses else 0)
-
- @ddt.data(
- # Staff user has course staff access
- (True, 'staff', None, 23),
- (False, 'staff', None, 23),
- # Base user has global staff access
- (True, 'user', ORG, 23),
- (False, 'user', ORG, 23),
- (True, 'user', None, 23),
- (False, 'user', None, 23),
- )
- @ddt.unpack
- def test_separate_archived_courses(self, separate_archived_courses, username, org, sql_queries):
- """
- Ensure that archived courses are shown as expected for all user types, when the feature is enabled/disabled.
- Also ensure that enabling the feature does not adversely affect the database query count.
- """
- # Authenticate the requested user
- user = getattr(self, username)
- password = getattr(self, username + '_password')
- self.client.login(username=user, password=password)
-
- # Enable/disable the feature before viewing the index page.
- features = settings.FEATURES.copy()
- features['ENABLE_SEPARATE_ARCHIVED_COURSES'] = separate_archived_courses
- with override_settings(FEATURES=features):
- self.check_index_page_with_query_count(separate_archived_courses=separate_archived_courses,
- org=org,
- mongo_queries=0,
- sql_queries=sql_queries)
-
- @ddt.data(
- # Staff user has course staff access
- (True, 'staff', None, 23),
- (False, 'staff', None, 23),
- # Base user has global staff access
- (True, 'user', ORG, 23),
- (False, 'user', ORG, 23),
- (True, 'user', None, 23),
- (False, 'user', None, 23),
- )
- @ddt.unpack
- def test_separate_archived_courses_with_home_page_course_v2_api(
- self,
- separate_archived_courses,
- username,
- org,
- sql_queries
- ):
- """
- Ensure that archived courses are shown as expected for all user types, when the feature is enabled/disabled.
- Also ensure that enabling the feature does not adversely affect the database query count.
- """
- # Authenticate the requested user
- user = getattr(self, username)
- password = getattr(self, username + '_password')
- self.client.login(username=user, password=password)
-
- # Enable/disable the feature before viewing the index page.
- features = settings.FEATURES.copy()
- features['ENABLE_SEPARATE_ARCHIVED_COURSES'] = separate_archived_courses
- with override_settings(FEATURES=features):
- self.check_index_page_with_query_count(separate_archived_courses=separate_archived_courses,
- org=org,
- mongo_queries=0,
- sql_queries=sql_queries)
-
-
-@override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True)
@ddt.ddt
class TestCourseOutline(CourseTestCase):
"""
@@ -689,38 +221,15 @@ def test_verify_warn_only_on_enabled_blocks(self, enabled_block_types, deprecate
expected_block_types
)
- @override_settings(FEATURES={'ENABLE_EXAM_SETTINGS_HTML_VIEW': True})
- @mock.patch('cms.djangoapps.models.settings.course_metadata.CourseMetadata.validate_proctoring_settings')
- def test_proctoring_link_is_visible(self, mock_validate_proctoring_settings):
- """
- Test to check proctored exam settings mfe url is rendering properly
- """
- mock_validate_proctoring_settings.return_value = [
- {
- 'key': 'proctoring_provider',
- 'message': 'error message',
- 'model': {'display_name': 'proctoring_provider'}
- },
- {
- 'key': 'proctoring_provider',
- 'message': 'error message',
- 'model': {'display_name': 'proctoring_provider'}
- }
- ]
- response = self.client.get_html(reverse_course_url('course_handler', self.course.id))
- proctored_exam_settings_url = get_proctored_exam_settings_url(self.course.id)
- self.assertContains(response, proctored_exam_settings_url, 2)
-
def test_number_of_calls_to_db(self):
"""
Test to check number of queries made to mysql and mongo
"""
- with self.assertNumQueries(39, table_ignorelist=WAFFLE_TABLES):
+ with self.assertNumQueries(21, table_ignorelist=WAFFLE_TABLES):
with check_mongo_calls(3):
- self.client.get_html(reverse_course_url('course_handler', self.course.id))
+ self.client.get(reverse_course_url('course_handler', self.course.id), content_type="application/json")
-@override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True)
class TestCourseReIndex(CourseTestCase):
"""
Unit tests for the course outline.
diff --git a/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py b/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py
index 9bad2c77fc1a..88c4aa27eb06 100644
--- a/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py
+++ b/cms/djangoapps/contentstore/views/tests/test_exam_settings_view.py
@@ -24,7 +24,6 @@
"ENABLE_PROCTORED_EXAMS": True,
},
)
-@override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True)
@override_waffle_flag(toggles.LEGACY_STUDIO_CERTIFICATES, True)
@override_waffle_flag(toggles.LEGACY_STUDIO_SCHEDULE_DETAILS, True)
@override_waffle_flag(toggles.LEGACY_STUDIO_CONFIGURATIONS, True)
@@ -93,7 +92,6 @@ def test_view_with_exam_settings_enabled(self, handler):
)
@ddt.data(
"advanced_settings_handler",
- "course_handler",
)
def test_exam_settings_alert_with_exam_settings_enabled(self, page_handler):
"""
@@ -130,7 +128,6 @@ def test_exam_settings_alert_with_exam_settings_enabled(self, page_handler):
)
@ddt.data(
"advanced_settings_handler",
- "course_handler",
)
@override_waffle_flag(toggles.LEGACY_STUDIO_EXAM_SETTINGS, True)
def test_exam_settings_alert_with_exam_settings_disabled(self, page_handler):
@@ -173,7 +170,6 @@ def test_exam_settings_alert_with_exam_settings_disabled(self, page_handler):
)
@ddt.data(
"advanced_settings_handler",
- "course_handler",
)
def test_invalid_provider_alert(self, page_handler):
"""
@@ -198,7 +194,6 @@ def test_invalid_provider_alert(self, page_handler):
@ddt.data(
"advanced_settings_handler",
- "course_handler",
)
def test_exam_settings_alert_not_shown(self, page_handler):
"""
diff --git a/cms/djangoapps/contentstore/views/tests/test_header_menu.py b/cms/djangoapps/contentstore/views/tests/test_header_menu.py
deleted file mode 100644
index fb961cc4fa89..000000000000
--- a/cms/djangoapps/contentstore/views/tests/test_header_menu.py
+++ /dev/null
@@ -1,93 +0,0 @@
-"""
-Course Header Menu Tests.
-"""
-from unittest import SkipTest
-
-from django.conf import settings
-from django.test.utils import override_settings
-from edx_toggles.toggles.testutils import override_waffle_flag
-
-from cms.djangoapps.contentstore import toggles
-from cms.djangoapps.contentstore.tests.utils import CourseTestCase
-from cms.djangoapps.contentstore.utils import reverse_course_url
-from common.djangoapps.util.testing import UrlResetMixin
-
-FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy()
-FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True
-
-FEATURES_WITH_EXAM_SETTINGS_ENABLED = settings.FEATURES.copy()
-FEATURES_WITH_EXAM_SETTINGS_ENABLED['ENABLE_EXAM_SETTINGS_HTML_VIEW'] = True
-
-FEATURES_WITH_EXAM_SETTINGS_DISABLED = settings.FEATURES.copy()
-FEATURES_WITH_EXAM_SETTINGS_DISABLED['ENABLE_EXAM_SETTINGS_HTML_VIEW'] = False
-
-
-@override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED)
-@override_waffle_flag(toggles.LEGACY_STUDIO_COURSE_OUTLINE, True)
-class TestHeaderMenu(CourseTestCase, UrlResetMixin):
- """
- Unit tests for the course header menu.
- """
- def setUp(self):
- """
- Set up the for the course header menu tests.
- """
- super().setUp()
- self.reset_urls()
-
- def test_header_menu_without_web_certs_enabled(self):
- """
- Tests course header menu should not have `Certificates` menu item
- if course has not web/HTML certificates enabled.
- """
- # course_handler raise 404 for old mongo course
- if self.course.id.deprecated:
- raise SkipTest("course_handler raise 404 for old mongo course")
- self.course.cert_html_view_enabled = False
- self.save_course()
- outline_url = reverse_course_url('course_handler', self.course.id)
- resp = self.client.get(outline_url, HTTP_ACCEPT='text/html')
- self.assertEqual(resp.status_code, 200)
- self.assertNotContains(resp, '
')
-
- def test_header_menu_with_web_certs_enabled(self):
- """
- Tests course header menu should have `Certificates` menu item
- if course has web/HTML certificates enabled.
- """
- # course_handler raise 404 for old mongo course
- if self.course.id.deprecated:
- raise SkipTest("course_handler raise 404 for old mongo course")
- outline_url = reverse_course_url('course_handler', self.course.id)
- resp = self.client.get(outline_url, HTTP_ACCEPT='text/html')
- self.assertEqual(resp.status_code, 200)
- self.assertContains(resp, '
')
-
- @override_settings(FEATURES=FEATURES_WITH_EXAM_SETTINGS_DISABLED)
- @override_waffle_flag(toggles.LEGACY_STUDIO_EXAM_SETTINGS, True)
- def test_header_menu_without_exam_settings_enabled(self):
- """
- Tests course header menu should not have `Exam Settings` menu item
- if course does not have the Exam Settings view enabled.
- """
- # course_handler raise 404 for old mongo course
- if self.course.id.deprecated:
- raise SkipTest("course_handler raise 404 for old mongo course")
- outline_url = reverse_course_url('course_handler', self.course.id)
- resp = self.client.get(outline_url, HTTP_ACCEPT='text/html')
- self.assertEqual(resp.status_code, 200)
- self.assertNotContains(resp, '
')
-
- @override_settings(FEATURES=FEATURES_WITH_EXAM_SETTINGS_ENABLED)
- def test_header_menu_with_exam_settings_enabled(self):
- """
- Tests course header menu should have `Exam Settings` menu item
- if course does have Exam Settings view enabled.
- """
- # course_handler raise 404 for old mongo course
- if self.course.id.deprecated:
- raise SkipTest("course_handler raise 404 for old mongo course")
- outline_url = reverse_course_url('course_handler', self.course.id)
- resp = self.client.get(outline_url, HTTP_ACCEPT='text/html')
- self.assertEqual(resp.status_code, 200)
- self.assertContains(resp, '
')
diff --git a/cms/djangoapps/contentstore/views/tests/test_organizations.py b/cms/djangoapps/contentstore/views/tests/test_organizations.py
index cf3a376f3461..d231e3adc507 100644
--- a/cms/djangoapps/contentstore/views/tests/test_organizations.py
+++ b/cms/djangoapps/contentstore/views/tests/test_organizations.py
@@ -3,12 +3,18 @@
import json
+from django.conf import settings
from django.test import TestCase
+from django.test.utils import override_settings
from django.urls import reverse
from organizations.api import add_organization
+from cms.djangoapps.course_creators.models import CourseCreator
+from common.djangoapps.student.roles import OrgStaffRole
from common.djangoapps.student.tests.factories import UserFactory
+from ..course import get_allowed_organizations_for_libraries
+
class TestOrganizationListing(TestCase):
"""Verify Organization listing behavior."""
@@ -32,3 +38,96 @@ def test_organization_list(self):
self.assertEqual(response.status_code, 200)
org_names = json.loads(response.content.decode('utf-8'))
self.assertEqual(org_names, self.org_short_names)
+
+
+class TestOrganizationsForLibraries(TestCase):
+ """
+ Verify who is allowed to create Libraries.
+
+ This uses some low-level implementation details to set up course creator and
+ org staff data, which should be replaced by API calls.
+
+ The behavior of this call depends on two FEATURES toggles:
+
+ * ENABLE_ORGANIZATION_STAFF_ACCESS_FOR_CONTENT_LIBRARIES
+ * ENABLE_CREATOR_GROUP
+ """
+
+ @classmethod
+ def setUpTestData(cls):
+ cls.library_author = UserFactory(is_staff=False)
+ cls.org_short_names = ["OrgStaffOrg", "CreatorOrg", "RandomOrg"]
+ cls.orgs = {}
+ for index, short_name in enumerate(cls.org_short_names):
+ cls.orgs[short_name] = add_organization(organization_data={
+ 'name': 'Test Organization %s' % index,
+ 'short_name': short_name,
+ 'description': 'Testing Organization %s Description' % index,
+ })
+
+ # Our user is an org staff for OrgStaffOrg
+ OrgStaffRole("OrgStaffOrg").add_users(cls.library_author)
+
+ # Our user is also a CourseCreator in CreatorOrg
+ creator = CourseCreator.objects.create(
+ user=cls.library_author,
+ state=CourseCreator.GRANTED,
+ all_organizations=False,
+ )
+ # The following is because course_creators app logic assumes that all
+ # updates to CourseCreator go through the CourseCreatorAdmin.
+ # Specifically, CourseCreatorAdmin.save_model() attaches the current
+ # request.user to the model instance's .admin field, and then the
+ # course_creator_organizations_changed_callback() signal handler assumes
+ # creator.admin is present. I think that code could use some judicious
+ # refactoring, but I'm just writing this test as part of a last-minute
+ # Ulmo bug fix, and I don't want to add risk by refactoring something as
+ # critical-path as course_creators as part of this work.
+ creator.admin = UserFactory(is_staff=True)
+ creator.organizations.add(
+ cls.orgs["CreatorOrg"]['id']
+ )
+
+ @override_settings(
+ FEATURES={
+ **settings.FEATURES,
+ 'ENABLE_ORGANIZATION_STAFF_ACCESS_FOR_CONTENT_LIBRARIES': False,
+ 'ENABLE_CREATOR_GROUP': False,
+ }
+ )
+ def test_both_toggles_disabled(self):
+ allowed_orgs = get_allowed_organizations_for_libraries(self.library_author)
+ assert allowed_orgs == []
+
+ @override_settings(
+ FEATURES={
+ **settings.FEATURES,
+ 'ENABLE_ORGANIZATION_STAFF_ACCESS_FOR_CONTENT_LIBRARIES': True,
+ 'ENABLE_CREATOR_GROUP': True,
+ }
+ )
+ def test_both_toggles_enabled(self):
+ allowed_orgs = get_allowed_organizations_for_libraries(self.library_author)
+ assert allowed_orgs == ["CreatorOrg", "OrgStaffOrg"]
+
+ @override_settings(
+ FEATURES={
+ **settings.FEATURES,
+ 'ENABLE_ORGANIZATION_STAFF_ACCESS_FOR_CONTENT_LIBRARIES': True,
+ 'ENABLE_CREATOR_GROUP': False,
+ }
+ )
+ def test_org_staff_enabled(self):
+ allowed_orgs = get_allowed_organizations_for_libraries(self.library_author)
+ assert allowed_orgs == ["OrgStaffOrg"]
+
+ @override_settings(
+ FEATURES={
+ **settings.FEATURES,
+ 'ENABLE_ORGANIZATION_STAFF_ACCESS_FOR_CONTENT_LIBRARIES': False,
+ 'ENABLE_CREATOR_GROUP': True,
+ }
+ )
+ def test_creator_group_enabled(self):
+ allowed_orgs = get_allowed_organizations_for_libraries(self.library_author)
+ assert allowed_orgs == ["CreatorOrg"]
diff --git a/cms/djangoapps/contentstore/views/tests/test_videos.py b/cms/djangoapps/contentstore/views/tests/test_videos.py
index 2d17229f59c1..cf2aefb9935d 100644
--- a/cms/djangoapps/contentstore/views/tests/test_videos.py
+++ b/cms/djangoapps/contentstore/views/tests/test_videos.py
@@ -9,13 +9,12 @@
from contextlib import contextmanager
from datetime import datetime
from io import StringIO
-from unittest.mock import Mock, patch
+from unittest.mock import Mock, call, patch
import dateutil.parser
from common.djangoapps.student.tests.factories import UserFactory
import ddt
import pytz
-from django.test import TestCase
from django.conf import settings
from django.test.utils import override_settings
from django.urls import reverse
@@ -33,10 +32,7 @@
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
from cms.djangoapps.contentstore.utils import reverse_course_url
from openedx.core.djangoapps.profile_images.tests.helpers import make_image_file
-from openedx.core.djangoapps.video_pipeline.config.waffle import (
- DEPRECATE_YOUTUBE,
- ENABLE_DEVSTACK_VIDEO_UPLOADS,
-)
+from openedx.core.djangoapps.video_pipeline.config.waffle import DEPRECATE_YOUTUBE
from openedx.core.djangoapps.waffle_utils.models import WaffleFlagCourseOverrideModel
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
@@ -52,11 +48,13 @@
TranscriptProvider,
StatusDisplayStrings,
convert_video_status,
- storage_service_bucket,
- storage_service_key,
- PUBLIC_VIDEO_SHARE
+ PUBLIC_VIDEO_SHARE,
)
+# Constant defined to make it clear when we're grabbing the kwargs from a
+# unittest.mock.call (which is a list of [args, kwargs]).
+CALL_KW = 1
+
class VideoUploadTestBase:
"""
@@ -167,6 +165,40 @@ def _get_previous_upload(self, edx_video_id):
if video["edx_video_id"] == edx_video_id
)
+ @contextmanager
+ def patch_presign_url(self, files):
+ """
+ Decorator that patches boto3 to mock out S3 URL presigning.
+
+ Assumes that the only client in use is S3, and that only the presigning
+ method will be called. Makes assertions about what calls were made.
+
+ Decorator yields a result dictionary that will be populated *after* the
+ context closes. The one key is "calls", a list of call objects to the mock.
+
+ Arguments:
+ files: List of files to use for upload (dict of file_name and content_type)
+ """
+ mock_gen_url = Mock(side_effect=[
+ 'http://example.com/url_{}'.format(file_info['file_name'])
+ for file_info in files
+ ])
+ mock_s3_client = Mock()
+ mock_s3_client.generate_presigned_url = mock_gen_url
+ with patch(
+ 'cms.djangoapps.contentstore.video_storage_handlers.boto3.client',
+ return_value=mock_s3_client
+ ) as mock_boto_client:
+ results = {}
+ try:
+ yield results # run wrapped block
+ finally:
+ results['calls'] = mock_gen_url.call_args_list
+
+ # Ensure that we're only trying to load the S3 client
+ for c in mock_boto_client.call_args_list:
+ self.assertEqual(c, call('s3'))
+
class VideoStudioAccessTestsMixin:
"""
@@ -215,10 +247,7 @@ class VideoUploadPostTestsMixin:
"""
Shared test cases for video post tests.
"""
- @override_settings(AWS_ACCESS_KEY_ID='test_key_id', AWS_SECRET_ACCESS_KEY='test_secret')
- @patch('boto.s3.key.Key')
- @patch('cms.djangoapps.contentstore.video_storage_handlers.S3Connection')
- def test_post_success(self, mock_conn, mock_key):
+ def test_post_success(self):
files = [
{
'file_name': 'first.mp4',
@@ -238,63 +267,42 @@ def test_post_success(self, mock_conn, mock_key):
},
]
- bucket = Mock()
- mock_conn.return_value = Mock(get_bucket=Mock(return_value=bucket))
- mock_key_instances = [
- Mock(
- generate_url=Mock(
- return_value='http://example.com/url_{}'.format(file_info['file_name'])
- )
+ with self.patch_presign_url(files) as presign_results:
+ response = self.client.post(
+ self.url,
+ json.dumps({'files': files}),
+ content_type='application/json'
)
- for file_info in files
- ]
- # If extra calls are made, return a dummy
- mock_key.side_effect = mock_key_instances + [Mock()]
-
- response = self.client.post(
- self.url,
- json.dumps({'files': files}),
- content_type='application/json'
- )
self.assertEqual(response.status_code, 200)
response_obj = json.loads(response.content.decode('utf-8'))
- mock_conn.assert_called_once_with(
- aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
- aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY
- )
self.assertEqual(len(response_obj['files']), len(files))
- self.assertEqual(mock_key.call_count, len(files))
+ presign_calls = presign_results['calls']
+ self.assertEqual(len(presign_calls), len(files))
for i, file_info in enumerate(files):
- # Ensure Key was set up correctly and extract id
- key_call_args, __ = mock_key.call_args_list[i]
- self.assertEqual(key_call_args[0], bucket)
+ call_kwargs = presign_calls[i][CALL_KW]
+
+ self.assertEqual(call_kwargs['ClientMethod'], 'put_object')
path_match = re.match(
(
settings.VIDEO_UPLOAD_PIPELINE['ROOT_PATH'] +
'/([a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12})$'
),
- key_call_args[1]
+ call_kwargs['Params']['Key']
)
self.assertIsNotNone(path_match)
video_id = path_match.group(1)
- mock_key_instance = mock_key_instances[i]
-
- mock_key_instance.set_metadata.assert_any_call(
- 'course_video_upload_token',
- self.test_token
- )
- mock_key_instance.set_metadata.assert_any_call(
- 'client_video_id',
- file_info['file_name']
- )
- mock_key_instance.set_metadata.assert_any_call('course_key', str(self.course.id))
- mock_key_instance.generate_url.assert_called_once_with(
- KEY_EXPIRATION_IN_SECONDS,
- 'PUT',
- headers={'Content-Type': file_info['content_type']}
+ self.assertEqual(
+ call_kwargs['Params']['Metadata'],
+ {
+ 'course_video_upload_token': self.test_token,
+ 'client_video_id': file_info['file_name'],
+ 'course_key': str(self.course.id),
+ }
)
+ self.assertEqual(call_kwargs['Params']['ContentType'], file_info['content_type'])
+ self.assertEqual(call_kwargs['ExpiresIn'], KEY_EXPIRATION_IN_SECONDS)
# Ensure VAL was updated
val_info = get_video_info(video_id)
@@ -307,7 +315,7 @@ def test_post_success(self, mock_conn, mock_key):
# Ensure response is correct
response_file = response_obj['files'][i]
self.assertEqual(response_file['file_name'], file_info['file_name'])
- self.assertEqual(response_file['upload_url'], mock_key_instance.generate_url())
+ self.assertEqual(response_file['upload_url'], f"http://example.com/url_{file_info['file_name']}")
def test_post_non_json(self):
response = self.client.post(self.url, {"files": []})
@@ -479,9 +487,6 @@ def test_get_html_paginated(self):
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'video_upload_pagination')
- @override_settings(AWS_ACCESS_KEY_ID="test_key_id", AWS_SECRET_ACCESS_KEY="test_secret")
- @patch("boto.s3.key.Key")
- @patch("cms.djangoapps.contentstore.video_storage_handlers.S3Connection")
@ddt.data(
(
[
@@ -511,28 +516,17 @@ def test_get_html_paginated(self):
)
)
@ddt.unpack
- def test_video_supported_file_formats(self, files, expected_status, mock_conn, mock_key):
+ def test_video_supported_file_formats(self, files, expected_status):
"""
Test that video upload works correctly against supported and unsupported file formats.
"""
- mock_conn.get_bucket = Mock()
- mock_key_instances = [
- Mock(
- generate_url=Mock(
- return_value="http://example.com/url_{}".format(file_info["file_name"])
- )
- )
- for file_info in files
- ]
- # If extra calls are made, return a dummy
- mock_key.side_effect = mock_key_instances + [Mock()]
-
# Check supported formats
- response = self.client.post(
- self.url,
- json.dumps({"files": files}),
- content_type="application/json"
- )
+ with self.patch_presign_url(files):
+ response = self.client.post(
+ self.url,
+ json.dumps({"files": files}),
+ content_type="application/json"
+ )
self.assertEqual(response.status_code, expected_status)
response = json.loads(response.content.decode('utf-8'))
@@ -542,19 +536,12 @@ def test_video_supported_file_formats(self, files, expected_status, mock_conn, m
self.assertIn('error', response)
self.assertEqual(response['error'], "Request 'files' entry contain unsupported content_type")
- @override_settings(AWS_ACCESS_KEY_ID='test_key_id', AWS_SECRET_ACCESS_KEY='test_secret')
- @patch('cms.djangoapps.contentstore.video_storage_handlers.S3Connection')
- def test_upload_with_non_ascii_charaters(self, mock_conn):
+ def test_upload_with_non_ascii_characters(self):
"""
Test that video uploads throws error message when file name contains special characters.
"""
- mock_conn.get_bucket = Mock()
file_name = 'test\u2019_file.mp4'
files = [{'file_name': file_name, 'content_type': 'video/mp4'}]
-
- bucket = Mock()
- mock_conn.return_value = Mock(get_bucket=Mock(return_value=bucket))
-
response = self.client.post(
self.url,
json.dumps({'files': files}),
@@ -564,67 +551,24 @@ def test_upload_with_non_ascii_charaters(self, mock_conn):
response = json.loads(response.content.decode('utf-8'))
self.assertEqual(response['error'], 'The file name for %s must contain only ASCII characters.' % file_name)
- @override_settings(AWS_ACCESS_KEY_ID='test_key_id', AWS_SECRET_ACCESS_KEY='test_secret', AWS_SECURITY_TOKEN='token')
- @patch('boto.s3.key.Key')
- @patch('cms.djangoapps.contentstore.video_storage_handlers.S3Connection')
- @override_waffle_flag(ENABLE_DEVSTACK_VIDEO_UPLOADS, active=True)
- def test_devstack_upload_connection(self, mock_conn, mock_key):
- files = [{'file_name': 'first.mp4', 'content_type': 'video/mp4'}]
- mock_conn.get_bucket = Mock()
- mock_key_instances = [
- Mock(
- generate_url=Mock(
- return_value='http://example.com/url_{}'.format(file_info['file_name'])
- )
- )
- for file_info in files
- ]
- mock_key.side_effect = mock_key_instances
- response = self.client.post(
- self.url,
- json.dumps({'files': files}),
- content_type='application/json'
- )
-
- self.assertEqual(response.status_code, 200)
- mock_conn.assert_called_once_with(
- aws_access_key_id=settings.AWS_ACCESS_KEY_ID,
- aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY,
- security_token=settings.AWS_SECURITY_TOKEN
- )
-
- @patch('boto.s3.key.Key')
- @patch('cms.djangoapps.contentstore.video_storage_handlers.S3Connection')
- def test_send_course_to_vem_pipeline(self, mock_conn, mock_key):
+ def test_send_course_to_vem_pipeline(self):
"""
Test that uploads always go to VEM S3 bucket by default.
"""
- mock_conn.get_bucket = Mock()
files = [{'file_name': 'first.mp4', 'content_type': 'video/mp4'}]
- mock_key_instances = [
- Mock(
- generate_url=Mock(
- return_value='http://example.com/url_{}'.format(file_info['file_name'])
- )
+ with self.patch_presign_url(files) as presign_results:
+ response = self.client.post(
+ self.url,
+ json.dumps({'files': files}),
+ content_type='application/json'
)
- for file_info in files
- ]
- mock_key.side_effect = mock_key_instances
-
- response = self.client.post(
- self.url,
- json.dumps({'files': files}),
- content_type='application/json'
- )
self.assertEqual(response.status_code, 200)
- mock_conn.return_value.get_bucket.assert_called_once_with(
- settings.VIDEO_UPLOAD_PIPELINE['VEM_S3_BUCKET'], validate=False # pylint: disable=unsubscriptable-object
+ self.assertEqual(
+ presign_results['calls'][0][CALL_KW]['Params']['Bucket'],
+ settings.VIDEO_UPLOAD_PIPELINE['VEM_S3_BUCKET']
)
- @override_settings(AWS_ACCESS_KEY_ID='test_key_id', AWS_SECRET_ACCESS_KEY='test_secret')
- @patch('boto.s3.key.Key')
- @patch('cms.djangoapps.contentstore.video_storage_handlers.S3Connection')
@ddt.data(
{
'global_waffle': True,
@@ -642,52 +586,26 @@ def test_send_course_to_vem_pipeline(self, mock_conn, mock_key):
'expect_token': True
}
)
- def test_video_upload_token_in_meta(self, data, mock_conn, mock_key):
+ def test_video_upload_token_in_meta(self, data):
"""
Test video upload token in s3 metadata.
"""
- @contextmanager
- def proxy_manager(manager, ignore_manager):
- """
- This acts as proxy to the original manager in the arguments given
- the original manager is not set to be ignored.
- """
- if ignore_manager:
- yield
- else:
- with manager:
- yield
-
file_data = {
'file_name': 'first.mp4',
'content_type': 'video/mp4',
}
- mock_conn.get_bucket = Mock()
- mock_key_instance = Mock(
- generate_url=Mock(
- return_value='http://example.com/url_{}'.format(file_data['file_name'])
- )
- )
- # If extra calls are made, return a dummy
- mock_key.side_effect = [mock_key_instance]
-
- # expected args to be passed to `set_metadata`.
- expected_args = ('course_video_upload_token', self.test_token)
-
with patch.object(WaffleFlagCourseOverrideModel, 'override_value', return_value=data['course_override']):
with override_waffle_flag(DEPRECATE_YOUTUBE, active=data['global_waffle']):
- response = self.client.post(
- self.url,
- json.dumps({'files': [file_data]}),
- content_type='application/json'
- )
+ with self.patch_presign_url([file_data]) as presign_results:
+ response = self.client.post(
+ self.url,
+ json.dumps({'files': [file_data]}),
+ content_type='application/json'
+ )
self.assertEqual(response.status_code, 200)
- with proxy_manager(self.assertRaises(AssertionError), data['expect_token']):
- # if we're not expecting token then following should raise assertion error and
- # if we're expecting token then we will be able to find the call to set the token
- # in s3 metadata.
- mock_key_instance.set_metadata.assert_any_call(*expected_args)
+ actual_token = presign_results['calls'][0][CALL_KW]['Params']['Metadata'].get('course_video_upload_token')
+ self.assertEqual(actual_token, self.test_token if data['expect_token'] else None)
def _assert_video_removal(self, url, edx_video_id, deleted_videos):
"""
@@ -1460,47 +1378,37 @@ def test_remove_transcript_preferences_not_found(self):
)
@ddt.unpack
@override_settings(AWS_ACCESS_KEY_ID='test_key_id', AWS_SECRET_ACCESS_KEY='test_secret')
- @patch('boto.s3.key.Key')
- @patch('cms.djangoapps.contentstore.video_storage_handlers.S3Connection')
@patch('cms.djangoapps.contentstore.video_storage_handlers.get_transcript_preferences')
def test_transcript_preferences_metadata(self, transcript_preferences, is_video_transcript_enabled,
- mock_transcript_preferences, mock_conn, mock_key):
+ mock_transcript_preferences):
"""
Tests that transcript preference metadata is only set if it is video transcript feature is enabled and
transcript preferences are already stored in the system.
"""
file_name = 'test-video.mp4'
- request_data = {'files': [{'file_name': file_name, 'content_type': 'video/mp4'}]}
+ files = [{'file_name': file_name, 'content_type': 'video/mp4'}]
mock_transcript_preferences.return_value = transcript_preferences
- bucket = Mock()
- mock_conn.return_value = Mock(get_bucket=Mock(return_value=bucket))
- mock_key_instance = Mock(
- generate_url=Mock(
- return_value=f'http://example.com/url_{file_name}'
- )
- )
- # If extra calls are made, return a dummy
- mock_key.side_effect = [mock_key_instance] + [Mock()]
-
videos_handler_url = reverse_course_url('videos_handler', self.course.id)
with patch(
'openedx.core.djangoapps.video_config.models.VideoTranscriptEnabledFlag.feature_enabled'
) as video_transcript_feature:
video_transcript_feature.return_value = is_video_transcript_enabled
- response = self.client.post(videos_handler_url, json.dumps(request_data), content_type='application/json')
+ with self.patch_presign_url(files) as presign_results:
+ response = self.client.post(
+ videos_handler_url, json.dumps({'files': files}),
+ content_type='application/json',
+ )
self.assertEqual(response.status_code, 200)
- # Ensure `transcript_preferences` was set up in Key correctly if sent through request.
+ # Ensure `transcript_preferences` was set in metadata correctly if sent through request.
+ actual_value = presign_results['calls'][0][CALL_KW]['Params']['Metadata'].get('transcript_preferences')
if is_video_transcript_enabled and transcript_preferences:
- mock_key_instance.set_metadata.assert_any_call('transcript_preferences', json.dumps(transcript_preferences))
+ self.assertEqual(actual_value, json.dumps(transcript_preferences))
else:
- with self.assertRaises(AssertionError):
- mock_key_instance.set_metadata.assert_any_call(
- 'transcript_preferences', json.dumps(transcript_preferences)
- )
+ self.assertEqual(actual_value, None)
@patch.dict("django.conf.settings.FEATURES", {"ENABLE_VIDEO_UPLOAD_PIPELINE": True})
@@ -1644,29 +1552,6 @@ def _test_video_feature(self, flag, key, override_fn, is_enabled):
self.assertEqual(response.json()[key], is_enabled)
-class GetStorageBucketTestCase(TestCase):
- """ This test just check that connection works and returns the bucket.
- It does not involve any mocking and triggers errors if has any import issue.
- """
- @override_settings(AWS_ACCESS_KEY_ID='test_key_id', AWS_SECRET_ACCESS_KEY='test_secret')
- @override_settings(VIDEO_UPLOAD_PIPELINE={
- "VEM_S3_BUCKET": "vem_test_bucket", "BUCKET": "test_bucket", "ROOT_PATH": "test_root"
- })
- def test_storage_bucket(self):
- """ get bucket and generate url. It will not hit actual s3."""
- bucket = storage_service_bucket()
- edx_video_id = 'dummy_video'
- key = storage_service_key(bucket, file_name=edx_video_id)
- upload_url = key.generate_url(
- KEY_EXPIRATION_IN_SECONDS,
- 'PUT',
- headers={'Content-Type': 'mp4'}
- )
-
- self.assertIn("https://vem_test_bucket.s3.amazonaws.com:443/test_root/", upload_url)
- self.assertIn(edx_video_id, upload_url)
-
-
class CourseYoutubeEdxVideoIds(ModuleStoreTestCase):
"""
This test checks youtube videos in a course
diff --git a/cms/djangoapps/contentstore/views/videos.py b/cms/djangoapps/contentstore/views/videos.py
index 2eac141b9c9e..499c73c29a23 100644
--- a/cms/djangoapps/contentstore/views/videos.py
+++ b/cms/djangoapps/contentstore/views/videos.py
@@ -23,8 +23,6 @@
videos_index_html as videos_index_html_source_function,
videos_index_json as videos_index_json_source_function,
videos_post as videos_post_source_function,
- storage_service_bucket as storage_service_bucket_source_function,
- storage_service_key as storage_service_key_source_function,
send_video_status_update as send_video_status_update_source_function,
is_status_update_request as is_status_update_request_source_function,
get_course_youtube_edx_video_ids,
@@ -212,20 +210,6 @@ def videos_post(course, request):
return videos_post_source_function(course, request)
-def storage_service_bucket():
- """
- Exposes helper method without breaking existing bindings/dependencies
- """
- return storage_service_bucket_source_function()
-
-
-def storage_service_key(bucket, file_name):
- """
- Exposes helper method without breaking existing bindings/dependencies
- """
- return storage_service_key_source_function(bucket, file_name)
-
-
def send_video_status_update(updates):
"""
Exposes helper method without breaking existing bindings/dependencies
diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py
index ae7492ddbc91..78c393532305 100644
--- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py
+++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py
@@ -33,7 +33,7 @@
from pytz import UTC
from xblock.core import XBlock
from xblock.fields import Scope
-from .xblock_helpers import get_block_key_dict
+from .xblock_helpers import get_block_key_string
from cms.djangoapps.contentstore.config.waffle import SHOW_REVIEW_RULES_FLAG
from cms.djangoapps.contentstore.helpers import StaticFileNotices
@@ -602,7 +602,7 @@ def sync_library_content(
block_id=f"{block_type}{uuid4().hex[:8]}",
fields={
"upstream": upstream_key,
- "top_level_downstream_parent_key": get_block_key_dict(
+ "top_level_downstream_parent_key": get_block_key_string(
top_level_downstream_parent.usage_key,
),
},
diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/xblock_helpers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/xblock_helpers.py
index 322bf530ab84..82ed7297d5af 100644
--- a/cms/djangoapps/contentstore/xblock_storage_handlers/xblock_helpers.py
+++ b/cms/djangoapps/contentstore/xblock_storage_handlers/xblock_helpers.py
@@ -18,11 +18,11 @@ def usage_key_with_run(usage_key_string: str) -> UsageKey:
return usage_key
-def get_block_key_dict(usage_key: UsageKey) -> dict:
+def get_block_key_string(usage_key: UsageKey) -> str:
"""
- Converts the usage_key in a dict with the form: `{"type": block_type, "id": block_id}`
+ Extract block key from UsageKey in string format: `html:my-id`.
"""
- return BlockKey.from_usage_key(usage_key)._asdict()
+ return str(BlockKey.from_usage_key(usage_key))
def get_tags_count(xblock: XBlock, include_children=False) -> dict[str, int]:
diff --git a/cms/djangoapps/maintenance/__init__.py b/cms/djangoapps/maintenance/__init__.py
deleted file mode 100644
index e69de29bb2d1..000000000000
diff --git a/cms/djangoapps/maintenance/tests.py b/cms/djangoapps/maintenance/tests.py
deleted file mode 100644
index 51f776b00afa..000000000000
--- a/cms/djangoapps/maintenance/tests.py
+++ /dev/null
@@ -1,194 +0,0 @@
-"""
-Tests for the maintenance app views.
-"""
-
-
-import ddt
-from django.conf import settings
-from django.urls import reverse
-
-from common.djangoapps.student.tests.factories import AdminFactory, UserFactory
-from openedx.features.announcements.models import Announcement
-from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
-
-from .views import MAINTENANCE_VIEWS
-
-# This list contains URLs of all maintenance app views.
-MAINTENANCE_URLS = [reverse(view['url']) for view in MAINTENANCE_VIEWS.values()]
-
-
-class TestMaintenanceIndex(ModuleStoreTestCase):
- """
- Tests for maintenance index view.
- """
-
- def setUp(self):
- super().setUp()
- self.user = AdminFactory()
- login_success = self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
- self.assertTrue(login_success)
- self.view_url = reverse('maintenance:maintenance_index')
-
- def test_maintenance_index(self):
- """
- Test that maintenance index view lists all the maintenance app views.
- """
- response = self.client.get(self.view_url)
- self.assertContains(response, 'Maintenance', status_code=200)
-
- # Check that all the expected links appear on the index page.
- for url in MAINTENANCE_URLS:
- self.assertContains(response, url, status_code=200)
-
-
-@ddt.ddt
-class MaintenanceViewTestCase(ModuleStoreTestCase):
- """
- Base class for maintenance view tests.
- """
- view_url = ''
-
- def setUp(self):
- super().setUp()
- self.user = AdminFactory()
- login_success = self.client.login(username=self.user.username, password=self.TEST_PASSWORD)
- self.assertTrue(login_success)
-
- def verify_error_message(self, data, error_message):
- """
- Verify the response contains error message.
- """
- response = self.client.post(self.view_url, data=data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
- self.assertContains(response, error_message, status_code=200)
-
- def tearDown(self):
- """
- Reverse the setup.
- """
- self.client.logout()
- super().tearDown()
-
-
-@ddt.ddt
-class MaintenanceViewAccessTests(MaintenanceViewTestCase):
- """
- Tests for access control of maintenance views.
- """
- @ddt.data(*MAINTENANCE_URLS)
- def test_require_login(self, url):
- """
- Test that maintenance app requires user login.
- """
- # Log out then try to retrieve the page
- self.client.logout()
- response = self.client.get(url)
-
- # Expect a redirect to the login page
- redirect_url = '{login_url}?next={original_url}'.format(
- login_url=settings.LOGIN_URL,
- original_url=url,
- )
-
- # Studio login redirects to LMS login
- self.assertRedirects(response, redirect_url, target_status_code=302)
-
- @ddt.data(*MAINTENANCE_URLS)
- def test_global_staff_access(self, url):
- """
- Test that all maintenance app views are accessible to global staff user.
- """
- response = self.client.get(url)
- self.assertEqual(response.status_code, 200)
-
- @ddt.data(*MAINTENANCE_URLS)
- def test_non_global_staff_access(self, url):
- """
- Test that all maintenance app views are not accessible to non-global-staff user.
- """
- user = UserFactory(username='test', email='test@example.com', password=self.TEST_PASSWORD)
- login_success = self.client.login(username=user.username, password=self.TEST_PASSWORD)
- self.assertTrue(login_success)
-
- response = self.client.get(url)
- self.assertContains(
- response,
- f'Must be {settings.PLATFORM_NAME} staff to perform this action.',
- status_code=403
- )
-
-
-@ddt.ddt
-class TestAnnouncementsViews(MaintenanceViewTestCase):
- """
- Tests for the announcements edit view.
- """
-
- def setUp(self):
- super().setUp()
- self.admin = AdminFactory.create(
- email='staff@edx.org',
- username='admin',
- password=self.TEST_PASSWORD
- )
- self.client.login(username=self.admin.username, password=self.TEST_PASSWORD)
- self.non_staff_user = UserFactory.create(
- email='test@edx.org',
- username='test',
- password=self.TEST_PASSWORD
- )
-
- def test_index(self):
- """
- Test create announcement view
- """
- url = reverse("maintenance:announcement_index")
- response = self.client.get(url)
- self.assertContains(response, '
diff --git a/cms/templates/container_chromeless.html b/cms/templates/container_chromeless.html
index 647a68f32231..3ef229e906e3 100644
--- a/cms/templates/container_chromeless.html
+++ b/cms/templates/container_chromeless.html
@@ -98,13 +98,6 @@
<%static:include path="common/templates/image-modal.underscore" />
- ## The following stylesheets are included for studio-frontend debugging.
- ## Remove this as part of studio frontend deprecation.
- ## https://github.com/openedx/studio-frontend/issues/381
- % if not settings.STUDIO_FRONTEND_CONTAINER_URL:
-
-
- % endif
diff --git a/cms/templates/container_editor.html b/cms/templates/container_editor.html
index e7585d7b9664..5f00d0f61338 100644
--- a/cms/templates/container_editor.html
+++ b/cms/templates/container_editor.html
@@ -77,10 +77,6 @@
<%static:include path="common/templates/image-modal.underscore" />
- % if not settings.STUDIO_FRONTEND_CONTAINER_URL:
-
-
- % endif
% for _, resource in resources:
% if resource['kind'] == 'url' and resource['mimetype'] == 'text/css':
@@ -119,12 +115,13 @@
function (XBlockInfo, EditXBlockModal) {
var decodedActionName = '${action_name|n, decode.utf8}';
var encodedXBlockDetails = ${xblock_info | n, dump_js_escaped_json};
+ var returnTo = '${return_to or "" | n, js_escaped_string}';
if (decodedActionName === 'edit') {
var editXBlockModal = new EditXBlockModal();
var xblockInfoInstance = new XBlockInfo(encodedXBlockDetails);
- editXBlockModal.edit([], xblockInfoInstance, {});
+ editXBlockModal.edit([], xblockInfoInstance, {returnTo: returnTo || null});
}
});
%static:webpack>
diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html
deleted file mode 100644
index 61f524123b48..000000000000
--- a/cms/templates/course_outline.html
+++ /dev/null
@@ -1,332 +0,0 @@
-<%page expression_filter="h"/>
-<%inherit file="base.html" />
-<%def name="online_help_token()"><% return "develop_course" %>%def>
-<%!
-import logging
-from six.moves.urllib.parse import quote
-
-from cms.djangoapps.contentstore.config.waffle_utils import should_show_checklists_quality
-from common.djangoapps.util.date_utils import get_default_time_display
-from django.utils.translation import gettext as _
-from openedx.core.djangolib.js_utils import js_escaped_string, dump_js_escaped_json
-from openedx.core.djangolib.markup import HTML, Text
-from django.urls import reverse
-%>
-<%block name="title">${_("Course Outline")}%block>
-<%block name="bodyclass">is-signedin course view-outline%block>
-
-<%namespace name='static' file='static_content.html'/>
-
-<%block name="requirejs">
- require(["js/factories/outline"], function (OutlineFactory) {
- OutlineFactory(
- ${course_structure | n, dump_js_escaped_json},
- ${initial_state | n, dump_js_escaped_json},
- ${initial_user_clipboard | n, dump_js_escaped_json}
- );
- });
-%block>
-
-<%block name="header_extras">
-
-% for template_name in ['course-outline', 'xblock-string-field-editor', 'basic-modal', 'modal-button', 'course-outline-modal', 'due-date-editor', 'self-paced-due-date-editor', 'release-date-editor', 'grading-editor', 'publish-editor', 'staff-lock-editor', 'unit-access-editor', 'discussion-editor', 'content-visibility-editor', 'verification-access-editor', 'timed-examination-preference-editor', 'access-editor', 'settings-modal-tabs', 'show-correctness-editor', 'highlights-editor', 'highlights-enable-editor', 'course-highlights-enable', 'course-manage-tags', 'course-video-sharing-enable', 'summary-configuration-editor', 'tag-count', 'subsection-share-link-modal-tabs', 'full-page-share-link-editor', 'embed-link-share-link-editor']:
-
-% endfor
-<%static:optional_include_mako file="course_outline_header_extras_post.html" />
-
-% if not settings.STUDIO_FRONTEND_CONTAINER_URL:
-
-
-% endif
-%block>
-
-<%block name="page_alert">
- %if notification_dismiss_url is not None:
-
-
-
-
-
-
${_("This course was created as a re-run. Some manual configuration is needed.")}
-
-
${_("No course content is currently visible, and no learners are enrolled. Be sure to review and reset all dates, including the Course Start Date; set up the course team; review course updates and other assets for dated material; and seed the discussions and wiki.")}
- ${_("This course run is using an upgraded version of edx discussion forum. In order to display the discussions sidebar, discussions xBlocks will no longer be visible to learners.")}
-
- %endif
-
-
- %if deprecated_blocks_info.get('blocks') or deprecated_blocks_info.get('deprecated_enabled_block_types'):
-
-
- ${_("Warning")}
-
-
-
${_("This course uses features that are no longer supported.")}
-
- %if deprecated_blocks_info.get('blocks'):
-
-
${_("You must delete or replace the following components.")}
-
-
- %endif
-
- % if deprecated_blocks_info.get('deprecated_enabled_block_types'):
-
-
- ${Text(_("To avoid errors, {platform_name} strongly recommends that you remove unsupported features from the course advanced settings. To do this, go to the {link_start}Advanced Settings page{link_end}, locate the \"Advanced Module List\" setting, and then delete the following modules from the list.")).format(
- platform_name=static.get_platform_name(),
- link_start=HTML('').format(advance_settings_url=deprecated_blocks_info['advance_settings_url']),
- link_end=HTML("")
- )}
-
-
-
- % endif
-
-
-
- %endif
-
- %if proctoring_errors:
-
-
-
-
-
-
${_("This course has proctored exam settings that are incomplete or invalid.")}
- %endif
-
-
- %if allow_course_reruns and rerun_creator_status and len(in_process_course_actions) > 0:
-
-
${_("Courses Being Processed")}
-
-
- %for course_info in sorted(in_process_course_actions, key=lambda s: s['display_name'].lower() if s['display_name'] is not None else ''):
-
- %if course_info['is_in_progress']:
-
${_("This course run is currently being created.")}
-
-
- ## Translators: This is a status message, used to inform the user of
- ## what the system is doing. This status means that the user has
- ## requested to re-run an existing course, and the system is currently
- ## in the process of duplicating and configuring the existing course
- ## so that it can be re-run.
- ${_("Configuring as re-run")}
-
-
-
-
-
-
${Text(_('The new course will be added to your course list in 5-10 minutes. Return to this page or {link_start}refresh it{link_end} to update the course list. The new course will need some manual configuration.')).format(
- link_start=HTML(''),
- link_end=HTML(''),
- )}
- ## Translators: This is a status message for the course re-runs feature.
- ## When a course admin indicates that a course should be re-run, the system
- ## needs to process the request and prepare the new course. The status of
- ## the process will follow this text.
-
${_("This re-run processing status:")}
-
-
- ${_("Configuration Error")}
-
-
-
-
-
-
${_("A system error occurred while your course was being processed. Please go to the original course to try the re-run again, or contact your PM for assistance.")}
${_('{studio_name} is a hosted solution for our xConsortium partners and selected guests. Courses for which you are a team member appear above for you to edit, while course creator privileges are granted by {platform_name}. Our team will evaluate your request and provide you feedback within 24 hours during the work week.').format(
- studio_name=settings.STUDIO_NAME, platform_name=settings.PLATFORM_NAME)}
${_('{studio_name} is a hosted solution for our xConsortium partners and selected guests. Courses for which you are a team member appear above for you to edit, while course creator privileges are granted by {platform_name}. Our team is has completed evaluating your request.').format(
- studio_name=settings.STUDIO_NAME, platform_name=settings.PLATFORM_NAME,
- )}
-
-
-
-
${_('Your Course Creator Request Status:')}
-
-
-
${_('Your Course Creator request is:')}
-
-
- ${_('Denied')}
- ${_('Your request did not meet the criteria/guidelines specified by {platform_name} Staff.').format(platform_name=settings.PLATFORM_NAME)}
-
${_('{studio_name} is a hosted solution for our xConsortium partners and selected guests. Courses for which you are a team member appear above for you to edit, while course creator privileges are granted by {platform_name}. Our team is currently evaluating your request.').format(
- studio_name=settings.STUDIO_NAME, platform_name=settings.PLATFORM_NAME,
- )}
-
-
-
-
${_('Your Course Creator Request Status:')}
-
-
-
${_('Your Course Creator request is:')}
-
-
- ${_('Pending')}
-
- ${_('Your request is currently being reviewed by {platform_name} staff and should be updated shortly.').format(platform_name=settings.PLATFORM_NAME)}
-
-
-
-
-
-
- % endif
-
- %if archived_courses:
-
- % if type(archived_courses) is list:
- ${static.renderReact(
- component="CourseOrLibraryListing",
- id="react-archived-course-listing",
- props={
- 'items': sorted(archived_courses, key=lambda s: s['display_name'].lower() if s['display_name'] is not None else ''),
- 'linkClass': 'course-link',
- 'idBase': 'archived',
- 'allowReruns': allow_course_reruns and rerun_creator_status and course_creator_status=='granted'
- }
- )}
- % endif
-
- ${static.renderReact(
- component="CourseOrLibraryListing",
- id="react-library-listing",
- props={
- 'items': sorted(libraries, key=lambda s: s['display_name'].lower() if s['display_name'] is not None else ''),
- 'linkClass': 'library-link',
- 'idBase': 'library',
- 'allowReruns': allow_course_reruns and rerun_creator_status and course_creator_status=='granted'
- }
- )}
-
-
- %else:
-
-
-
-
${_("Were you expecting to see a particular library here?")}
-
-
${_('The library creator must give you access to the library. Contact the library creator or administrator for the library you are helping to author.')}
-
-
-
- % if show_new_library_button:
-
-
-
${_('Create Your First Library')}
-
-
${_('Libraries hold a pool of components that can be re-used across multiple courses. Create your first library with the click of a button!')}
${_("Thanks for signing up, {name}!").format(name=user.username)}
-
-
-
-
-
${_("We need to verify your email address")}
-
-
${_('Almost there! In order to complete your sign up we need you to verify your email address ({email}). An activation message and next steps should be waiting for you there.').format(email=user.email)}
-
-
-
-
-
-
-
-
- %endif
-
-%block>
diff --git a/cms/templates/js/show-correctness-editor.underscore b/cms/templates/js/show-correctness-editor.underscore
index 3db6c3c27a5c..1b0dd896747a 100644
--- a/cms/templates/js/show-correctness-editor.underscore
+++ b/cms/templates/js/show-correctness-editor.underscore
@@ -35,6 +35,13 @@
<% } %>
<%- gettext('If the subsection does not have a due date, learners always see their scores when they submit answers to assessments.') %>
+
+
+ <%- gettext('Learners do not see question-level correctness or scores before or after the due date. However, once the due date passes, they can see their overall score for the subsection on the Progress page.') %>
+