Merge branch 'master' into mfarhan/drop-py38-add-py312
This commit is contained in:
@@ -9,13 +9,13 @@
|
||||
|
||||
# Via Django
|
||||
auth.Group:
|
||||
".. no_pii:" : "No PII"
|
||||
".. no_pii:": "No PII"
|
||||
auth.Permission:
|
||||
".. no_pii:" : "No PII"
|
||||
".. no_pii:": "No PII"
|
||||
auth.User:
|
||||
".. pii:": "Contains username, password, and email address, retired in AccountRetirementView"
|
||||
".. pii_types:" : username, email_address, password
|
||||
".. pii_retirement:" : local_api
|
||||
".. pii_types:": username, email_address, password
|
||||
".. pii_retirement:": local_api
|
||||
contenttypes.ContentType:
|
||||
".. no_pii:": "No PII"
|
||||
admin.LogEntry:
|
||||
@@ -27,6 +27,66 @@ sessions.Session:
|
||||
sites.Site:
|
||||
".. no_pii:": "No PII"
|
||||
|
||||
# Automatically generated edx-platform models that can't be annotated
|
||||
calendar_sync.HistoricalUserCalendarSyncConfig:
|
||||
".. no_pii:": "No PII"
|
||||
certificates.HistoricalCertificateAllowlist:
|
||||
".. no_pii:": "No PII"
|
||||
certificates.HistoricalCertificateDateOverride:
|
||||
".. no_pii:": "No PII"
|
||||
certificates.HistoricalCertificateInvalidation:
|
||||
".. no_pii:": "No PII"
|
||||
certificates.HistoricalGeneratedCertificate:
|
||||
".. pii:": "PII can exist in the generated certificate linked to in this model. Certificate data is currently retained."
|
||||
".. pii_types:": "name, username"
|
||||
".. pii_retirement:": "retained"
|
||||
course_apps.HistoricalCourseAppStatus:
|
||||
".. no_pii:": "No PII"
|
||||
course_goals.HistoricalCourseGoal:
|
||||
".. no_pii:": "No PII"
|
||||
course_live.HistoricalCourseLiveConfiguration:
|
||||
".. no_pii:": "No PII"
|
||||
course_modes.HistoricalCourseMode:
|
||||
".. no_pii:": "No PII"
|
||||
course_overviews.HistoricalCourseOverview:
|
||||
".. no_pii:": "No PII"
|
||||
discussions.HistoricalDiscussionsConfiguration:
|
||||
".. no_pii:": "No PII"
|
||||
entitlements.HistoricalCourseEntitlement:
|
||||
".. no_pii:": "No PII"
|
||||
entitlements.HistoricalCourseEntitlementSupportDetail:
|
||||
".. no_pii:": "No PII"
|
||||
experiments.HistoricalExperimentKeyValue:
|
||||
".. no_pii:": "No PII"
|
||||
external_user_ids.HistoricalExternalId:
|
||||
".. no_pii:": "We store external_user_id here, but do not consider that PII under OEP-30."
|
||||
external_user_ids.HistoricalExternalIdType:
|
||||
".. no_pii:": "No PII"
|
||||
grades.HistoricalPersistentSubsectionGradeOverride:
|
||||
".. no_pii:": "No PII"
|
||||
instructor_task.HistoricalInstructorTaskSchedule:
|
||||
".. no_pii:": "No PII"
|
||||
program_enrollments.HistoricalProgramCourseEnrollment:
|
||||
".. no_pii:": "No PII"
|
||||
program_enrollments.HistoricalProgramEnrollment:
|
||||
".. pii:": "PII is found in the external key for a program enrollment"
|
||||
".. pii_types:": "other"
|
||||
".. pii_retirement:": "local_api"
|
||||
programs.HistoricalProgramDiscussionsConfiguration:
|
||||
".. no_pii:": "No PII"
|
||||
programs.HistoricalProgramLiveConfiguration:
|
||||
".. no_pii:": "No PII"
|
||||
schedules.HistoricalSchedule:
|
||||
".. no_pii:": "No PII"
|
||||
split_modulestore_django.HistoricalSplitModulestoreCourseIndex:
|
||||
".. no_pii:": "No PII"
|
||||
student.HistoricalCourseEnrollment:
|
||||
".. no_pii:": "No PII"
|
||||
student.HistoricalManualEnrollmentAudit:
|
||||
".. pii:": "Contains enrolled_email, retired in LMSAccountRetirementView"
|
||||
".. pii_types:": "email_address"
|
||||
".. pii_retirement:": "local_api"
|
||||
|
||||
# Automatically generated models in edx-enterprise that can't be annotated there
|
||||
consent.HistoricalDataSharingConsent:
|
||||
".. pii:": "The username field inherited from Consent contains PII."
|
||||
@@ -45,7 +105,7 @@ enterprise.HistoricalEnterpriseCustomerCatalog:
|
||||
enterprise.HistoricalEnterpriseCustomerEntitlement:
|
||||
".. no_pii:": "No PII"
|
||||
|
||||
# Via ORA2
|
||||
# Via edx-ora2, these can be removed once the models are annotated for real
|
||||
assessment.Assessment:
|
||||
".. no_pii:": "No PII"
|
||||
assessment.AssessmentFeedback:
|
||||
@@ -127,10 +187,24 @@ djcelery.TaskState:
|
||||
djcelery.WorkerState:
|
||||
".. no_pii:": "No PII"
|
||||
|
||||
# Via django-celery-results
|
||||
django_celery_results.ChordCounter:
|
||||
".. no_pii:": "No PII"
|
||||
django_celery_results.GroupResult:
|
||||
".. no_pii:": "No PII"
|
||||
django_celery_results.TaskResult:
|
||||
".. no_pii:": "No PII"
|
||||
|
||||
# Via edx-oauth2-provider https://github.com/edx/edx-oauth2-provider
|
||||
edx_oauth2_provider.TrustedClient:
|
||||
".. no_pii:": "No PII"
|
||||
|
||||
# Via edx-name-affirmation, not part of the openedx org
|
||||
edx_name_affirmation.HistoricalVerifiedName:
|
||||
".. pii:": "Contains name fields."
|
||||
".. pii_types:": "name"
|
||||
".. pii_retirement:": "local_api"
|
||||
|
||||
# Via VAL
|
||||
edxval.CourseVideo:
|
||||
".. no_pii:": "No PII"
|
||||
@@ -149,6 +223,12 @@ edxval.VideoImage:
|
||||
edxval.VideoTranscript:
|
||||
".. no_pii:": "No PII"
|
||||
|
||||
# Via PyLTI1p3
|
||||
lti1p3_tool_config.LtiTool:
|
||||
".. no_pii:": "No PII"
|
||||
lti1p3_tool_config.LtiToolKey:
|
||||
".. no_pii:": "No PII"
|
||||
|
||||
# Via Milestones
|
||||
milestones.CourseContentMilestone:
|
||||
".. no_pii:": "No PII"
|
||||
@@ -190,6 +270,10 @@ oauth2_provider.Grant:
|
||||
".. pii:": "Contains 3rd party authentication secrets. Retired in DeactivateLogoutView."
|
||||
".. pii_types:": password, other
|
||||
".. pii_retirement:": local_api
|
||||
oauth2_provider.IDToken:
|
||||
".. pii:": "Contains 3rd party authentication secrets, currently this is retained until the token times out, but should be retired explicitly with the other models from this package."
|
||||
".. pii_types:": password, other
|
||||
".. pii_retirement:": retained
|
||||
oauth2_provider.RefreshToken:
|
||||
".. pii:": "Contains 3rd party authentication secrets. Retired in DeactivateLogoutView."
|
||||
".. pii_types:": password, other
|
||||
@@ -250,6 +334,8 @@ submissions.StudentItem:
|
||||
".. no_pii:": "No PII"
|
||||
submissions.Submission:
|
||||
".. no_pii:": "No PII"
|
||||
submissions.TeamSubmission:
|
||||
".. no_pii:": "No PII"
|
||||
|
||||
# Via sorl-thumbnail https://github.com/jazzband/sorl-thumbnail
|
||||
thumbnail.KVStore:
|
||||
|
||||
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -22,7 +22,7 @@ openedx/core/djangoapps/enrollments/ @openedx/2U-
|
||||
openedx/core/djangoapps/heartbeat/
|
||||
openedx/core/djangoapps/oauth_dispatch
|
||||
openedx/core/djangoapps/user_api/ @openedx/2U-aperture
|
||||
openedx/core/djangoapps/user_authn/ @openedx/2U-vanguards
|
||||
openedx/core/djangoapps/user_authn/ @openedx/2U-infinity
|
||||
openedx/core/djangoapps/verified_track_content/ @openedx/2u-infinity
|
||||
openedx/features/course_experience/
|
||||
xmodule/
|
||||
|
||||
6
.github/renovate.json5
vendored
6
.github/renovate.json5
vendored
@@ -32,7 +32,11 @@
|
||||
// When adding an ignoreDep, please include a reason and a public link that we can use to follow up and ensure
|
||||
// that the ignoreDep is removed.
|
||||
// This can be done as a comment within the ignoreDeps list.
|
||||
"ignoreDeps": [],
|
||||
"ignoreDeps": [
|
||||
// karma-spec-reporter>0.20.0 does not seem compatible with our super-old 2016 Karma version (0.13.22).
|
||||
// Ticket link: None, as upgrading Karma does not strike as worth the benefit.
|
||||
"karma-spec-reporter"
|
||||
],
|
||||
"timezone": "America/New_York",
|
||||
"prConcurrentLimit": 3,
|
||||
"enabledManagers": ["npm"]
|
||||
|
||||
@@ -15,7 +15,7 @@ defaults:
|
||||
jobs:
|
||||
check-requirements:
|
||||
name: Compile requirements
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
steps:
|
||||
# Only run remaining steps if there are changes to requirements/**
|
||||
|
||||
2
.github/workflows/ci-static-analysis.yml
vendored
2
.github/workflows/ci-static-analysis.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.11"
|
||||
os: ["ubuntu-22.04"]
|
||||
os: ["ubuntu-24.04"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
4
.github/workflows/js-tests.yml
vendored
4
.github/workflows/js-tests.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
node-version: [18, 20]
|
||||
node-version: [20]
|
||||
python-version:
|
||||
- "3.11"
|
||||
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
|
||||
- name: Setup npm
|
||||
run: npm i -g npm@10.5.x
|
||||
run: npm i -g npm@10.7.x
|
||||
|
||||
- name: Install Firefox 123.0
|
||||
run: |
|
||||
|
||||
4
.github/workflows/migrations-check.yml
vendored
4
.github/workflows/migrations-check.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-22.04]
|
||||
os: [ubuntu-24.04]
|
||||
python-version:
|
||||
- "3.11"
|
||||
# 'pinned' is used to install the latest patch version of Django
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
if: always()
|
||||
needs:
|
||||
- check_migrations
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Decide whether the needed jobs succeeded or failed
|
||||
# uses: re-actors/alls-green@v1.2.1
|
||||
|
||||
4
.github/workflows/pylint-checks.yml
vendored
4
.github/workflows/pylint-checks.yml
vendored
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
jobs:
|
||||
run-pylint:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
@@ -75,7 +75,7 @@ jobs:
|
||||
if: always()
|
||||
needs:
|
||||
- run-pylint
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Decide whether the needed jobs succeeded or failed
|
||||
# uses: re-actors/alls-green@v1.2.1
|
||||
|
||||
2
.github/workflows/quality-checks.yml
vendored
2
.github/workflows/quality-checks.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-22.04]
|
||||
os: [ubuntu-24.04]
|
||||
python-version:
|
||||
- "3.11"
|
||||
node-version: [20]
|
||||
|
||||
6
.github/workflows/static-assets-check.yml
vendored
6
.github/workflows/static-assets-check.yml
vendored
@@ -12,11 +12,11 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-22.04]
|
||||
os: [ubuntu-24.04]
|
||||
python-version:
|
||||
- "3.11"
|
||||
node-version: [18, 20]
|
||||
npm-version: [10.5.x]
|
||||
node-version: [20]
|
||||
npm-version: [10.7.x]
|
||||
mongo-version:
|
||||
- "7.0"
|
||||
|
||||
|
||||
1
.github/workflows/unit-test-shards.json
vendored
1
.github/workflows/unit-test-shards.json
vendored
@@ -239,7 +239,6 @@
|
||||
"cms/djangoapps/cms_user_tasks/",
|
||||
"cms/djangoapps/course_creators/",
|
||||
"cms/djangoapps/export_course_metadata/",
|
||||
"cms/djangoapps/maintenance/",
|
||||
"cms/djangoapps/models/",
|
||||
"cms/djangoapps/pipeline_js/",
|
||||
"cms/djangoapps/xblock_config/",
|
||||
|
||||
62
.github/workflows/unit-tests.yml
vendored
62
.github/workflows/unit-tests.yml
vendored
@@ -15,7 +15,7 @@ concurrency:
|
||||
jobs:
|
||||
run-tests:
|
||||
name: ${{ matrix.shard_name }}(py=${{ matrix.python-version }},dj=${{ matrix.django-version }},mongo=${{ matrix.mongo-version }})
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ${{ matrix.os-version }}
|
||||
strategy:
|
||||
matrix:
|
||||
python-version:
|
||||
@@ -43,22 +43,27 @@ jobs:
|
||||
- "xmodule-with-cms"
|
||||
mongo-version:
|
||||
- "7.0"
|
||||
os-version:
|
||||
- ubuntu-24.04
|
||||
|
||||
# We only need to test older versions of Mongo with modules that directly interface with Mongo (that is: xmodule.modulestore)
|
||||
# This code is left here as an example for future refernce in case we need to reduce the number of shards we're
|
||||
# testing but still have good confidence with older versions of mongo. We use Mongo 4.4 in the example.
|
||||
# It's useful to run some subset of the tests on the older version of Ubuntu
|
||||
# so that we don't spend too many resources on this but can find major issues quickly
|
||||
# while we're in a situation where we support two versions. This section may be commented
|
||||
# out when not in use to easily add/drop future support for any given major dependency.
|
||||
#
|
||||
# exclude:
|
||||
# - mongo-version: "4.4"
|
||||
# include:
|
||||
# - shard_name: "xmodule-with-cms"
|
||||
# python-version: "3.11"
|
||||
# django-version: "pinned"
|
||||
# mongo-version: "4.4"
|
||||
# - shard_name: "xmodule-with-lms"
|
||||
# python-version: "3.11"
|
||||
# django-version: "pinned"
|
||||
# mongo-version: "4.4"
|
||||
# We're testing the older version of Ubuntu and running the xmodule tests since those rely on many
|
||||
# dependent complex libraries and will hopefully catch most issues quickly.
|
||||
include:
|
||||
- shard_name: "xmodule-with-cms"
|
||||
python-version: "3.11"
|
||||
django-version: "pinned"
|
||||
mongo-version: "7.0"
|
||||
os-version: "ubuntu-22.04"
|
||||
- shard_name: "xmodule-with-lms"
|
||||
python-version: "3.11"
|
||||
django-version: "pinned"
|
||||
mongo-version: "7.0"
|
||||
os-version: "ubuntu-22.04"
|
||||
|
||||
steps:
|
||||
- name: checkout repo
|
||||
@@ -90,19 +95,10 @@ jobs:
|
||||
activate = 1
|
||||
EOF
|
||||
|
||||
- name: install mongo version
|
||||
run: |
|
||||
if [[ "${{ matrix.mongo-version }}" != "4.4" ]]; then
|
||||
wget -qO - https://www.mongodb.org/static/pgp/server-${{ matrix.mongo-version }}.asc | sudo apt-key add -
|
||||
echo "deb https://repo.mongodb.org/apt/ubuntu focal/mongodb-org/${{ matrix.mongo-version }} multiverse" | sudo tee /etc/apt/sources.list.d/mongodb-org-${{ matrix.mongo-version }}.list
|
||||
sudo apt-get update && sudo apt-get install -y mongodb-org="${{ matrix.mongo-version }}.*"
|
||||
fi
|
||||
|
||||
- name: start mongod server for tests
|
||||
run: |
|
||||
sudo mkdir -p /data/db
|
||||
sudo chmod -R a+rw /data/db
|
||||
mongod &
|
||||
- name: Start MongoDB
|
||||
uses: supercharge/mongodb-github-action@1.11.0
|
||||
with:
|
||||
mongodb-version: ${{ matrix.mongo-version }}
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v5
|
||||
@@ -164,7 +160,7 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
collect-and-verify:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Setup Python
|
||||
@@ -229,7 +225,7 @@ jobs:
|
||||
# https://github.com/orgs/community/discussions/33579
|
||||
success:
|
||||
name: Unit tests successful
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
if: always()
|
||||
needs: [run-tests]
|
||||
steps:
|
||||
@@ -240,7 +236,7 @@ jobs:
|
||||
jobs: ${{ toJSON(needs) }}
|
||||
|
||||
compile-warnings-report:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [run-tests]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
@@ -268,7 +264,7 @@ jobs:
|
||||
overwrite: true
|
||||
|
||||
merge-artifacts:
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [compile-warnings-report]
|
||||
steps:
|
||||
- name: Merge Pytest Warnings JSON Artifacts
|
||||
@@ -288,7 +284,7 @@ jobs:
|
||||
# Combine and upload coverage reports.
|
||||
coverage:
|
||||
if: (github.repository == 'edx/edx-platform-private') || (github.repository == 'openedx/edx-platform' && (startsWith(github.base_ref, 'open-release') == false))
|
||||
runs-on: ubuntu-22.04
|
||||
runs-on: ubuntu-24.04
|
||||
needs: [run-tests]
|
||||
strategy:
|
||||
matrix:
|
||||
|
||||
2
.github/workflows/verify-dunder-init.yml
vendored
2
.github/workflows/verify-dunder-init.yml
vendored
@@ -1,4 +1,4 @@
|
||||
name: CI
|
||||
name: Verify Dunder __init__.py Files
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
|
||||
@@ -3,7 +3,7 @@ version: 2
|
||||
build:
|
||||
os: "ubuntu-22.04"
|
||||
tools:
|
||||
python: "3.12"
|
||||
python: "3.11"
|
||||
|
||||
sphinx:
|
||||
configuration: docs/conf.py
|
||||
|
||||
84
Dockerfile
84
Dockerfile
@@ -48,36 +48,36 @@ RUN echo "locales locales/locales_to_be_generated multiselect en_US.UTF-8 UTF-8"
|
||||
|
||||
# Setting up ppa deadsnakes to get python 3.11
|
||||
RUN apt-get update && \
|
||||
apt-get install -y software-properties-common && \
|
||||
apt-add-repository -y ppa:deadsnakes/ppa
|
||||
apt-get install -y software-properties-common && \
|
||||
apt-add-repository -y ppa:deadsnakes/ppa
|
||||
|
||||
# Install requirements that are absolutely necessary
|
||||
RUN apt-get update && \
|
||||
apt-get -y dist-upgrade && \
|
||||
apt-get -y install --no-install-recommends \
|
||||
python3-pip \
|
||||
python3.11 \
|
||||
# python3-dev: required for building mysqlclient python package
|
||||
python3.11-dev \
|
||||
python3.11-venv \
|
||||
libpython3.11 \
|
||||
libpython3.11-stdlib \
|
||||
libmysqlclient21 \
|
||||
# libmysqlclient-dev: required for building mysqlclient python package
|
||||
libmysqlclient-dev \
|
||||
pkg-config \
|
||||
libssl1.1 \
|
||||
libxmlsec1-openssl \
|
||||
# lynx: Required by https://github.com/openedx/edx-platform/blob/b489a4ecb122/openedx/core/lib/html_to_text.py#L16
|
||||
lynx \
|
||||
ntp \
|
||||
git \
|
||||
build-essential \
|
||||
gettext \
|
||||
gfortran \
|
||||
graphviz \
|
||||
locales \
|
||||
swig \
|
||||
python3-pip \
|
||||
python3.11 \
|
||||
# python3-dev: required for building mysqlclient python package
|
||||
python3.11-dev \
|
||||
python3.11-venv \
|
||||
libpython3.11 \
|
||||
libpython3.11-stdlib \
|
||||
libmysqlclient21 \
|
||||
# libmysqlclient-dev: required for building mysqlclient python package
|
||||
libmysqlclient-dev \
|
||||
pkg-config \
|
||||
libssl1.1 \
|
||||
libxmlsec1-openssl \
|
||||
# lynx: Required by https://github.com/openedx/edx-platform/blob/b489a4ecb122/openedx/core/lib/html_to_text.py#L16
|
||||
lynx \
|
||||
ntp \
|
||||
git \
|
||||
build-essential \
|
||||
gettext \
|
||||
gfortran \
|
||||
graphviz \
|
||||
locales \
|
||||
swig \
|
||||
&& \
|
||||
apt-get clean all && \
|
||||
rm -rf /var/lib/apt/*
|
||||
@@ -93,19 +93,19 @@ FROM minimal-system as builder-production
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get -y install --no-install-recommends \
|
||||
curl \
|
||||
libssl-dev \
|
||||
libffi-dev \
|
||||
libfreetype6-dev \
|
||||
libgeos-dev \
|
||||
libgraphviz-dev \
|
||||
libjpeg8-dev \
|
||||
liblapack-dev \
|
||||
libpng-dev \
|
||||
libsqlite3-dev \
|
||||
libxml2-dev \
|
||||
libxmlsec1-dev \
|
||||
libxslt1-dev
|
||||
curl \
|
||||
libssl-dev \
|
||||
libffi-dev \
|
||||
libfreetype6-dev \
|
||||
libgeos-dev \
|
||||
libgraphviz-dev \
|
||||
libjpeg8-dev \
|
||||
liblapack-dev \
|
||||
libpng-dev \
|
||||
libsqlite3-dev \
|
||||
libxml2-dev \
|
||||
libxmlsec1-dev \
|
||||
libxslt1-dev
|
||||
|
||||
# Setup python virtual environment
|
||||
# It is already 'activated' because $VIRTUAL_ENV/bin was put on $PATH
|
||||
@@ -118,8 +118,8 @@ RUN pip install -r requirements/pip.txt
|
||||
RUN pip install -r requirements/edx/base.txt
|
||||
|
||||
# Install node and npm
|
||||
RUN nodeenv /edx/app/edxapp/nodeenv --node=18.19.0 --prebuilt
|
||||
RUN npm install -g npm@10.5.x
|
||||
RUN nodeenv /edx/app/edxapp/nodeenv --node=20.15.1 --prebuilt
|
||||
RUN npm install -g npm@10.7.x
|
||||
|
||||
# This script is used by an npm post-install hook.
|
||||
# We copy it into the image now so that it will be available when we run `npm install` in the next step.
|
||||
@@ -178,8 +178,8 @@ FROM base as development
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get -y install --no-install-recommends \
|
||||
# wget is used in Makefile for common_constraints.txt
|
||||
wget \
|
||||
# wget is used in Makefile for common_constraints.txt
|
||||
wget \
|
||||
&& \
|
||||
apt-get clean all && \
|
||||
rm -rf /var/lib/apt/*
|
||||
|
||||
16
README.rst
16
README.rst
@@ -70,6 +70,11 @@ complexity of Open edX configuration and deployment into their own hands.
|
||||
System Dependencies
|
||||
-------------------
|
||||
|
||||
OS:
|
||||
* Ubuntu 22.04
|
||||
|
||||
* Ubuntu 24.04
|
||||
|
||||
Interperters/Tools:
|
||||
|
||||
* Python 3.11
|
||||
@@ -100,6 +105,17 @@ Language Packages:
|
||||
- ``pip install -r requirements/edx/base.txt`` (production)
|
||||
- ``pip install -r requirements/edx/dev.txt`` (development)
|
||||
|
||||
Some Python packages have system dependencies. For example, installing these packages on Debian or Ubuntu will require first running ``sudo apt install python3-dev default-libmysqlclient-dev build-essential pkg-config`` to satisfy the requirements of the ``mysqlclient`` Python package.
|
||||
|
||||
Codejail Setup
|
||||
--------------
|
||||
|
||||
As a part of the baremetal setup, you will need to configure your system to
|
||||
work properly with codejail. See the `codejail installation steps`_ for more
|
||||
details.
|
||||
|
||||
.. _codejail installation steps: https://github.com/openedx/codejail?tab=readme-ov-file#installation
|
||||
|
||||
Build Steps
|
||||
-----------
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ metadata:
|
||||
- url: "https://docs.openedx.org"
|
||||
title: "Documentation"
|
||||
icon: "Web"
|
||||
annotations:
|
||||
openedx.org/release: "master"
|
||||
spec:
|
||||
owner: group:wg-maintenance-edx-platform
|
||||
type: 'service'
|
||||
|
||||
@@ -25,6 +25,7 @@ from common.djangoapps.util.date_utils import get_default_time_display
|
||||
from common.djangoapps.util.json_request import JsonResponse
|
||||
from openedx.core.djangoapps.contentserver.caching import del_cached_content
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
from openedx_filters.course_authoring.filters import LMSPageURLRequested
|
||||
from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.exceptions import NotFoundError # lint-amnesty, pylint: disable=wrong-import-order
|
||||
@@ -714,7 +715,15 @@ def get_asset_json(display_name, content_type, date, location, thumbnail_locatio
|
||||
Helper method for formatting the asset information to send to client.
|
||||
'''
|
||||
asset_url = StaticContent.serialize_asset_key_with_slash(location)
|
||||
external_url = urljoin(configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL), asset_url)
|
||||
|
||||
## .. filter_implemented_name: LMSPageURLRequested
|
||||
## .. filter_type: org.openedx.course_authoring.lms.page.url.requested.v1
|
||||
lms_root, _ = LMSPageURLRequested.run_filter(
|
||||
url=configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL),
|
||||
org=location.org,
|
||||
)
|
||||
|
||||
external_url = urljoin(lms_root, asset_url)
|
||||
portable_url = StaticContent.get_static_path_from_location(location)
|
||||
usage_locations = [] if usage is None else usage
|
||||
return {
|
||||
|
||||
@@ -256,7 +256,8 @@ class SearchIndexerBase(metaclass=ABCMeta):
|
||||
# Now index the content
|
||||
for item in structure.get_children():
|
||||
prepare_item_index(item, groups_usage_info=groups_usage_info)
|
||||
searcher.index(items_index, request_timeout=timeout)
|
||||
if items_index:
|
||||
searcher.index(items_index, request_timeout=timeout)
|
||||
cls.remove_deleted_items(searcher, structure_key, indexed_items)
|
||||
except Exception as err: # pylint: disable=broad-except
|
||||
# broad exception so that index operation does not prevent the rest of the application from working
|
||||
|
||||
@@ -3,14 +3,17 @@ Helper methods for Studio views.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import logging
|
||||
import pathlib
|
||||
import urllib
|
||||
from lxml import etree
|
||||
from mimetypes import guess_type
|
||||
import re
|
||||
|
||||
from attrs import frozen, Factory
|
||||
from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.utils.translation import gettext as _
|
||||
from opaque_keys.edx.keys import AssetKey, CourseKey, UsageKey
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from opaque_keys.edx.locator import DefinitionLocator, LocalId
|
||||
from xblock.core import XBlock
|
||||
from xblock.fields import ScopeIds
|
||||
@@ -22,6 +25,7 @@ from xmodule.modulestore.django import modulestore
|
||||
from xmodule.xml_block import XmlMixin
|
||||
|
||||
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
|
||||
from cms.lib.xblock.upstream_sync import UpstreamLink, UpstreamLinkException, fetch_customizable_fields
|
||||
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
|
||||
import openedx.core.djangoapps.content_staging.api as content_staging_api
|
||||
import openedx.core.djangoapps.content_tagging.api as content_tagging_api
|
||||
@@ -30,6 +34,10 @@ from .utils import reverse_course_url, reverse_library_url, reverse_usage_url
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
# Note: Grader types are used throughout the platform but most usages are simply in-line
|
||||
# strings. In addition, new grader types can be defined on the fly anytime one is needed
|
||||
# (because they're just strings). This dict is an attempt to constrain the sprawl in Studio.
|
||||
@@ -184,6 +192,10 @@ def xblock_type_display_name(xblock, default_display_name=None):
|
||||
# description like "Multiple Choice Problem", but that won't work if our 'block' argument is just the block_type
|
||||
# string ("problem").
|
||||
return _('Problem')
|
||||
elif category == 'library_v2':
|
||||
return _('Library Content')
|
||||
elif category == 'itembank':
|
||||
return _('Problem Bank')
|
||||
component_class = XBlock.load_class(category)
|
||||
if hasattr(component_class, 'display_name') and component_class.display_name.default:
|
||||
return _(component_class.display_name.default) # lint-amnesty, pylint: disable=translation-of-non-string
|
||||
@@ -261,7 +273,6 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
|
||||
empty, and (2) a summary of changes made to static files in the destination
|
||||
course.
|
||||
"""
|
||||
|
||||
from cms.djangoapps.contentstore.views.preview import _load_preview_block
|
||||
|
||||
if not content_staging_api:
|
||||
@@ -271,7 +282,6 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
|
||||
# Clipboard is empty or expired/error/loading
|
||||
return None, StaticFileNotices()
|
||||
olx_str = content_staging_api.get_staged_content_olx(user_clipboard.content.id)
|
||||
static_files = content_staging_api.get_staged_content_static_files(user_clipboard.content.id)
|
||||
node = etree.fromstring(olx_str)
|
||||
store = modulestore()
|
||||
with store.bulk_operations(parent_key.course_key):
|
||||
@@ -282,32 +292,102 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) ->
|
||||
node,
|
||||
parent_xblock,
|
||||
store,
|
||||
user_id=request.user.id,
|
||||
user=request.user,
|
||||
slug_hint=user_clipboard.source_usage_key.block_id,
|
||||
copied_from_block=str(user_clipboard.source_usage_key),
|
||||
copied_from_version_num=user_clipboard.content.version_num,
|
||||
tags=user_clipboard.content.tags,
|
||||
)
|
||||
# Now handle static files that need to go into Files & Uploads:
|
||||
notices = _import_files_into_course(
|
||||
course_key=parent_key.context_key,
|
||||
staged_content_id=user_clipboard.content.id,
|
||||
static_files=static_files,
|
||||
)
|
||||
|
||||
# Now handle static files that need to go into Files & Uploads.
|
||||
static_files = content_staging_api.get_staged_content_static_files(user_clipboard.content.id)
|
||||
notices, substitutions = _import_files_into_course(
|
||||
course_key=parent_key.context_key,
|
||||
staged_content_id=user_clipboard.content.id,
|
||||
static_files=static_files,
|
||||
usage_key=new_xblock.scope_ids.usage_id,
|
||||
)
|
||||
|
||||
# Rewrite the OLX's static asset references to point to the new
|
||||
# locations for those assets. See _import_files_into_course for more
|
||||
# info on why this is necessary.
|
||||
if hasattr(new_xblock, 'data') and substitutions:
|
||||
data_with_substitutions = new_xblock.data
|
||||
for old_static_ref, new_static_ref in substitutions.items():
|
||||
data_with_substitutions = data_with_substitutions.replace(
|
||||
old_static_ref,
|
||||
new_static_ref,
|
||||
)
|
||||
new_xblock.data = data_with_substitutions
|
||||
store.update_item(new_xblock, request.user.id)
|
||||
|
||||
return new_xblock, notices
|
||||
|
||||
|
||||
def _fetch_and_set_upstream_link(
|
||||
copied_from_block: str,
|
||||
copied_from_version_num: int,
|
||||
temp_xblock: XBlock,
|
||||
user: User
|
||||
):
|
||||
"""
|
||||
Fetch and set upstream link for the given xblock. This function handles following cases:
|
||||
* the xblock is copied from a v2 library; the library block is set as upstream.
|
||||
* the xblock is copied from a course; no upstream is set, only copied_from_block is set.
|
||||
* the xblock is copied from a course where the source block was imported from a library; the original libary block
|
||||
is set as upstream.
|
||||
"""
|
||||
# Try to link the pasted block (downstream) to the copied block (upstream).
|
||||
temp_xblock.upstream = copied_from_block
|
||||
try:
|
||||
UpstreamLink.get_for_block(temp_xblock)
|
||||
except UpstreamLinkException:
|
||||
# Usually this will fail. For example, if the copied block is a modulestore course block, it can't be an
|
||||
# upstream. That's fine! Instead, we store a reference to where this block was copied from, in the
|
||||
# 'copied_from_block' field (from AuthoringMixin).
|
||||
|
||||
# In case if the source block was imported from a library, we need to check its upstream
|
||||
# and set the same upstream link for the new block.
|
||||
source_descriptor = modulestore().get_item(UsageKey.from_string(copied_from_block))
|
||||
if source_descriptor.upstream:
|
||||
_fetch_and_set_upstream_link(
|
||||
source_descriptor.upstream,
|
||||
source_descriptor.upstream_version,
|
||||
temp_xblock,
|
||||
user,
|
||||
)
|
||||
else:
|
||||
# else we store a reference to where this block was copied from, in the 'copied_from_block'
|
||||
# field (from AuthoringMixin).
|
||||
temp_xblock.upstream = None
|
||||
temp_xblock.copied_from_block = copied_from_block
|
||||
else:
|
||||
# But if it doesn't fail, then populate the `upstream_version` field based on what was copied. Note that
|
||||
# this could be the latest published version, or it could be an an even newer draft version.
|
||||
temp_xblock.upstream_version = copied_from_version_num
|
||||
# Also, fetch upstream values (`upstream_display_name`, etc.).
|
||||
# Recall that the copied block could be a draft. So, rather than fetching from the published upstream (which
|
||||
# could be older), fetch from the copied block itself. That way, if an author customizes a field, but then
|
||||
# later wants to restore it, it will restore to the value that the field had when the block was pasted. Of
|
||||
# course, if the author later syncs updates from a *future* published upstream version, then that will fetch
|
||||
# new values from the published upstream content.
|
||||
fetch_customizable_fields(upstream=temp_xblock, downstream=temp_xblock, user=user)
|
||||
|
||||
|
||||
def _import_xml_node_to_parent(
|
||||
node,
|
||||
parent_xblock: XBlock,
|
||||
# The modulestore we're using
|
||||
store,
|
||||
# The ID of the user who is performing this operation
|
||||
user_id: int,
|
||||
# The user who is performing this operation
|
||||
user: User,
|
||||
# Hint to use as usage ID (block_id) for the new XBlock
|
||||
slug_hint: str | None = None,
|
||||
# UsageKey of the XBlock that this one is a copy of
|
||||
copied_from_block: str | None = None,
|
||||
# Positive int version of source block, if applicable (e.g., library block).
|
||||
# Zero if not applicable (e.g., course block).
|
||||
copied_from_version_num: int = 0,
|
||||
# Content tags applied to the source XBlock(s)
|
||||
tags: dict[str, str] | None = None,
|
||||
) -> XBlock:
|
||||
@@ -315,6 +395,8 @@ def _import_xml_node_to_parent(
|
||||
Given an XML node representing a serialized XBlock (OLX), import it into modulestore 'store' as a child of the
|
||||
specified parent block. Recursively copy children as needed.
|
||||
"""
|
||||
# pylint: disable=too-many-statements
|
||||
|
||||
runtime = parent_xblock.runtime
|
||||
parent_key = parent_xblock.scope_ids.usage_id
|
||||
block_type = node.tag
|
||||
@@ -366,6 +448,10 @@ def _import_xml_node_to_parent(
|
||||
temp_xblock = xblock_class.parse_xml(node_without_children, runtime, keys)
|
||||
child_nodes = list(node)
|
||||
|
||||
if issubclass(xblock_class, XmlMixin) and "x-is-pointer-node" in getattr(temp_xblock, "data", ""):
|
||||
# Undo the "pointer node" hack if needed (e.g. for capa problems)
|
||||
temp_xblock.data = re.sub(r'([^>]+) x-is-pointer-node="no"', r'\1', temp_xblock.data, count=1)
|
||||
|
||||
# Restore the original id_generator
|
||||
runtime.id_generator = original_id_generator
|
||||
|
||||
@@ -373,19 +459,18 @@ def _import_xml_node_to_parent(
|
||||
raise NotImplementedError("We don't yet support pasting XBlocks with children")
|
||||
temp_xblock.parent = parent_key
|
||||
if copied_from_block:
|
||||
# Store a reference to where this block was copied from, in the 'copied_from_block' field (AuthoringMixin)
|
||||
temp_xblock.copied_from_block = copied_from_block
|
||||
_fetch_and_set_upstream_link(copied_from_block, copied_from_version_num, temp_xblock, user)
|
||||
# Save the XBlock into modulestore. We need to save the block and its parent for this to work:
|
||||
new_xblock = store.update_item(temp_xblock, user_id, allow_not_found=True)
|
||||
new_xblock = store.update_item(temp_xblock, user.id, allow_not_found=True)
|
||||
parent_xblock.children.append(new_xblock.location)
|
||||
store.update_item(parent_xblock, user_id)
|
||||
store.update_item(parent_xblock, user.id)
|
||||
|
||||
children_handled = False
|
||||
if hasattr(new_xblock, 'studio_post_paste'):
|
||||
# Allow an XBlock to do anything fancy it may need to when pasted from the clipboard.
|
||||
# These blocks may handle their own children or parenting if needed. Let them return booleans to
|
||||
# let us know if we need to handle these or not.
|
||||
children_handed = new_xblock.studio_post_paste(store, node)
|
||||
children_handled = new_xblock.studio_post_paste(store, node)
|
||||
|
||||
if not children_handled:
|
||||
for child_node in child_nodes:
|
||||
@@ -394,13 +479,20 @@ def _import_xml_node_to_parent(
|
||||
child_node,
|
||||
new_xblock,
|
||||
store,
|
||||
user_id=user_id,
|
||||
user=user,
|
||||
copied_from_block=str(child_copied_from),
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
# Copy content tags to the new xblock
|
||||
if copied_from_block and tags:
|
||||
if new_xblock.upstream:
|
||||
# If this block is synced from an upstream (e.g. library content),
|
||||
# copy the tags from the upstream as ready-only
|
||||
content_tagging_api.copy_tags_as_read_only(
|
||||
new_xblock.upstream,
|
||||
new_xblock.location,
|
||||
)
|
||||
elif copied_from_block and tags:
|
||||
object_tags = tags.get(str(copied_from_block))
|
||||
if object_tags:
|
||||
content_tagging_api.set_all_object_tags(
|
||||
@@ -415,11 +507,21 @@ def _import_files_into_course(
|
||||
course_key: CourseKey,
|
||||
staged_content_id: int,
|
||||
static_files: list[content_staging_api.StagedContentFileData],
|
||||
) -> StaticFileNotices:
|
||||
usage_key: UsageKey,
|
||||
) -> tuple[StaticFileNotices, dict[str, str]]:
|
||||
"""
|
||||
For the given staged static asset files (which are in "Staged Content" such as the user's clipbaord, but which
|
||||
need to end up in the course's Files & Uploads page), import them into the destination course, unless they already
|
||||
For the given staged static asset files (which are in "Staged Content" such
|
||||
as the user's clipbaord, but which need to end up in the course's Files &
|
||||
Uploads page), import them into the destination course, unless they already
|
||||
exist.
|
||||
|
||||
This function returns a tuple of StaticFileNotices (assets added, errors,
|
||||
conflicts), and static asset path substitutions that should be made in the
|
||||
OLX in order to paste this content into this course. The latter is for the
|
||||
case in which we're brining content in from a v2 library, which stores
|
||||
static assets locally to a Component and needs to go into a subdirectory
|
||||
when pasting into a course to avoid overwriting commonly named things, e.g.
|
||||
"figure1.png".
|
||||
"""
|
||||
# List of files that were newly added to the destination course
|
||||
new_files = []
|
||||
@@ -427,17 +529,25 @@ def _import_files_into_course(
|
||||
conflicting_files = []
|
||||
# List of files that had an error (shouldn't happen unless we have some kind of bug)
|
||||
error_files = []
|
||||
|
||||
# Store a mapping of asset URLs that need to be modified for the destination
|
||||
# assets. This is necessary when you take something from a library and paste
|
||||
# it into a course, because we need to translate Component-local static
|
||||
# assets and shove them into the Course's global Files & Uploads space in a
|
||||
# nested directory structure.
|
||||
substitutions = {}
|
||||
for file_data_obj in static_files:
|
||||
if not isinstance(file_data_obj.source_key, AssetKey):
|
||||
# This static asset was managed by the XBlock and instead of being added to "Files & Uploads", it is stored
|
||||
# using some other system. We could make it available via runtime.resources_fs during XML parsing, but it's
|
||||
# not needed here.
|
||||
continue
|
||||
# At this point, we know this is a "Files & Uploads" asset that we may need to copy into the course:
|
||||
try:
|
||||
result = _import_file_into_course(course_key, staged_content_id, file_data_obj)
|
||||
result, substitution_for_file = _import_file_into_course(
|
||||
course_key,
|
||||
staged_content_id,
|
||||
file_data_obj,
|
||||
usage_key,
|
||||
)
|
||||
if result is True:
|
||||
new_files.append(file_data_obj.filename)
|
||||
substitutions.update(substitution_for_file)
|
||||
elif result is None:
|
||||
pass # This file already exists; no action needed.
|
||||
else:
|
||||
@@ -445,25 +555,45 @@ def _import_files_into_course(
|
||||
except Exception: # lint-amnesty, pylint: disable=broad-except
|
||||
error_files.append(file_data_obj.filename)
|
||||
log.exception(f"Failed to import Files & Uploads file {file_data_obj.filename}")
|
||||
return StaticFileNotices(
|
||||
|
||||
notices = StaticFileNotices(
|
||||
new_files=new_files,
|
||||
conflicting_files=conflicting_files,
|
||||
error_files=error_files,
|
||||
)
|
||||
|
||||
return notices, substitutions
|
||||
|
||||
|
||||
def _import_file_into_course(
|
||||
course_key: CourseKey,
|
||||
staged_content_id: int,
|
||||
file_data_obj: content_staging_api.StagedContentFileData,
|
||||
) -> bool | None:
|
||||
usage_key: UsageKey,
|
||||
) -> tuple[bool | None, dict]:
|
||||
"""
|
||||
Import a single staged static asset file into the course, unless it already exists.
|
||||
Returns True if it was imported, False if there's a conflict, or None if
|
||||
the file already existed (no action needed).
|
||||
"""
|
||||
filename = file_data_obj.filename
|
||||
new_key = course_key.make_asset_key("asset", filename)
|
||||
clipboard_file_path = file_data_obj.filename
|
||||
|
||||
# We need to generate an AssetKey to add an asset to a course. The mapping
|
||||
# of directories '/' -> '_' is a long-existing contentstore convention that
|
||||
# we're not going to attempt to change.
|
||||
if clipboard_file_path.startswith('static/'):
|
||||
# If it's in this form, it came from a library and assumes component-local assets
|
||||
file_path = clipboard_file_path.lstrip('static/')
|
||||
import_path = f"components/{usage_key.block_type}/{usage_key.block_id}/{file_path}"
|
||||
filename = pathlib.Path(file_path).name
|
||||
new_key = course_key.make_asset_key("asset", import_path.replace("/", "_"))
|
||||
else:
|
||||
# Otherwise it came from a course...
|
||||
file_path = clipboard_file_path
|
||||
import_path = None
|
||||
filename = pathlib.Path(file_path).name
|
||||
new_key = course_key.make_asset_key("asset", file_path.replace("/", "_"))
|
||||
|
||||
try:
|
||||
current_file = contentstore().find(new_key)
|
||||
except NotFoundError:
|
||||
@@ -471,22 +601,28 @@ def _import_file_into_course(
|
||||
if not current_file:
|
||||
# This static asset should be imported into the new course:
|
||||
content_type = guess_type(filename)[0]
|
||||
data = content_staging_api.get_staged_content_static_file_data(staged_content_id, filename)
|
||||
data = content_staging_api.get_staged_content_static_file_data(staged_content_id, clipboard_file_path)
|
||||
if data is None:
|
||||
raise NotFoundError(file_data_obj.source_key)
|
||||
content = StaticContent(new_key, name=filename, content_type=content_type, data=data)
|
||||
content = StaticContent(
|
||||
new_key,
|
||||
name=filename,
|
||||
content_type=content_type,
|
||||
data=data,
|
||||
import_path=import_path
|
||||
)
|
||||
# If it's an image file, also generate the thumbnail:
|
||||
thumbnail_content, thumbnail_location = contentstore().generate_thumbnail(content)
|
||||
if thumbnail_content is not None:
|
||||
content.thumbnail_location = thumbnail_location
|
||||
contentstore().save(content)
|
||||
return True
|
||||
return True, {clipboard_file_path: f"static/{import_path}"}
|
||||
elif current_file.content_digest == file_data_obj.md5_hash:
|
||||
# The file already exists and matches exactly, so no action is needed
|
||||
return None
|
||||
return None, {}
|
||||
else:
|
||||
# There is a conflict with some other file that has the same name.
|
||||
return False
|
||||
return False, {}
|
||||
|
||||
|
||||
def is_item_in_course_tree(item):
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.core.management.base import BaseCommand
|
||||
from openedx.core.djangoapps.django_comment_common.utils import are_permissions_roles_seeded, seed_permissions_roles
|
||||
from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.django import SignalHandler, modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.xml_importer import import_course_from_xml # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.util.sandboxing import DEFAULT_PYTHON_LIB_FILENAME # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
@@ -73,6 +73,10 @@ class Command(BaseCommand):
|
||||
|
||||
for course in course_items:
|
||||
course_id = course.id
|
||||
# Importing is an act of publishing so send the course published signal.
|
||||
SignalHandler.course_published.send_robust(sender=self, course_key=course_id)
|
||||
|
||||
# Seed forum permission roles if we need to.
|
||||
if not are_permissions_roles_seeded(course_id):
|
||||
self.stdout.write(f'Seeding forum roles for course {course_id}\n')
|
||||
seed_permissions_roles(course_id)
|
||||
|
||||
@@ -4,9 +4,10 @@
|
||||
import logging
|
||||
from textwrap import dedent
|
||||
from time import time
|
||||
from datetime import date
|
||||
from datetime import date, datetime
|
||||
|
||||
from django.core.management import BaseCommand, CommandError
|
||||
from django.conf import settings
|
||||
from elasticsearch import exceptions
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
@@ -42,6 +43,10 @@ class Command(BaseCommand):
|
||||
parser.add_argument('--active',
|
||||
action='store_true',
|
||||
help='Reindex active courses only')
|
||||
parser.add_argument('--from_inclusion_date',
|
||||
action='store_true',
|
||||
help='Reindex courses with a start date greater than COURSEWARE_SEARCH_INCLUSION_DATE'
|
||||
)
|
||||
parser.add_argument('--setup',
|
||||
action='store_true',
|
||||
help='Reindex all courses on developers stack setup')
|
||||
@@ -70,15 +75,17 @@ class Command(BaseCommand):
|
||||
course_ids = options['course_ids']
|
||||
all_option = options['all']
|
||||
active_option = options['active']
|
||||
inclusion_date_option = options['from_inclusion_date']
|
||||
setup_option = options['setup']
|
||||
readable_option = options['warning']
|
||||
index_all_courses_option = all_option or setup_option
|
||||
|
||||
if ((not course_ids and not (index_all_courses_option or active_option)) or
|
||||
(course_ids and (index_all_courses_option or active_option))):
|
||||
course_option_flag_option = index_all_courses_option or active_option or inclusion_date_option
|
||||
|
||||
if (not course_ids and not course_option_flag_option) or (course_ids and course_option_flag_option):
|
||||
raise CommandError((
|
||||
"reindex_course requires one or more <course_id>s"
|
||||
" OR the --all, --active or --setup flags."
|
||||
" OR the --all, --active, --setup, or --from_inclusion_date flags."
|
||||
))
|
||||
|
||||
store = modulestore()
|
||||
@@ -97,14 +104,16 @@ class Command(BaseCommand):
|
||||
logging.exception('Search Engine error - %s', exc)
|
||||
return
|
||||
|
||||
index_exists = searcher._es.indices.exists(index=index_name) # pylint: disable=protected-access
|
||||
# Legacy Elasticsearch engine
|
||||
if hasattr(searcher, '_es'): # pylint: disable=protected-access
|
||||
index_exists = searcher._es.indices.exists(index=index_name) # pylint: disable=protected-access
|
||||
|
||||
index_mapping = searcher._es.indices.get_mapping( # pylint: disable=protected-access
|
||||
index=index_name,
|
||||
) if index_exists else {}
|
||||
index_mapping = searcher._es.indices.get_mapping( # pylint: disable=protected-access
|
||||
index=index_name,
|
||||
) if index_exists else {}
|
||||
|
||||
if index_exists and index_mapping:
|
||||
return
|
||||
if index_exists and index_mapping:
|
||||
return
|
||||
|
||||
# if reindexing is done during devstack setup step, don't prompt the user
|
||||
if setup_option or query_yes_no(self.CONFIRMATION_PROMPT, default="no"):
|
||||
@@ -127,6 +136,20 @@ class Command(BaseCommand):
|
||||
course_keys = list(map(lambda course: course.id, active_courses))
|
||||
|
||||
logging.warning(f'Selected {len(course_keys)} active courses over a total of {len(all_courses)}.')
|
||||
elif inclusion_date_option:
|
||||
# in case of --from_inclusion_date, we get the list of course keys from all courses
|
||||
# that are stored in modulestore and filter out courses with a start date less than
|
||||
# the settings defined COURSEWARE_SEARCH_INCLUSION_DATE
|
||||
all_courses = modulestore().get_courses()
|
||||
|
||||
inclusion_date = datetime.strptime(
|
||||
settings.FEATURES.get('COURSEWARE_SEARCH_INCLUSION_DATE', '2020-01-01'),
|
||||
'%Y-%m-%d'
|
||||
)
|
||||
|
||||
# We keep the courses that has a start date and the start date is greater than the inclusion date
|
||||
active_courses = filter(lambda course: course.start and (course.start >= inclusion_date), all_courses)
|
||||
course_keys = list(map(lambda course: course.id, active_courses))
|
||||
|
||||
else:
|
||||
# in case course keys are provided as arguments
|
||||
|
||||
@@ -40,6 +40,9 @@ class TestReindexCourse(ModuleStoreTestCase):
|
||||
self.third_course = CourseFactory.create(
|
||||
org="test", course="course3", display_name="run1", start=None, end=None
|
||||
)
|
||||
self.fourth_course = CourseFactory.create(
|
||||
org="test", course="course4", display_name="run1", start=datetime.min.today() - timedelta(weeks=60)
|
||||
)
|
||||
|
||||
REINDEX_PATH_LOCATION = (
|
||||
'cms.djangoapps.contentstore.management.commands.reindex_course.CoursewareSearchIndexer.do_course_reindex'
|
||||
@@ -111,7 +114,9 @@ class TestReindexCourse(ModuleStoreTestCase):
|
||||
call_command('reindex_course', all=True)
|
||||
|
||||
patched_yes_no.assert_called_once_with(ReindexCommand.CONFIRMATION_PROMPT, default='no')
|
||||
expected_calls = self._build_calls(self.first_course, self.second_course, self.third_course)
|
||||
expected_calls = self._build_calls(
|
||||
self.first_course, self.second_course, self.third_course, self.fourth_course
|
||||
)
|
||||
self.assertCountEqual(patched_index.mock_calls, expected_calls)
|
||||
|
||||
def test_given_all_key_prompts_and_reindexes_all_courses_cancelled(self):
|
||||
@@ -134,5 +139,21 @@ class TestReindexCourse(ModuleStoreTestCase):
|
||||
mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)):
|
||||
call_command('reindex_course', active=True)
|
||||
|
||||
expected_calls = self._build_calls(self.first_course)
|
||||
expected_calls = self._build_calls(self.first_course, self.fourth_course)
|
||||
self.assertCountEqual(patched_index.mock_calls, expected_calls)
|
||||
|
||||
@mock.patch.dict(
|
||||
'django.conf.settings.FEATURES',
|
||||
{'COURSEWARE_SEARCH_INCLUSION_DATE': (datetime.min.today() - timedelta(weeks=52)).strftime('%Y-%m-%d')}
|
||||
)
|
||||
def test_given_from_inclusion_date_key_prompt(self):
|
||||
"""
|
||||
Test that reindexes all courses that have a start date after a defined inclusion date
|
||||
when --from_inclusion_date key is given.
|
||||
"""
|
||||
with mock.patch(self.REINDEX_PATH_LOCATION) as patched_index, \
|
||||
mock.patch(self.MODULESTORE_PATCH_LOCATION, mock.Mock(return_value=self.store)):
|
||||
call_command('reindex_course', from_inclusion_date=True)
|
||||
|
||||
expected_calls = self._build_calls(self.first_course, self.second_course)
|
||||
self.assertCountEqual(patched_index.mock_calls, expected_calls)
|
||||
|
||||
@@ -6,9 +6,10 @@ from .course_details import CourseDetailsSerializer
|
||||
from .course_index import CourseIndexSerializer
|
||||
from .course_rerun import CourseRerunSerializer
|
||||
from .course_team import CourseTeamSerializer
|
||||
from .course_waffle_flags import CourseWaffleFlagsSerializer
|
||||
from .grading import CourseGradingModelSerializer, CourseGradingSerializer
|
||||
from .group_configurations import CourseGroupConfigurationsSerializer
|
||||
from .home import CourseHomeSerializer, CourseHomeTabSerializer, LibraryTabSerializer
|
||||
from .home import StudioHomeSerializer, CourseHomeTabSerializer, LibraryTabSerializer
|
||||
from .proctoring import (
|
||||
LimitedProctoredExamSettingsSerializer,
|
||||
ProctoredExamConfigurationSerializer,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
"""
|
||||
API Serializers for course waffle flags
|
||||
"""
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from cms.djangoapps.contentstore import toggles
|
||||
|
||||
|
||||
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()
|
||||
use_new_advanced_settings_page = serializers.SerializerMethodField()
|
||||
use_new_grading_page = serializers.SerializerMethodField()
|
||||
use_new_updates_page = serializers.SerializerMethodField()
|
||||
use_new_import_page = serializers.SerializerMethodField()
|
||||
use_new_export_page = serializers.SerializerMethodField()
|
||||
use_new_files_uploads_page = serializers.SerializerMethodField()
|
||||
use_new_video_uploads_page = serializers.SerializerMethodField()
|
||||
use_new_course_outline_page = serializers.SerializerMethodField()
|
||||
use_new_unit_page = serializers.SerializerMethodField()
|
||||
use_new_course_team_page = serializers.SerializerMethodField()
|
||||
use_new_certificates_page = serializers.SerializerMethodField()
|
||||
use_new_textbooks_page = serializers.SerializerMethodField()
|
||||
use_new_group_configurations_page = serializers.SerializerMethodField()
|
||||
enable_course_optimizer = serializers.SerializerMethodField()
|
||||
|
||||
def get_course_key(self):
|
||||
"""
|
||||
Retrieve the course_key from the context
|
||||
"""
|
||||
return self.context.get("course_key")
|
||||
|
||||
def get_use_new_home_page(self, obj):
|
||||
"""
|
||||
Method to get the use_new_home_page switch
|
||||
"""
|
||||
return toggles.use_new_home_page()
|
||||
|
||||
def get_use_new_custom_pages(self, obj):
|
||||
"""
|
||||
Method to get the use_new_custom_pages switch
|
||||
"""
|
||||
course_key = self.get_course_key()
|
||||
return toggles.use_new_custom_pages(course_key)
|
||||
|
||||
def get_use_new_schedule_details_page(self, obj):
|
||||
"""
|
||||
Method to get the use_new_schedule_details_page switch
|
||||
"""
|
||||
course_key = self.get_course_key()
|
||||
return toggles.use_new_schedule_details_page(course_key)
|
||||
|
||||
def get_use_new_advanced_settings_page(self, obj):
|
||||
"""
|
||||
Method to get the use_new_advanced_settings_page switch
|
||||
"""
|
||||
course_key = self.get_course_key()
|
||||
return toggles.use_new_advanced_settings_page(course_key)
|
||||
|
||||
def get_use_new_grading_page(self, obj):
|
||||
"""
|
||||
Method to get the use_new_grading_page switch
|
||||
"""
|
||||
course_key = self.get_course_key()
|
||||
return toggles.use_new_grading_page(course_key)
|
||||
|
||||
def get_use_new_updates_page(self, obj):
|
||||
"""
|
||||
Method to get the use_new_updates_page switch
|
||||
"""
|
||||
course_key = self.get_course_key()
|
||||
return toggles.use_new_updates_page(course_key)
|
||||
|
||||
def get_use_new_import_page(self, obj):
|
||||
"""
|
||||
Method to get the use_new_import_page switch
|
||||
"""
|
||||
course_key = self.get_course_key()
|
||||
return toggles.use_new_import_page(course_key)
|
||||
|
||||
def get_use_new_export_page(self, obj):
|
||||
"""
|
||||
Method to get the use_new_export_page switch
|
||||
"""
|
||||
course_key = self.get_course_key()
|
||||
return toggles.use_new_export_page(course_key)
|
||||
|
||||
def get_use_new_files_uploads_page(self, obj):
|
||||
"""
|
||||
Method to get the use_new_files_uploads_page switch
|
||||
"""
|
||||
course_key = self.get_course_key()
|
||||
return toggles.use_new_files_uploads_page(course_key)
|
||||
|
||||
def get_use_new_video_uploads_page(self, obj):
|
||||
"""
|
||||
Method to get the use_new_video_uploads_page switch
|
||||
"""
|
||||
course_key = self.get_course_key()
|
||||
return toggles.use_new_video_uploads_page(course_key)
|
||||
|
||||
def get_use_new_course_outline_page(self, obj):
|
||||
"""
|
||||
Method to get the use_new_course_outline_page switch
|
||||
"""
|
||||
course_key = self.get_course_key()
|
||||
return toggles.use_new_course_outline_page(course_key)
|
||||
|
||||
def get_use_new_unit_page(self, obj):
|
||||
"""
|
||||
Method to get the use_new_unit_page switch
|
||||
"""
|
||||
course_key = self.get_course_key()
|
||||
return toggles.use_new_unit_page(course_key)
|
||||
|
||||
def get_use_new_course_team_page(self, obj):
|
||||
"""
|
||||
Method to get the use_new_course_team_page switch
|
||||
"""
|
||||
course_key = self.get_course_key()
|
||||
return toggles.use_new_course_team_page(course_key)
|
||||
|
||||
def get_use_new_certificates_page(self, obj):
|
||||
"""
|
||||
Method to get the use_new_certificates_page switch
|
||||
"""
|
||||
course_key = self.get_course_key()
|
||||
return toggles.use_new_certificates_page(course_key)
|
||||
|
||||
def get_use_new_textbooks_page(self, obj):
|
||||
"""
|
||||
Method to get the use_new_textbooks_page switch
|
||||
"""
|
||||
course_key = self.get_course_key()
|
||||
return toggles.use_new_textbooks_page(course_key)
|
||||
|
||||
def get_use_new_group_configurations_page(self, obj):
|
||||
"""
|
||||
Method to get the use_new_group_configurations_page switch
|
||||
"""
|
||||
course_key = self.get_course_key()
|
||||
return toggles.use_new_group_configurations_page(course_key)
|
||||
|
||||
def get_enable_course_optimizer(self, obj):
|
||||
"""
|
||||
Method to get the enable_course_optimizer waffle flag
|
||||
"""
|
||||
course_key = self.get_course_key()
|
||||
return toggles.enable_course_optimizer(course_key)
|
||||
@@ -41,8 +41,8 @@ class LibraryTabSerializer(serializers.Serializer):
|
||||
libraries = LibraryViewSerializer(many=True, required=False, allow_null=True)
|
||||
|
||||
|
||||
class CourseHomeSerializer(serializers.Serializer):
|
||||
"""Serializer for course home"""
|
||||
class StudioHomeSerializer(serializers.Serializer):
|
||||
"""Serializer for Studio home"""
|
||||
allow_course_reruns = serializers.BooleanField()
|
||||
allow_to_create_new_org = serializers.BooleanField()
|
||||
allow_unicode_course_id = serializers.BooleanField()
|
||||
@@ -58,12 +58,15 @@ class CourseHomeSerializer(serializers.Serializer):
|
||||
in_process_course_actions = UnsucceededCourseSerializer(many=True, required=False, allow_null=True)
|
||||
libraries = LibraryViewSerializer(many=True, required=False, allow_null=True)
|
||||
libraries_enabled = serializers.BooleanField()
|
||||
libraries_v1_enabled = serializers.BooleanField()
|
||||
libraries_v2_enabled = serializers.BooleanField()
|
||||
taxonomies_enabled = serializers.BooleanField()
|
||||
taxonomy_list_mfe_url = serializers.CharField()
|
||||
optimization_enabled = serializers.BooleanField()
|
||||
request_course_creator_url = serializers.CharField()
|
||||
rerun_creator_status = serializers.BooleanField()
|
||||
show_new_library_button = serializers.BooleanField()
|
||||
show_new_library_v2_button = serializers.BooleanField()
|
||||
split_studio_home = serializers.BooleanField()
|
||||
studio_name = serializers.CharField()
|
||||
studio_short_name = serializers.CharField()
|
||||
|
||||
@@ -89,6 +89,7 @@ class ContainerHandlerSerializer(serializers.Serializer):
|
||||
unit_block_id = serializers.CharField(source="unit.location.block_id")
|
||||
subsection_location = serializers.CharField(source="subsection.location")
|
||||
course_sequence_ids = serializers.ListField(child=serializers.CharField())
|
||||
library_content_picker_url = serializers.CharField()
|
||||
|
||||
def get_assets_url(self, obj):
|
||||
"""
|
||||
@@ -103,6 +104,18 @@ class ContainerHandlerSerializer(serializers.Serializer):
|
||||
return None
|
||||
|
||||
|
||||
class UpstreamLinkSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer holding info for syncing a block with its upstream (eg, a library block).
|
||||
"""
|
||||
upstream_ref = serializers.CharField()
|
||||
version_synced = serializers.IntegerField()
|
||||
version_available = serializers.IntegerField(allow_null=True)
|
||||
version_declined = serializers.IntegerField(allow_null=True)
|
||||
error_message = serializers.CharField(allow_null=True)
|
||||
ready_to_sync = serializers.BooleanField()
|
||||
|
||||
|
||||
class ChildVerticalContainerSerializer(serializers.Serializer):
|
||||
"""
|
||||
Serializer for representing a xblock child of vertical container.
|
||||
@@ -113,6 +126,7 @@ class ChildVerticalContainerSerializer(serializers.Serializer):
|
||||
block_type = serializers.CharField()
|
||||
user_partition_info = serializers.DictField()
|
||||
user_partitions = serializers.ListField()
|
||||
upstream_link = UpstreamLinkSerializer(allow_null=True)
|
||||
actions = serializers.SerializerMethodField()
|
||||
validation_messages = MessageValidation(many=True)
|
||||
render_error = serializers.CharField()
|
||||
|
||||
@@ -17,6 +17,7 @@ from .views import (
|
||||
CourseRerunView,
|
||||
CourseSettingsView,
|
||||
CourseVideosView,
|
||||
CourseWaffleFlagsView,
|
||||
HomePageView,
|
||||
HomePageCoursesView,
|
||||
HomePageLibrariesView,
|
||||
@@ -131,6 +132,11 @@ urlpatterns = [
|
||||
VerticalContainerView.as_view(),
|
||||
name="container_vertical"
|
||||
),
|
||||
re_path(
|
||||
fr'^course_waffle_flags(?:/{COURSE_ID_PATTERN})?$',
|
||||
CourseWaffleFlagsView.as_view(),
|
||||
name="course_waffle_flags"
|
||||
),
|
||||
|
||||
# Authoring API
|
||||
# Do not use under v1 yet (Nov. 23). The Authoring API is still experimental and the v0 versions should be used
|
||||
|
||||
@@ -5,6 +5,7 @@ from .certificates import CourseCertificatesView
|
||||
from .course_details import CourseDetailsView
|
||||
from .course_index import CourseIndexView
|
||||
from .course_rerun import CourseRerunView
|
||||
from .course_waffle_flags import CourseWaffleFlagsView
|
||||
from .course_team import CourseTeamView
|
||||
from .grading import CourseGradingView
|
||||
from .group_configurations import CourseGroupConfigurationsView
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
""" API Views for course waffle flags """
|
||||
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework.decorators import APIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
|
||||
from ..serializers import CourseWaffleFlagsSerializer
|
||||
|
||||
|
||||
@view_auth_classes(is_authenticated=True)
|
||||
class CourseWaffleFlagsView(APIView):
|
||||
"""
|
||||
API view to retrieve course waffle flag settings for a specific course.
|
||||
|
||||
This view provides a GET endpoint that returns the status of various waffle
|
||||
flags for a given course. It requires the user to be authenticated.
|
||||
"""
|
||||
|
||||
def get(self, request, course_id=None):
|
||||
"""
|
||||
Retrieve the waffle flag settings for the specified course.
|
||||
|
||||
Args:
|
||||
request (HttpRequest): The HTTP request object.
|
||||
course_id (str, optional): The ID of the course for which to retrieve
|
||||
the waffle flag settings. If not provided,
|
||||
defaults to None.
|
||||
|
||||
Returns:
|
||||
Response: A JSON response containing the status of various waffle flags
|
||||
for the specified course.
|
||||
|
||||
**Example Request**
|
||||
|
||||
GET /api/contentstore/v1/course_waffle_flags
|
||||
GET /api/contentstore/v1/course_waffle_flags/course-v1:test+test+test
|
||||
|
||||
**Response Values**
|
||||
|
||||
A JSON response containing the status of various waffle flags
|
||||
for the specified course.
|
||||
|
||||
**Example Response**
|
||||
|
||||
```json
|
||||
{
|
||||
"use_new_home_page": true,
|
||||
"use_new_custom_pages": true,
|
||||
"use_new_schedule_details_page": true,
|
||||
"use_new_advanced_settings_page": true,
|
||||
"use_new_grading_page": true,
|
||||
"use_new_updates_page": true,
|
||||
"use_new_import_page": true,
|
||||
"use_new_export_page": true,
|
||||
"use_new_files_uploads_page": true,
|
||||
"use_new_video_uploads_page": false,
|
||||
"use_new_course_outline_page": true,
|
||||
"use_new_unit_page": false,
|
||||
"use_new_course_team_page": true,
|
||||
"use_new_certificates_page": true,
|
||||
"use_new_textbooks_page": true,
|
||||
"use_new_group_configurations_page": true
|
||||
}
|
||||
```
|
||||
"""
|
||||
course_key = CourseKey.from_string(course_id) if course_id else None
|
||||
serializer = CourseWaffleFlagsSerializer(
|
||||
context={"course_key": course_key}, data={}
|
||||
)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
return Response(serializer.data)
|
||||
@@ -8,7 +8,7 @@ from rest_framework.views import APIView
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
|
||||
from ....utils import get_home_context, get_course_context, get_library_context
|
||||
from ..serializers import CourseHomeSerializer, CourseHomeTabSerializer, LibraryTabSerializer
|
||||
from ..serializers import StudioHomeSerializer, CourseHomeTabSerializer, LibraryTabSerializer
|
||||
|
||||
|
||||
@view_auth_classes(is_authenticated=True)
|
||||
@@ -24,7 +24,7 @@ class HomePageView(APIView):
|
||||
description="Query param to filter by course org",
|
||||
)],
|
||||
responses={
|
||||
200: CourseHomeSerializer,
|
||||
200: StudioHomeSerializer,
|
||||
401: "The requester is not authenticated.",
|
||||
},
|
||||
)
|
||||
@@ -59,10 +59,14 @@ class HomePageView(APIView):
|
||||
"in_process_course_actions": [],
|
||||
"libraries": [],
|
||||
"libraries_enabled": true,
|
||||
"libraries_v1_enabled": true,
|
||||
"libraries_v2_enabled": true,
|
||||
"library_authoring_mfe_url": "//localhost:3001/course/course-v1:edX+P315+2T2023",
|
||||
"optimization_enabled": true,
|
||||
"request_course_creator_url": "/request_course_creator",
|
||||
"rerun_creator_status": true,
|
||||
"show_new_library_button": true,
|
||||
"show_new_library_v2_button": true,
|
||||
"split_studio_home": false,
|
||||
"studio_name": "Studio",
|
||||
"studio_short_name": "Studio",
|
||||
@@ -84,7 +88,7 @@ class HomePageView(APIView):
|
||||
'platform_name': settings.PLATFORM_NAME,
|
||||
'user_is_active': request.user.is_active,
|
||||
})
|
||||
serializer = CourseHomeSerializer(home_context)
|
||||
serializer = StudioHomeSerializer(home_context)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"""
|
||||
Unit tests for the course waffle flags view
|
||||
"""
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.urls import reverse
|
||||
from rest_framework import status
|
||||
|
||||
from cms.djangoapps.contentstore.tests.utils import CourseTestCase
|
||||
from openedx.core.djangoapps.waffle_utils.models import WaffleFlagCourseOverrideModel
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class CourseWaffleFlagsViewTest(CourseTestCase):
|
||||
"""
|
||||
Tests for the CourseWaffleFlagsView endpoint, which returns waffle flag states
|
||||
for a specific course or globally if no course ID is provided.
|
||||
"""
|
||||
|
||||
course_waffle_flags = [
|
||||
"use_new_custom_pages",
|
||||
"use_new_schedule_details_page",
|
||||
"use_new_advanced_settings_page",
|
||||
"use_new_grading_page",
|
||||
"use_new_updates_page",
|
||||
"use_new_import_page",
|
||||
"use_new_export_page",
|
||||
"use_new_files_uploads_page",
|
||||
"use_new_video_uploads_page",
|
||||
"use_new_course_outline_page",
|
||||
"use_new_unit_page",
|
||||
"use_new_course_team_page",
|
||||
"use_new_certificates_page",
|
||||
"use_new_textbooks_page",
|
||||
"use_new_group_configurations_page",
|
||||
]
|
||||
|
||||
other_expected_waffle_flags = ["enable_course_optimizer"]
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Set up test data and state before each test method.
|
||||
|
||||
This method initializes the endpoint URL and creates a set of waffle flags
|
||||
for the test course, setting each flag's value to `True`.
|
||||
"""
|
||||
super().setUp()
|
||||
self.url = reverse("cms.djangoapps.contentstore:v1:course_waffle_flags")
|
||||
self.create_waffle_flags(self.course_waffle_flags)
|
||||
self.create_custom_waffle_flags()
|
||||
|
||||
def create_custom_waffle_flags(self, enabled=True):
|
||||
"""
|
||||
Helper method to create waffle flags that are not part of `course_waffle_flags` and have
|
||||
a different format.
|
||||
"""
|
||||
WaffleFlagCourseOverrideModel.objects.create(
|
||||
waffle_flag="contentstore.enable_course_optimizer",
|
||||
course_id=self.course.id,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
def create_waffle_flags(self, flags, enabled=True):
|
||||
"""
|
||||
Helper method to create waffle flag entries in the database for the test course.
|
||||
|
||||
Args:
|
||||
flags (list): A list of flag names to set up.
|
||||
enabled (bool): The value to set for each flag's enabled state.
|
||||
"""
|
||||
for flag in flags:
|
||||
WaffleFlagCourseOverrideModel.objects.create(
|
||||
waffle_flag=f"contentstore.new_studio_mfe.{flag}",
|
||||
course_id=self.course.id,
|
||||
enabled=enabled,
|
||||
)
|
||||
|
||||
def expected_response(self, enabled=False):
|
||||
"""
|
||||
Generate an expected response dictionary based on the enabled flag.
|
||||
|
||||
Args:
|
||||
enabled (bool): State to assign to each waffle flag in the response.
|
||||
|
||||
Returns:
|
||||
dict: A dictionary with each flag set to the value of `enabled`.
|
||||
"""
|
||||
res = {flag: enabled for flag in self.course_waffle_flags}
|
||||
for flag in self.other_expected_waffle_flags:
|
||||
res[flag] = enabled
|
||||
return res
|
||||
|
||||
def test_get_course_waffle_flags_with_course_id(self):
|
||||
"""
|
||||
Test that waffle flags for a specific course are correctly returned when
|
||||
a valid course ID is provided.
|
||||
|
||||
Expected Behavior:
|
||||
- The response should return HTTP 200 status.
|
||||
- Each flag returned should be `True` as set up in the `setUp` method.
|
||||
"""
|
||||
course_url = reverse(
|
||||
"cms.djangoapps.contentstore:v1:course_waffle_flags",
|
||||
kwargs={"course_id": self.course.id},
|
||||
)
|
||||
|
||||
expected_response = self.expected_response(enabled=True)
|
||||
expected_response["use_new_home_page"] = False
|
||||
|
||||
response = self.client.get(course_url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertDictEqual(expected_response, response.data)
|
||||
|
||||
def test_get_course_waffle_flags_without_course_id(self):
|
||||
"""
|
||||
Test that the default waffle flag states are returned when no course ID is provided.
|
||||
|
||||
Expected Behavior:
|
||||
- The response should return HTTP 200 status.
|
||||
- Each flag returned should default to `False`, representing the global
|
||||
default state for each flag.
|
||||
"""
|
||||
expected_response = self.expected_response(enabled=False)
|
||||
expected_response["use_new_home_page"] = False
|
||||
|
||||
response = self.client.get(self.url)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertDictEqual(expected_response, response.data)
|
||||
@@ -2,7 +2,9 @@
|
||||
Unit tests for home page view.
|
||||
"""
|
||||
import ddt
|
||||
import pytz
|
||||
from collections import OrderedDict
|
||||
from datetime import datetime, timedelta
|
||||
from django.conf import settings
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
@@ -33,12 +35,7 @@ class HomePageViewTest(CourseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.url = reverse("cms.djangoapps.contentstore:v1:home")
|
||||
|
||||
def test_home_page_courses_response(self):
|
||||
"""Check successful response content"""
|
||||
response = self.client.get(self.url)
|
||||
|
||||
expected_response = {
|
||||
self.expected_response = {
|
||||
"allow_course_reruns": True,
|
||||
"allow_to_create_new_org": False,
|
||||
"allow_unicode_course_id": False,
|
||||
@@ -51,12 +48,15 @@ class HomePageViewTest(CourseTestCase):
|
||||
"in_process_course_actions": [],
|
||||
"libraries": [],
|
||||
"libraries_enabled": True,
|
||||
"libraries_v1_enabled": True,
|
||||
"libraries_v2_enabled": False,
|
||||
"taxonomies_enabled": True,
|
||||
"taxonomy_list_mfe_url": 'http://course-authoring-mfe/taxonomies',
|
||||
"optimization_enabled": False,
|
||||
"request_course_creator_url": "/request_course_creator",
|
||||
"rerun_creator_status": True,
|
||||
"show_new_library_button": True,
|
||||
"show_new_library_v2_button": True,
|
||||
"split_studio_home": False,
|
||||
"studio_name": settings.STUDIO_NAME,
|
||||
"studio_short_name": settings.STUDIO_SHORT_NAME,
|
||||
@@ -66,6 +66,21 @@ class HomePageViewTest(CourseTestCase):
|
||||
"user_is_active": True,
|
||||
}
|
||||
|
||||
def test_home_page_studio_response(self):
|
||||
"""Check successful response content"""
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertDictEqual(self.expected_response, response.data)
|
||||
|
||||
@override_settings(MEILISEARCH_ENABLED=True)
|
||||
def test_home_page_studio_with_meilisearch_enabled(self):
|
||||
"""Check response content when Meilisearch is enabled"""
|
||||
response = self.client.get(self.url)
|
||||
|
||||
expected_response = self.expected_response
|
||||
expected_response["libraries_v2_enabled"] = True
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertDictEqual(expected_response, response.data)
|
||||
|
||||
@@ -88,12 +103,13 @@ class HomePageCoursesViewTest(CourseTestCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.url = reverse("cms.djangoapps.contentstore:v1:courses")
|
||||
CourseOverviewFactory.create(
|
||||
self.course_overview = CourseOverviewFactory.create(
|
||||
id=self.course.id,
|
||||
org=self.course.org,
|
||||
display_name=self.course.display_name,
|
||||
display_number_with_default=self.course.number,
|
||||
)
|
||||
self.non_staff_client, _ = self.create_non_staff_authed_user_client()
|
||||
|
||||
def test_home_page_response(self):
|
||||
"""Check successful response content"""
|
||||
@@ -105,7 +121,7 @@ class HomePageCoursesViewTest(CourseTestCase):
|
||||
"courses": [{
|
||||
"course_key": course_id,
|
||||
"display_name": self.course.display_name,
|
||||
"lms_link": f'//{settings.LMS_BASE}/courses/{course_id}/jump_to/{self.course.location}',
|
||||
"lms_link": f'{settings.LMS_ROOT_URL}/courses/{course_id}/jump_to/{self.course.location}',
|
||||
"number": self.course.number,
|
||||
"org": self.course.org,
|
||||
"rerun_link": f'/course_rerun/{course_id}',
|
||||
@@ -119,6 +135,7 @@ class HomePageCoursesViewTest(CourseTestCase):
|
||||
print(response.data)
|
||||
self.assertDictEqual(expected_response, response.data)
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
|
||||
def test_home_page_response_with_api_v2(self):
|
||||
"""Check successful response content with api v2 modifications.
|
||||
|
||||
@@ -132,7 +149,7 @@ class HomePageCoursesViewTest(CourseTestCase):
|
||||
OrderedDict([
|
||||
("course_key", course_id),
|
||||
("display_name", self.course.display_name),
|
||||
("lms_link", f'//{settings.LMS_BASE}/courses/{course_id}/jump_to/{self.course.location}'),
|
||||
("lms_link", f'{settings.LMS_ROOT_URL}/courses/{course_id}/jump_to/{self.course.location}'),
|
||||
("number", self.course.number),
|
||||
("org", self.course.org),
|
||||
("rerun_link", f'/course_rerun/{course_id}'),
|
||||
@@ -143,12 +160,88 @@ class HomePageCoursesViewTest(CourseTestCase):
|
||||
"in_process_course_actions": [],
|
||||
}
|
||||
|
||||
with override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API):
|
||||
response = self.client.get(self.url)
|
||||
response = self.client.get(self.url)
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertDictEqual(expected_response, response.data)
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
|
||||
@ddt.data(
|
||||
("active_only", "true", 2, 0),
|
||||
("archived_only", "true", 0, 1),
|
||||
("search", "sample", 1, 0),
|
||||
("search", "demo", 0, 1),
|
||||
("order", "org", 2, 1),
|
||||
("order", "display_name", 2, 1),
|
||||
("order", "number", 2, 1),
|
||||
("order", "run", 2, 1)
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_filter_and_ordering_courses(
|
||||
self,
|
||||
filter_key,
|
||||
filter_value,
|
||||
expected_active_length,
|
||||
expected_archived_length
|
||||
):
|
||||
"""Test home page with org filter and ordering for a staff user.
|
||||
|
||||
The test creates an active/archived course, and then filters/orders them using the query parameters.
|
||||
"""
|
||||
archived_course_key = self.store.make_course_key("demo-org", "demo-number", "demo-run")
|
||||
CourseOverviewFactory.create(
|
||||
display_name="Course (Demo)",
|
||||
id=archived_course_key,
|
||||
org=archived_course_key.org,
|
||||
end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC),
|
||||
)
|
||||
active_course_key = self.store.make_course_key("sample-org", "sample-number", "sample-run")
|
||||
CourseOverviewFactory.create(
|
||||
display_name="Course (Sample)",
|
||||
id=active_course_key,
|
||||
org=active_course_key.org,
|
||||
)
|
||||
|
||||
response = self.client.get(self.url, {filter_key: filter_value})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(len(response.data["archived_courses"]), expected_archived_length)
|
||||
self.assertEqual(len(response.data["courses"]), expected_active_length)
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
|
||||
@ddt.data(
|
||||
("active_only", "true"),
|
||||
("archived_only", "true"),
|
||||
("search", "sample"),
|
||||
("order", "org"),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_filter_and_ordering_no_courses_staff(self, filter_key, filter_value):
|
||||
"""Test home page with org filter and ordering when there are no courses for a staff user."""
|
||||
self.course_overview.delete()
|
||||
|
||||
response = self.client.get(self.url, {filter_key: filter_value})
|
||||
|
||||
self.assertEqual(len(response.data["courses"]), 0)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
|
||||
@ddt.data(
|
||||
("active_only", "true"),
|
||||
("archived_only", "true"),
|
||||
("search", "sample"),
|
||||
("order", "org"),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_home_page_response_no_courses_non_staff(self, filter_key, filter_value):
|
||||
"""Test home page with org filter and ordering when there are no courses for a non-staff user."""
|
||||
self.course_overview.delete()
|
||||
|
||||
response = self.non_staff_client.get(self.url)
|
||||
|
||||
self.assertEqual(len(response.data["courses"]), 0)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@override_waffle_switch(ENABLE_GLOBAL_STAFF_OPTIMIZATION, True)
|
||||
def test_org_query_if_passed(self):
|
||||
"""Test home page when org filter passed as a query param"""
|
||||
|
||||
@@ -70,6 +70,8 @@ class BaseXBlockContainer(CourseTestCase):
|
||||
parent=self.vertical.location,
|
||||
category="html",
|
||||
display_name="Html Content 2",
|
||||
upstream="lb:FakeOrg:FakeLib:html:FakeBlock",
|
||||
upstream_version=5,
|
||||
)
|
||||
|
||||
def create_block(self, parent, category, display_name, **kwargs):
|
||||
@@ -193,6 +195,7 @@ class ContainerVerticalViewTest(BaseXBlockContainer):
|
||||
"name": self.html_unit_first.display_name_with_default,
|
||||
"block_id": str(self.html_unit_first.location),
|
||||
"block_type": self.html_unit_first.location.block_type,
|
||||
"upstream_link": None,
|
||||
"user_partition_info": expected_user_partition_info,
|
||||
"user_partitions": expected_user_partitions,
|
||||
"actions": {
|
||||
@@ -218,12 +221,21 @@ class ContainerVerticalViewTest(BaseXBlockContainer):
|
||||
"can_delete": True,
|
||||
"can_manage_tags": True,
|
||||
},
|
||||
"upstream_link": {
|
||||
"upstream_ref": "lb:FakeOrg:FakeLib:html:FakeBlock",
|
||||
"version_synced": 5,
|
||||
"version_available": None,
|
||||
"version_declined": None,
|
||||
"error_message": "Linked library item was not found in the system",
|
||||
"ready_to_sync": False,
|
||||
},
|
||||
"user_partition_info": expected_user_partition_info,
|
||||
"user_partitions": expected_user_partitions,
|
||||
"validation_messages": [],
|
||||
"render_error": "",
|
||||
},
|
||||
]
|
||||
self.maxDiff = None
|
||||
self.assertEqual(response.data["children"], expected_response)
|
||||
|
||||
def test_not_valid_usage_key_string(self):
|
||||
|
||||
@@ -20,6 +20,7 @@ from cms.djangoapps.contentstore.rest_api.v1.serializers import (
|
||||
ContainerHandlerSerializer,
|
||||
VerticalContainerSerializer,
|
||||
)
|
||||
from cms.lib.xblock.upstream_sync import UpstreamLink
|
||||
from openedx.core.lib.api.view_utils import view_auth_classes
|
||||
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
|
||||
@@ -198,6 +199,7 @@ class VerticalContainerView(APIView, ContainerHandlerMixin):
|
||||
"block_type": "drag-and-drop-v2",
|
||||
"user_partition_info": {},
|
||||
"user_partitions": {}
|
||||
"upstream_link": null,
|
||||
"actions": {
|
||||
"can_copy": true,
|
||||
"can_duplicate": true,
|
||||
@@ -215,6 +217,13 @@ class VerticalContainerView(APIView, ContainerHandlerMixin):
|
||||
"block_type": "video",
|
||||
"user_partition_info": {},
|
||||
"user_partitions": {}
|
||||
"upstream_link": {
|
||||
"upstream_ref": "lb:org:mylib:video:404",
|
||||
"version_synced": 16
|
||||
"version_available": null,
|
||||
"error_message": "Linked library item not found: lb:org:mylib:video:404",
|
||||
"ready_to_sync": false,
|
||||
},
|
||||
"actions": {
|
||||
"can_copy": true,
|
||||
"can_duplicate": true,
|
||||
@@ -232,6 +241,13 @@ class VerticalContainerView(APIView, ContainerHandlerMixin):
|
||||
"block_type": "html",
|
||||
"user_partition_info": {},
|
||||
"user_partitions": {},
|
||||
"upstream_link": {
|
||||
"upstream_ref": "lb:org:mylib:html:abcd",
|
||||
"version_synced": 43,
|
||||
"version_available": 49,
|
||||
"error_message": null,
|
||||
"ready_to_sync": true,
|
||||
},
|
||||
"actions": {
|
||||
"can_copy": true,
|
||||
"can_duplicate": true,
|
||||
@@ -267,6 +283,7 @@ class VerticalContainerView(APIView, ContainerHandlerMixin):
|
||||
child_info = modulestore().get_item(child)
|
||||
user_partition_info = get_visibility_partition_info(child_info, course=course)
|
||||
user_partitions = get_user_partition_info(child_info, course=course)
|
||||
upstream_link = UpstreamLink.try_get_for_block(child_info)
|
||||
validation_messages = get_xblock_validation_messages(child_info)
|
||||
render_error = get_xblock_render_error(request, child_info)
|
||||
|
||||
@@ -277,6 +294,12 @@ class VerticalContainerView(APIView, ContainerHandlerMixin):
|
||||
"block_type": child_info.location.block_type,
|
||||
"user_partition_info": user_partition_info,
|
||||
"user_partitions": user_partitions,
|
||||
"upstream_link": (
|
||||
# If the block isn't linked to an upstream (which is by far the most common case) then just
|
||||
# make this field null, which communicates the same info, but with less noise.
|
||||
upstream_link.to_json() if upstream_link.upstream_ref
|
||||
else None
|
||||
),
|
||||
"validation_messages": validation_messages,
|
||||
"render_error": render_error,
|
||||
})
|
||||
|
||||
@@ -1,15 +1,31 @@
|
||||
"""Contenstore API v2 URLs."""
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from cms.djangoapps.contentstore.rest_api.v2.views import HomePageCoursesViewV2
|
||||
from django.conf import settings
|
||||
from django.urls import path, re_path
|
||||
|
||||
from cms.djangoapps.contentstore.rest_api.v2.views import home, downstreams
|
||||
app_name = "v2"
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"home/courses",
|
||||
HomePageCoursesViewV2.as_view(),
|
||||
home.HomePageCoursesViewV2.as_view(),
|
||||
name="courses",
|
||||
),
|
||||
# TODO: Potential future path.
|
||||
# re_path(
|
||||
# fr'^downstreams/$',
|
||||
# downstreams.DownstreamsListView.as_view(),
|
||||
# name="downstreams_list",
|
||||
# ),
|
||||
re_path(
|
||||
fr'^downstreams/{settings.USAGE_KEY_PATTERN}$',
|
||||
downstreams.DownstreamView.as_view(),
|
||||
name="downstream"
|
||||
),
|
||||
re_path(
|
||||
fr'^downstreams/{settings.USAGE_KEY_PATTERN}/sync$',
|
||||
downstreams.SyncFromUpstreamView.as_view(),
|
||||
name="sync_from_upstream"
|
||||
),
|
||||
]
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
"""Module for v2 views."""
|
||||
|
||||
from cms.djangoapps.contentstore.rest_api.v2.views.home import HomePageCoursesViewV2
|
||||
|
||||
253
cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py
Normal file
253
cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
API Views for managing & syncing links between upstream & downstream content
|
||||
|
||||
API paths (We will move these into proper api_doc_tools annotations soon
|
||||
https://github.com/openedx/edx-platform/issues/35653):
|
||||
|
||||
/api/contentstore/v2/downstreams/{usage_key_string}
|
||||
|
||||
GET: Inspect a single downstream block's link to upstream content.
|
||||
200: Upstream link details successfully fetched. Returns UpstreamLink (may contain an error_message).
|
||||
404: Downstream block not found or user lacks permission to edit it.
|
||||
|
||||
DELETE: Sever a single downstream block's link to upstream content.
|
||||
204: Block successfully unlinked (or it wasn't linked in the first place). No response body.
|
||||
404: Downstream block not found or user lacks permission to edit it.
|
||||
|
||||
PUT: Establish or modify a single downstream block's link to upstream content. An authoring client could use this
|
||||
endpoint to add library content in a two-step process, specifically: (1) add a blank block to a course, then
|
||||
(2) link it to a content library with ?sync=True.
|
||||
REQUEST BODY: {
|
||||
"upstream_ref": str, // reference to upstream block (eg, library block usage key)
|
||||
"sync": bool, // whether to sync in upstream content (False by default)
|
||||
}
|
||||
200: Downstream block's upstream link successfully edited (and synced, if requested). Returns UpstreamLink.
|
||||
400: upstream_ref is malformed, missing, or inaccessible.
|
||||
400: Content at upstream_ref does not support syncing.
|
||||
404: Downstream block not found or user lacks permission to edit it.
|
||||
|
||||
/api/contentstore/v2/downstreams/{usage_key_string}/sync
|
||||
|
||||
POST: Sync a downstream block with upstream content.
|
||||
200: Downstream block successfully synced with upstream content.
|
||||
400: Downstream block is not linked to upstream content.
|
||||
400: Upstream is malformed, missing, or inaccessible.
|
||||
400: Upstream block does not support syncing.
|
||||
404: Downstream block not found or user lacks permission to edit it.
|
||||
|
||||
DELETE: Decline an available sync for a downstream block.
|
||||
204: Sync successfuly dismissed. No response body.
|
||||
400: Downstream block is not linked to upstream content.
|
||||
404: Downstream block not found or user lacks permission to edit it.
|
||||
|
||||
# NOT YET IMPLEMENTED -- Will be needed for full Libraries Relaunch in ~Teak.
|
||||
/api/contentstore/v2/downstreams
|
||||
/api/contentstore/v2/downstreams?course_id=course-v1:A+B+C&ready_to_sync=true
|
||||
GET: List downstream blocks that can be synced, filterable by course or sync-readiness.
|
||||
200: A paginated list of applicable & accessible downstream blocks. Entries are UpstreamLinks.
|
||||
|
||||
UpstreamLink response schema:
|
||||
{
|
||||
"upstream_ref": string?
|
||||
"version_synced": string?,
|
||||
"version_available": string?,
|
||||
"version_declined": string?,
|
||||
"error_message": string?,
|
||||
"ready_to_sync": Boolean
|
||||
}
|
||||
"""
|
||||
import logging
|
||||
|
||||
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from rest_framework.exceptions import NotFound, ValidationError
|
||||
from rest_framework.request import Request
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from xblock.core import XBlock
|
||||
|
||||
from cms.lib.xblock.upstream_sync import (
|
||||
UpstreamLink, UpstreamLinkException, NoUpstream, BadUpstream, BadDownstream,
|
||||
fetch_customizable_fields, sync_from_upstream, decline_sync, sever_upstream_link
|
||||
)
|
||||
from common.djangoapps.student.auth import has_studio_write_access, has_studio_read_access
|
||||
from openedx.core.lib.api.view_utils import (
|
||||
DeveloperErrorViewMixin,
|
||||
view_auth_classes,
|
||||
)
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _AuthenticatedRequest(Request):
|
||||
"""
|
||||
Alias for the `Request` class which tells mypy to assume that `.user` is not an AnonymousUser.
|
||||
|
||||
Using this does NOT ensure the request is actually authenticated--
|
||||
you will some other way to ensure that, such as `@view_auth_classes(is_authenticated=True)`.
|
||||
"""
|
||||
user: User
|
||||
|
||||
|
||||
# TODO: Potential future view.
|
||||
# @view_auth_classes(is_authenticated=True)
|
||||
# class DownstreamListView(DeveloperErrorViewMixin, APIView):
|
||||
# """
|
||||
# List all blocks which are linked to upstream content, with optional filtering.
|
||||
# """
|
||||
# def get(self, request: _AuthenticatedRequest) -> Response:
|
||||
# """
|
||||
# Handle the request.
|
||||
# """
|
||||
# course_key_string = request.GET['course_id']
|
||||
# syncable = request.GET['ready_to_sync']
|
||||
# ...
|
||||
|
||||
|
||||
@view_auth_classes(is_authenticated=True)
|
||||
class DownstreamView(DeveloperErrorViewMixin, APIView):
|
||||
"""
|
||||
Inspect or manage an XBlock's link to upstream content.
|
||||
"""
|
||||
def get(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
|
||||
"""
|
||||
Inspect an XBlock's link to upstream content.
|
||||
"""
|
||||
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=False)
|
||||
return Response(UpstreamLink.try_get_for_block(downstream).to_json())
|
||||
|
||||
def put(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
|
||||
"""
|
||||
Edit an XBlock's link to upstream content.
|
||||
"""
|
||||
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
|
||||
new_upstream_ref = request.data.get("upstream_ref")
|
||||
|
||||
# Set `downstream.upstream` so that we can try to sync and/or fetch.
|
||||
# Note that, if this fails and we raise a 4XX, then we will not call modulstore().update_item,
|
||||
# thus preserving the former value of `downstream.upstream`.
|
||||
downstream.upstream = new_upstream_ref
|
||||
sync_param = request.data.get("sync", "false")
|
||||
if isinstance(sync_param, str):
|
||||
sync_param = sync_param.lower()
|
||||
if sync_param not in ["true", "false", True, False]:
|
||||
raise ValidationError({"sync": "must be 'true' or 'false'"})
|
||||
try:
|
||||
if sync_param == "true" or sync_param is True:
|
||||
sync_from_upstream(downstream=downstream, user=request.user)
|
||||
else:
|
||||
# Even if we're not syncing (i.e., updating the downstream's values with the upstream's), we still need
|
||||
# to fetch the upstream's customizable values and store them as hidden fields on the downstream. This
|
||||
# ensures that downstream authors can restore defaults based on the upstream.
|
||||
fetch_customizable_fields(downstream=downstream, user=request.user)
|
||||
except BadDownstream as exc:
|
||||
logger.exception(
|
||||
"'%s' is an invalid downstream; refusing to set its upstream to '%s'",
|
||||
usage_key_string,
|
||||
new_upstream_ref,
|
||||
)
|
||||
raise ValidationError(str(exc)) from exc
|
||||
except BadUpstream as exc:
|
||||
logger.exception(
|
||||
"'%s' is an invalid upstream reference; refusing to set it as upstream of '%s'",
|
||||
new_upstream_ref,
|
||||
usage_key_string,
|
||||
)
|
||||
raise ValidationError({"upstream_ref": str(exc)}) from exc
|
||||
except NoUpstream as exc:
|
||||
raise ValidationError({"upstream_ref": "value missing"}) from exc
|
||||
modulestore().update_item(downstream, request.user.id)
|
||||
# Note: We call `get_for_block` (rather than `try_get_for_block`) because if anything is wrong with the
|
||||
# upstream at this point, then that is completely unexpected, so it's appropriate to let the 500 happen.
|
||||
return Response(UpstreamLink.get_for_block(downstream).to_json())
|
||||
|
||||
def delete(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
|
||||
"""
|
||||
Sever an XBlock's link to upstream content.
|
||||
"""
|
||||
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
|
||||
try:
|
||||
sever_upstream_link(downstream)
|
||||
except NoUpstream as exc:
|
||||
logger.exception(
|
||||
"Tried to DELETE upstream link of '%s', but it wasn't linked to anything in the first place. "
|
||||
"Will do nothing. ",
|
||||
usage_key_string,
|
||||
)
|
||||
else:
|
||||
modulestore().update_item(downstream, request.user.id)
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
@view_auth_classes(is_authenticated=True)
|
||||
class SyncFromUpstreamView(DeveloperErrorViewMixin, APIView):
|
||||
"""
|
||||
Accept or decline an opportunity to sync a downstream block from its upstream content.
|
||||
"""
|
||||
|
||||
def post(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
|
||||
"""
|
||||
Pull latest updates to the block at {usage_key_string} from its linked upstream content.
|
||||
"""
|
||||
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
|
||||
try:
|
||||
sync_from_upstream(downstream, request.user)
|
||||
except UpstreamLinkException as exc:
|
||||
logger.exception(
|
||||
"Could not sync from upstream '%s' to downstream '%s'",
|
||||
downstream.upstream,
|
||||
usage_key_string,
|
||||
)
|
||||
raise ValidationError(detail=str(exc)) from exc
|
||||
modulestore().update_item(downstream, request.user.id)
|
||||
# Note: We call `get_for_block` (rather than `try_get_for_block`) because if anything is wrong with the
|
||||
# upstream at this point, then that is completely unexpected, so it's appropriate to let the 500 happen.
|
||||
return Response(UpstreamLink.get_for_block(downstream).to_json())
|
||||
|
||||
def delete(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response:
|
||||
"""
|
||||
Decline the latest updates to the block at {usage_key_string}.
|
||||
"""
|
||||
downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True)
|
||||
try:
|
||||
decline_sync(downstream)
|
||||
except (NoUpstream, BadUpstream, BadDownstream) as exc:
|
||||
# This is somewhat unexpected. If the upstream link is missing or invalid, then the downstream author
|
||||
# shouldn't have been prompted to accept/decline a sync in the first place. Of course, they could have just
|
||||
# hit the HTTP API anyway, or they could be viewing a Studio page which hasn't been refreshed in a while.
|
||||
# So, it's a 400, not a 500.
|
||||
logger.exception(
|
||||
"Tried to decline a sync to downstream '%s', but the upstream link '%s' is invalid.",
|
||||
usage_key_string,
|
||||
downstream.upstream,
|
||||
)
|
||||
raise ValidationError(str(exc)) from exc
|
||||
modulestore().update_item(downstream, request.user.id)
|
||||
return Response(status=204)
|
||||
|
||||
|
||||
def _load_accessible_block(user: User, usage_key_string: str, *, require_write_access: bool) -> XBlock:
|
||||
"""
|
||||
Given a logged in-user and a serialized usage key of an upstream-linked XBlock, load it from the ModuleStore,
|
||||
raising a DRF-friendly exception if anything goes wrong.
|
||||
|
||||
Raises NotFound if usage key is malformed, if the user lacks access, or if the block doesn't exist.
|
||||
"""
|
||||
not_found = NotFound(detail=f"Block not found or not accessible: {usage_key_string}")
|
||||
try:
|
||||
usage_key = UsageKey.from_string(usage_key_string)
|
||||
except InvalidKeyError as exc:
|
||||
raise ValidationError(detail=f"Malformed block usage key: {usage_key_string}") from exc
|
||||
if require_write_access and not has_studio_write_access(user, usage_key.context_key):
|
||||
raise not_found
|
||||
if not has_studio_read_access(user, usage_key.context_key):
|
||||
raise not_found
|
||||
try:
|
||||
block = modulestore().get_item(usage_key)
|
||||
except ItemNotFoundError as exc:
|
||||
raise not_found from exc
|
||||
return block
|
||||
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Unit tests for /api/contentstore/v2/downstreams/* JSON APIs.
|
||||
"""
|
||||
from unittest.mock import patch
|
||||
from django.conf import settings
|
||||
|
||||
from cms.lib.xblock.upstream_sync import UpstreamLink, BadUpstream
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from xmodule.modulestore.django import modulestore
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
|
||||
|
||||
from .. import downstreams as downstreams_views
|
||||
|
||||
|
||||
MOCK_LIB_KEY = "lib:OpenedX:CSPROB3"
|
||||
MOCK_UPSTREAM_REF = "lb:OpenedX:CSPROB3:html:843b4c73-1e2d-4ced-a0ff-24e503cdb3e4"
|
||||
MOCK_UPSTREAM_LINK = "{mfe_url}/library/{lib_key}/components?usageKey={usage_key}".format(
|
||||
mfe_url=settings.COURSE_AUTHORING_MICROFRONTEND_URL,
|
||||
lib_key=MOCK_LIB_KEY,
|
||||
usage_key=MOCK_UPSTREAM_REF,
|
||||
)
|
||||
MOCK_UPSTREAM_ERROR = "your LibraryGPT subscription has expired"
|
||||
|
||||
|
||||
def _get_upstream_link_good_and_syncable(downstream):
|
||||
return UpstreamLink(
|
||||
upstream_ref=downstream.upstream,
|
||||
version_synced=downstream.upstream_version,
|
||||
version_available=(downstream.upstream_version or 0) + 1,
|
||||
version_declined=downstream.upstream_version_declined,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
|
||||
def _get_upstream_link_bad(_downstream):
|
||||
raise BadUpstream(MOCK_UPSTREAM_ERROR)
|
||||
|
||||
|
||||
class _DownstreamViewTestMixin:
|
||||
"""
|
||||
Shared data and error test cases.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create a simple course with one unit and two videos, one of which is linked to an "upstream".
|
||||
"""
|
||||
super().setUp()
|
||||
self.course = CourseFactory.create()
|
||||
chapter = BlockFactory.create(category='chapter', parent=self.course)
|
||||
sequential = BlockFactory.create(category='sequential', parent=chapter)
|
||||
unit = BlockFactory.create(category='vertical', parent=sequential)
|
||||
self.regular_video_key = BlockFactory.create(category='video', parent=unit).usage_key
|
||||
self.downstream_video_key = BlockFactory.create(
|
||||
category='video', parent=unit, upstream=MOCK_UPSTREAM_REF, upstream_version=123,
|
||||
).usage_key
|
||||
self.fake_video_key = self.course.id.make_usage_key("video", "NoSuchVideo")
|
||||
self.superuser = UserFactory(username="superuser", password="password", is_staff=True, is_superuser=True)
|
||||
self.learner = UserFactory(username="learner", password="password")
|
||||
|
||||
def call_api(self, usage_key_string):
|
||||
raise NotImplementedError
|
||||
|
||||
def test_404_downstream_not_found(self):
|
||||
"""
|
||||
Do we raise 404 if the specified downstream block could not be loaded?
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(self.fake_video_key)
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.data["developer_message"]
|
||||
|
||||
def test_404_downstream_not_accessible(self):
|
||||
"""
|
||||
Do we raise 404 if the user lacks read access on the specified downstream block?
|
||||
"""
|
||||
self.client.login(username="learner", password="password")
|
||||
response = self.call_api(self.downstream_video_key)
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.data["developer_message"]
|
||||
|
||||
|
||||
class GetDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase):
|
||||
"""
|
||||
Test that `GET /api/v2/contentstore/downstreams/...` inspects a downstream's link to an upstream.
|
||||
"""
|
||||
def call_api(self, usage_key_string):
|
||||
return self.client.get(f"/api/contentstore/v2/downstreams/{usage_key_string}")
|
||||
|
||||
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
|
||||
def test_200_good_upstream(self):
|
||||
"""
|
||||
Does the happy path work?
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(self.downstream_video_key)
|
||||
assert response.status_code == 200
|
||||
assert response.data['upstream_ref'] == MOCK_UPSTREAM_REF
|
||||
assert response.data['error_message'] is None
|
||||
assert response.data['ready_to_sync'] is True
|
||||
assert response.data['upstream_link'] == MOCK_UPSTREAM_LINK
|
||||
|
||||
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_bad)
|
||||
def test_200_bad_upstream(self):
|
||||
"""
|
||||
If the upstream link is broken, do we still return 200, but with an error message in body?
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(self.downstream_video_key)
|
||||
assert response.status_code == 200
|
||||
assert response.data['upstream_ref'] == MOCK_UPSTREAM_REF
|
||||
assert response.data['error_message'] == MOCK_UPSTREAM_ERROR
|
||||
assert response.data['ready_to_sync'] is False
|
||||
assert response.data['upstream_link'] is None
|
||||
|
||||
def test_200_no_upstream(self):
|
||||
"""
|
||||
If the upstream link is missing, do we still return 200, but with an error message in body?
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(self.regular_video_key)
|
||||
assert response.status_code == 200
|
||||
assert response.data['upstream_ref'] is None
|
||||
assert "is not linked" in response.data['error_message']
|
||||
assert response.data['ready_to_sync'] is False
|
||||
assert response.data['upstream_link'] is None
|
||||
|
||||
|
||||
class PutDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase):
|
||||
"""
|
||||
Test that `PUT /api/v2/contentstore/downstreams/...` edits a downstream's link to an upstream.
|
||||
"""
|
||||
def call_api(self, usage_key_string, sync: str | None = None):
|
||||
return self.client.put(
|
||||
f"/api/contentstore/v2/downstreams/{usage_key_string}",
|
||||
data={
|
||||
"upstream_ref": MOCK_UPSTREAM_REF,
|
||||
**({"sync": sync} if sync else {}),
|
||||
},
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
@patch.object(downstreams_views, "fetch_customizable_fields")
|
||||
@patch.object(downstreams_views, "sync_from_upstream")
|
||||
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
|
||||
def test_200_with_sync(self, mock_sync, mock_fetch):
|
||||
"""
|
||||
Does the happy path work (with sync=True)?
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(self.regular_video_key, sync='true')
|
||||
assert response.status_code == 200
|
||||
video_after = modulestore().get_item(self.regular_video_key)
|
||||
assert mock_sync.call_count == 1
|
||||
assert mock_fetch.call_count == 0
|
||||
assert video_after.upstream == MOCK_UPSTREAM_REF
|
||||
|
||||
@patch.object(downstreams_views, "fetch_customizable_fields")
|
||||
@patch.object(downstreams_views, "sync_from_upstream")
|
||||
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
|
||||
def test_200_no_sync(self, mock_sync, mock_fetch):
|
||||
"""
|
||||
Does the happy path work (with sync=False)?
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(self.regular_video_key, sync='false')
|
||||
assert response.status_code == 200
|
||||
video_after = modulestore().get_item(self.regular_video_key)
|
||||
assert mock_sync.call_count == 0
|
||||
assert mock_fetch.call_count == 1
|
||||
assert video_after.upstream == MOCK_UPSTREAM_REF
|
||||
|
||||
@patch.object(downstreams_views, "fetch_customizable_fields", side_effect=BadUpstream(MOCK_UPSTREAM_ERROR))
|
||||
def test_400(self, sync: str):
|
||||
"""
|
||||
Do we raise a 400 if the provided upstream reference is malformed or not accessible?
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(self.downstream_video_key)
|
||||
assert response.status_code == 400
|
||||
assert MOCK_UPSTREAM_ERROR in response.data['developer_message']['upstream_ref']
|
||||
video_after = modulestore().get_item(self.regular_video_key)
|
||||
assert video_after.upstream is None
|
||||
|
||||
|
||||
class DeleteDownstreamViewTest(_DownstreamViewTestMixin, SharedModuleStoreTestCase):
|
||||
"""
|
||||
Test that `DELETE /api/v2/contentstore/downstreams/...` severs a downstream's link to an upstream.
|
||||
"""
|
||||
def call_api(self, usage_key_string):
|
||||
return self.client.delete(f"/api/contentstore/v2/downstreams/{usage_key_string}")
|
||||
|
||||
@patch.object(downstreams_views, "sever_upstream_link")
|
||||
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
|
||||
def test_204(self, mock_sever):
|
||||
"""
|
||||
Does the happy path work?
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(self.downstream_video_key)
|
||||
assert response.status_code == 204
|
||||
assert mock_sever.call_count == 1
|
||||
|
||||
@patch.object(downstreams_views, "sever_upstream_link")
|
||||
def test_204_no_upstream(self, mock_sever):
|
||||
"""
|
||||
If there's no upsream, do we still happily return 204?
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(self.regular_video_key)
|
||||
assert response.status_code == 204
|
||||
assert mock_sever.call_count == 1
|
||||
|
||||
|
||||
class _DownstreamSyncViewTestMixin(_DownstreamViewTestMixin):
|
||||
"""
|
||||
Shared tests between the /api/contentstore/v2/downstreams/.../sync endpoints.
|
||||
"""
|
||||
|
||||
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_bad)
|
||||
def test_400_bad_upstream(self):
|
||||
"""
|
||||
If the upstream link is bad, do we get a 400?
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(self.downstream_video_key)
|
||||
assert response.status_code == 400
|
||||
assert MOCK_UPSTREAM_ERROR in response.data["developer_message"][0]
|
||||
|
||||
def test_400_no_upstream(self):
|
||||
"""
|
||||
If the upstream link is missing, do we get a 400?
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(self.regular_video_key)
|
||||
assert response.status_code == 400
|
||||
assert "is not linked" in response.data["developer_message"][0]
|
||||
|
||||
|
||||
class PostDownstreamSyncViewTest(_DownstreamSyncViewTestMixin, SharedModuleStoreTestCase):
|
||||
"""
|
||||
Test that `POST /api/v2/contentstore/downstreams/.../sync` initiates a sync from the linked upstream.
|
||||
"""
|
||||
def call_api(self, usage_key_string):
|
||||
return self.client.post(f"/api/contentstore/v2/downstreams/{usage_key_string}/sync")
|
||||
|
||||
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
|
||||
@patch.object(downstreams_views, "sync_from_upstream")
|
||||
def test_200(self, mock_sync_from_upstream):
|
||||
"""
|
||||
Does the happy path work?
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(self.downstream_video_key)
|
||||
assert response.status_code == 200
|
||||
assert mock_sync_from_upstream.call_count == 1
|
||||
|
||||
|
||||
class DeleteDownstreamSyncViewtest(_DownstreamSyncViewTestMixin, SharedModuleStoreTestCase):
|
||||
"""
|
||||
Test that `DELETE /api/v2/contentstore/downstreams/.../sync` declines a sync from the linked upstream.
|
||||
"""
|
||||
def call_api(self, usage_key_string):
|
||||
return self.client.delete(f"/api/contentstore/v2/downstreams/{usage_key_string}/sync")
|
||||
|
||||
@patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable)
|
||||
@patch.object(downstreams_views, "decline_sync")
|
||||
def test_204(self, mock_decline_sync):
|
||||
"""
|
||||
Does the happy path work?
|
||||
"""
|
||||
self.client.login(username="superuser", password="password")
|
||||
response = self.call_api(self.downstream_video_key)
|
||||
assert response.status_code == 204
|
||||
assert mock_decline_sync.call_count == 1
|
||||
@@ -45,6 +45,7 @@ class HomePageCoursesViewV2Test(CourseTestCase):
|
||||
org=archived_course_key.org,
|
||||
end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC),
|
||||
)
|
||||
self.non_staff_client, _ = self.create_non_staff_authed_user_client()
|
||||
|
||||
def test_home_page_response(self):
|
||||
"""Get list of courses available to the logged in user.
|
||||
@@ -62,7 +63,7 @@ class HomePageCoursesViewV2Test(CourseTestCase):
|
||||
OrderedDict([
|
||||
("course_key", course_id),
|
||||
("display_name", self.course.display_name),
|
||||
("lms_link", f'//{settings.LMS_BASE}/courses/{course_id}/jump_to/{self.course.location}'),
|
||||
("lms_link", f'{settings.LMS_ROOT_URL}/courses/{course_id}/jump_to/{self.course.location}'),
|
||||
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.course.id)}'),
|
||||
("number", self.course.number),
|
||||
("org", self.course.org),
|
||||
@@ -76,7 +77,7 @@ class HomePageCoursesViewV2Test(CourseTestCase):
|
||||
("display_name", self.archived_course.display_name),
|
||||
(
|
||||
"lms_link",
|
||||
f'//{settings.LMS_BASE}/courses/{archived_course_id}/jump_to/{self.archived_course.location}'
|
||||
f'{settings.LMS_ROOT_URL}/courses/{archived_course_id}/jump_to/{self.archived_course.location}'
|
||||
),
|
||||
(
|
||||
"cms_link",
|
||||
@@ -139,7 +140,7 @@ class HomePageCoursesViewV2Test(CourseTestCase):
|
||||
self.assertEqual(response.data["results"]["courses"], [OrderedDict([
|
||||
("course_key", str(self.course.id)),
|
||||
("display_name", self.course.display_name),
|
||||
("lms_link", f'//{settings.LMS_BASE}/courses/{str(self.course.id)}/jump_to/{self.course.location}'),
|
||||
("lms_link", f'{settings.LMS_ROOT_URL}/courses/{str(self.course.id)}/jump_to/{self.course.location}'),
|
||||
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.course.id)}'),
|
||||
("number", self.course.number),
|
||||
("org", self.course.org),
|
||||
@@ -164,7 +165,11 @@ class HomePageCoursesViewV2Test(CourseTestCase):
|
||||
("display_name", self.archived_course.display_name),
|
||||
(
|
||||
"lms_link",
|
||||
f'//{settings.LMS_BASE}/courses/{str(self.archived_course.id)}/jump_to/{self.archived_course.location}',
|
||||
'{url_root}/courses/{course_id}/jump_to/{location}'.format(
|
||||
url_root=settings.LMS_ROOT_URL,
|
||||
course_id=str(self.archived_course.id),
|
||||
location=self.archived_course.location
|
||||
),
|
||||
),
|
||||
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.archived_course.id)}'),
|
||||
("number", self.archived_course.number),
|
||||
@@ -190,7 +195,11 @@ class HomePageCoursesViewV2Test(CourseTestCase):
|
||||
("display_name", self.archived_course.display_name),
|
||||
(
|
||||
"lms_link",
|
||||
f'//{settings.LMS_BASE}/courses/{str(self.archived_course.id)}/jump_to/{self.archived_course.location}',
|
||||
'{url_root}/courses/{course_id}/jump_to/{location}'.format(
|
||||
url_root=settings.LMS_ROOT_URL,
|
||||
course_id=str(self.archived_course.id),
|
||||
location=self.archived_course.location
|
||||
),
|
||||
),
|
||||
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.archived_course.id)}'),
|
||||
("number", self.archived_course.number),
|
||||
@@ -239,3 +248,100 @@ class HomePageCoursesViewV2Test(CourseTestCase):
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
mock_modulestore().get_course_summaries.assert_called_once()
|
||||
mock_course_overview.get_all_courses.assert_not_called()
|
||||
|
||||
@ddt.data(
|
||||
("active_only", "true"),
|
||||
("archived_only", "true"),
|
||||
("search", "sample"),
|
||||
("order", "org"),
|
||||
("page", 1),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_if_empty_list_of_courses(self, query_param, value):
|
||||
"""Get list of courses when no courses are available.
|
||||
|
||||
Expected result:
|
||||
- An empty list of courses available to the logged in user.
|
||||
"""
|
||||
self.active_course.delete()
|
||||
self.archived_course.delete()
|
||||
|
||||
response = self.client.get(self.api_v2_url, {query_param: value})
|
||||
|
||||
self.assertEqual(len(response.data['results']['courses']), 0)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
|
||||
@ddt.data(
|
||||
("active_only", "true", 2, 0),
|
||||
("archived_only", "true", 0, 1),
|
||||
("search", "foo", 1, 0),
|
||||
("search", "demo", 0, 1),
|
||||
("order", "org", 2, 1),
|
||||
("order", "display_name", 2, 1),
|
||||
("order", "number", 2, 1),
|
||||
("order", "run", 2, 1)
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_filter_and_ordering_courses(
|
||||
self,
|
||||
filter_key,
|
||||
filter_value,
|
||||
expected_active_length,
|
||||
expected_archived_length
|
||||
):
|
||||
"""Get list of courses when filter and ordering are applied.
|
||||
|
||||
This test creates two courses besides the default courses created in the setUp method.
|
||||
Then filters and orders them based on the filter_key and filter_value passed as query parameters.
|
||||
|
||||
Expected result:
|
||||
- A list of courses available to the logged in user for the specified filter and order.
|
||||
"""
|
||||
archived_course_key = self.store.make_course_key("demo-org", "demo-number", "demo-run")
|
||||
CourseOverviewFactory.create(
|
||||
display_name="Course (Demo)",
|
||||
id=archived_course_key,
|
||||
org=archived_course_key.org,
|
||||
end=(datetime.now() - timedelta(days=365)).replace(tzinfo=pytz.UTC),
|
||||
)
|
||||
active_course_key = self.store.make_course_key("foo-org", "foo-number", "foo-run")
|
||||
CourseOverviewFactory.create(
|
||||
display_name="Course (Foo)",
|
||||
id=active_course_key,
|
||||
org=active_course_key.org,
|
||||
)
|
||||
|
||||
response = self.client.get(self.api_v2_url, {filter_key: filter_value})
|
||||
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(
|
||||
len([course for course in response.data["results"]["courses"] if course["is_active"]]),
|
||||
expected_active_length
|
||||
)
|
||||
self.assertEqual(
|
||||
len([course for course in response.data["results"]["courses"] if not course["is_active"]]),
|
||||
expected_archived_length
|
||||
)
|
||||
|
||||
@ddt.data(
|
||||
("active_only", "true"),
|
||||
("archived_only", "true"),
|
||||
("search", "sample"),
|
||||
("order", "org"),
|
||||
("page", 1),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_if_empty_list_of_courses_non_staff(self, query_param, value):
|
||||
"""Get list of courses when no courses are available for non-staff users.
|
||||
|
||||
Expected result:
|
||||
- An empty list of courses available to the logged in user.
|
||||
"""
|
||||
self.active_course.delete()
|
||||
self.archived_course.delete()
|
||||
|
||||
response = self.non_staff_client.get(self.api_v2_url, {query_param: value})
|
||||
|
||||
self.assertEqual(len(response.data["results"]["courses"]), 0)
|
||||
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||
|
||||
@@ -504,6 +504,11 @@ class TestCoursewareSearchIndexer(MixedWithOptionsTestCase):
|
||||
""" Test for removing course from CourseAboutSearchIndexer """
|
||||
self._test_delete_course_from_search_index_after_course_deletion(self.store)
|
||||
|
||||
def test_empty_course(self):
|
||||
empty_course = CourseFactory.create(modulestore=self.store, start=datetime(2015, 3, 1, tzinfo=UTC))
|
||||
added_to_index = CoursewareSearchIndexer.do_course_reindex(self.store, empty_course.id)
|
||||
assert added_to_index == 0
|
||||
|
||||
|
||||
@patch('django.conf.settings.SEARCH_ENGINE', 'search.tests.utils.ForceRefreshElasticSearchEngine')
|
||||
@ddt.ddt
|
||||
|
||||
98
cms/djangoapps/contentstore/tests/test_filters.py
Normal file
98
cms/djangoapps/contentstore/tests/test_filters.py
Normal file
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
Unit tests for the asset upload endpoint.
|
||||
"""
|
||||
from datetime import datetime
|
||||
from urllib.parse import urljoin
|
||||
|
||||
from pytz import UTC
|
||||
|
||||
from django.test import override_settings
|
||||
from cms.djangoapps.contentstore import asset_storage_handlers
|
||||
from opaque_keys.edx.locator import CourseLocator
|
||||
from openedx_filters import PipelineStep
|
||||
from xmodule.contentstore.content import StaticContent
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
|
||||
|
||||
class TestPageURLRequestedPipelineStep(PipelineStep):
|
||||
"""
|
||||
Utility class used when getting steps for pipeline.
|
||||
"""
|
||||
|
||||
def run_filter(self, url, org): # pylint: disable=arguments-differ
|
||||
"""Pipeline step that modifies lms url creation."""
|
||||
url = "https://lms-url-creation"
|
||||
org = "org"
|
||||
return {
|
||||
"url": url,
|
||||
"org": org,
|
||||
}
|
||||
|
||||
|
||||
class LMSPageURLRequestedFiltersTest(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests for the Open edX Filters associated with the lms url requested process.
|
||||
This class guarantees that the following filters are triggered during the microsite render:
|
||||
- LMSPageURLRequested
|
||||
"""
|
||||
|
||||
def setUp(self): # pylint: disable=arguments-differ
|
||||
super().setUp()
|
||||
self.upload_date = datetime(2013, 6, 1, 10, 30, tzinfo=UTC)
|
||||
self.content_type = 'image/jpg'
|
||||
self.course_key = CourseLocator('org', 'class', 'run')
|
||||
self.location = self.course_key.make_asset_key('asset', 'my_file_name.jpg')
|
||||
self.thumbnail_location = self.course_key.make_asset_key('thumbnail', 'my_file_name_thumb.jpg')
|
||||
|
||||
self.asset_url = StaticContent.serialize_asset_key_with_slash(self.location)
|
||||
|
||||
@override_settings(
|
||||
OPEN_EDX_FILTERS_CONFIG={
|
||||
"org.openedx.course_authoring.lms.page.url.requested.v1": {
|
||||
"pipeline": [
|
||||
"common.djangoapps.util.tests.test_filters.TestPageURLRequestedPipelineStep",
|
||||
],
|
||||
"fail_silently": False,
|
||||
},
|
||||
},
|
||||
)
|
||||
def test_lms_url_requested_filter_executed(self):
|
||||
"""
|
||||
Test that filter get new LMS URL for asset URL generation
|
||||
based on the course organization settings for org.
|
||||
Expected result:
|
||||
- LMSPageURLRequested is triggered and executes TestPageURLRequestedPipelineStep.
|
||||
- The arguments that the receiver gets are the arguments used by the filter.
|
||||
"""
|
||||
output = asset_storage_handlers.get_asset_json(
|
||||
"my_file",
|
||||
self.content_type,
|
||||
self.upload_date,
|
||||
self.location,
|
||||
self.thumbnail_location,
|
||||
True,
|
||||
self.course_key
|
||||
)
|
||||
|
||||
self.assertEqual(output.get('external_url'), urljoin('https://lms-url-creation', self.asset_url))
|
||||
|
||||
@override_settings(OPEN_EDX_FILTERS_CONFIG={}, LMS_ROOT_URL="https://lms-base")
|
||||
def test_lms_url_requested_without_filter_configuration(self):
|
||||
"""
|
||||
Test that filter get new LMS URL for asset URL generation
|
||||
based on LMS_ROOT_URL settings because OPEN_EDX_FILTERS_CONFIG is not set.
|
||||
Expected result:
|
||||
- Returns the asset URL with domain base LMS_ROOT_URL.
|
||||
- The get process ends successfully.
|
||||
"""
|
||||
output = asset_storage_handlers.get_asset_json(
|
||||
"my_file",
|
||||
self.content_type,
|
||||
self.upload_date,
|
||||
self.location,
|
||||
self.thumbnail_location,
|
||||
True,
|
||||
self.course_key
|
||||
)
|
||||
|
||||
self.assertEqual(output.get('external_url'), urljoin('https://lms-base', self.asset_url))
|
||||
@@ -960,4 +960,32 @@ class CourseUpdateNotificationTests(ModuleStoreTestCase):
|
||||
send_course_update_notification(self.course.id, content, self.user)
|
||||
assert Notification.objects.all().count() == 1
|
||||
notification = Notification.objects.first()
|
||||
assert notification.content == "<p><strong><p>content</p></strong></p>"
|
||||
assert notification.content == "<p><strong>content</strong></p>"
|
||||
|
||||
def test_if_content_is_plain_text(self):
|
||||
"""
|
||||
Test that the course_update notification is sent.
|
||||
"""
|
||||
user = UserFactory()
|
||||
CourseEnrollment.enroll(user=user, course_key=self.course.id)
|
||||
assert Notification.objects.all().count() == 0
|
||||
content = "<p>content<p>Sub content</p><h1>heading</h1></p><img src='' />"
|
||||
send_course_update_notification(self.course.id, content, self.user)
|
||||
assert Notification.objects.all().count() == 1
|
||||
notification = Notification.objects.first()
|
||||
assert notification.content == "<p><strong>content Sub content heading</strong></p>"
|
||||
|
||||
def test_if_html_unescapes(self):
|
||||
"""
|
||||
Tests if html unescapes when creating content of course update notification
|
||||
"""
|
||||
user = UserFactory()
|
||||
CourseEnrollment.enroll(user=user, course_key=self.course.id)
|
||||
assert Notification.objects.all().count() == 0
|
||||
content = "<p><p> &nbsp;</p><br />"\
|
||||
"<p>abcd</p><br />"\
|
||||
"<p>&nbsp;</p><br /></p>"
|
||||
send_course_update_notification(self.course.id, content, self.user)
|
||||
assert Notification.objects.all().count() == 1
|
||||
notification = Notification.objects.first()
|
||||
assert notification.content == "<p><strong>abcd</strong></p>"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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
|
||||
|
||||
# .. toggle_name: FEATURES['ENABLE_EXPORT_GIT']
|
||||
@@ -593,3 +594,96 @@ def default_enable_flexible_peer_openassessments(course_key):
|
||||
level to opt in/out of rolling forward this feature.
|
||||
"""
|
||||
return DEFAULT_ENABLE_FLEXIBLE_PEER_OPENASSESSMENTS.is_enabled(course_key)
|
||||
|
||||
|
||||
# .. toggle_name: FEATURES['ENABLE_CONTENT_LIBRARIES']
|
||||
# .. toggle_implementation: SettingDictToggle
|
||||
# .. toggle_default: True
|
||||
# .. toggle_description: Enables use of the legacy and v2 libraries waffle flags.
|
||||
# Note that legacy content libraries are only supported in courses using split mongo.
|
||||
# .. toggle_use_cases: open_edx
|
||||
# .. toggle_creation_date: 2015-03-06
|
||||
# .. toggle_target_removal_date: 2025-04-09
|
||||
# .. toggle_warning: This flag is deprecated in Sumac, and will be removed in favor of the disable_legacy_libraries and
|
||||
# disable_new_libraries waffle flags.
|
||||
ENABLE_CONTENT_LIBRARIES = SettingDictToggle(
|
||||
"FEATURES", "ENABLE_CONTENT_LIBRARIES", default=True, module_name=__name__
|
||||
)
|
||||
|
||||
# .. toggle_name: contentstore.new_studio_mfe.disable_legacy_libraries
|
||||
# .. toggle_implementation: WaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: Hides legacy (v1) Libraries tab in Authoring MFE.
|
||||
# This toggle interacts with ENABLE_CONTENT_LIBRARIES toggle: if this is disabled, then legacy libraries are also
|
||||
# disabled.
|
||||
# .. toggle_use_cases: open_edx
|
||||
# .. toggle_creation_date: 2024-10-02
|
||||
# .. toggle_target_removal_date: 2025-04-09
|
||||
# .. toggle_tickets: https://github.com/openedx/frontend-app-authoring/issues/1334
|
||||
# .. toggle_warning: Legacy libraries are deprecated in Sumac, cf https://github.com/openedx/edx-platform/issues/32457
|
||||
DISABLE_LEGACY_LIBRARIES = WaffleFlag(
|
||||
f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.disable_legacy_libraries',
|
||||
__name__,
|
||||
CONTENTSTORE_LOG_PREFIX,
|
||||
)
|
||||
|
||||
|
||||
def libraries_v1_enabled():
|
||||
"""
|
||||
Returns a boolean if Libraries V2 is enabled in the new Studio Home.
|
||||
"""
|
||||
return (
|
||||
ENABLE_CONTENT_LIBRARIES.is_enabled() and
|
||||
not DISABLE_LEGACY_LIBRARIES.is_enabled()
|
||||
)
|
||||
|
||||
|
||||
# .. toggle_name: contentstore.new_studio_mfe.disable_new_libraries
|
||||
# .. toggle_implementation: WaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: Hides new Libraries v2 tab in Authoring MFE.
|
||||
# This toggle interacts with settings.MEILISEARCH_ENABLED and ENABLE_CONTENT_LIBRARIES toggle: if these flags are
|
||||
# False, then v2 libraries are also disabled.
|
||||
# .. toggle_use_cases: open_edx
|
||||
# .. toggle_creation_date: 2024-10-02
|
||||
# .. toggle_target_removal_date: 2025-04-09
|
||||
# .. toggle_tickets: https://github.com/openedx/frontend-app-authoring/issues/1334
|
||||
# .. toggle_warning: Libraries v2 are in beta for Sumac, will be fully supported in Teak.
|
||||
DISABLE_NEW_LIBRARIES = WaffleFlag(
|
||||
f'{CONTENTSTORE_NAMESPACE}.new_studio_mfe.disable_new_libraries',
|
||||
__name__,
|
||||
CONTENTSTORE_LOG_PREFIX,
|
||||
)
|
||||
|
||||
|
||||
def libraries_v2_enabled():
|
||||
"""
|
||||
Returns a boolean if Libraries V2 is enabled in the new Studio Home.
|
||||
|
||||
Requires the ENABLE_CONTENT_LIBRARIES feature flag to be enabled, plus Meilisearch.
|
||||
"""
|
||||
return (
|
||||
ENABLE_CONTENT_LIBRARIES.is_enabled() and
|
||||
search_api.is_meilisearch_enabled() and
|
||||
not DISABLE_NEW_LIBRARIES.is_enabled()
|
||||
)
|
||||
|
||||
|
||||
# .. toggle_name: contentstore.enable_course_optimizer
|
||||
# .. toggle_implementation: CourseWaffleFlag
|
||||
# .. toggle_default: False
|
||||
# .. toggle_description: This flag enables the use of unique anonymous_user_id during studio preview
|
||||
# .. toggle_use_cases: temporary
|
||||
# .. toggle_creation_date: 2022-05-04
|
||||
# .. toggle_target_removal_date: 2022-05-30
|
||||
# .. toggle_tickets: MST-1455
|
||||
ENABLE_COURSE_OPTIMIZER = CourseWaffleFlag(
|
||||
f'{CONTENTSTORE_NAMESPACE}.enable_course_optimizer', __name__
|
||||
)
|
||||
|
||||
|
||||
def enable_course_optimizer(course_id):
|
||||
"""
|
||||
Returns a boolean if individualized anonymous_user_id is enabled on the course
|
||||
"""
|
||||
return ENABLE_COURSE_OPTIMIZER.is_enabled(course_id)
|
||||
|
||||
@@ -3,12 +3,13 @@ Common utility functions useful throughout the contentstore
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import configparser
|
||||
import html
|
||||
import logging
|
||||
import re
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime, timezone
|
||||
from urllib.parse import quote_plus
|
||||
from urllib.parse import quote_plus, urlencode, urlunparse, urlparse
|
||||
from uuid import uuid4
|
||||
|
||||
from bs4 import BeautifulSoup
|
||||
@@ -34,7 +35,32 @@ from milestones import api as milestones_api
|
||||
from pytz import UTC
|
||||
from xblock.fields import Scope
|
||||
|
||||
from cms.djangoapps.contentstore.toggles import exam_setting_view_enabled
|
||||
from cms.djangoapps.contentstore.toggles import (
|
||||
exam_setting_view_enabled,
|
||||
libraries_v1_enabled,
|
||||
libraries_v2_enabled,
|
||||
split_library_view_on_dashboard,
|
||||
use_new_advanced_settings_page,
|
||||
use_new_course_outline_page,
|
||||
use_new_certificates_page,
|
||||
use_new_export_page,
|
||||
use_new_files_uploads_page,
|
||||
use_new_grading_page,
|
||||
use_new_group_configurations_page,
|
||||
use_new_course_team_page,
|
||||
use_new_home_page,
|
||||
use_new_import_page,
|
||||
use_new_schedule_details_page,
|
||||
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,
|
||||
use_new_custom_pages,
|
||||
)
|
||||
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
|
||||
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
|
||||
from common.djangoapps.course_action_state.models import CourseRerunUIStateManager, CourseRerunState
|
||||
from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError
|
||||
from common.djangoapps.course_modes.models import CourseMode
|
||||
@@ -75,29 +101,6 @@ from openedx.core.lib.html_to_text import html_to_text
|
||||
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
|
||||
from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME
|
||||
from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML
|
||||
from cms.djangoapps.contentstore.toggles import (
|
||||
split_library_view_on_dashboard,
|
||||
use_new_advanced_settings_page,
|
||||
use_new_course_outline_page,
|
||||
use_new_certificates_page,
|
||||
use_new_export_page,
|
||||
use_new_files_uploads_page,
|
||||
use_new_grading_page,
|
||||
use_new_group_configurations_page,
|
||||
use_new_course_team_page,
|
||||
use_new_home_page,
|
||||
use_new_import_page,
|
||||
use_new_schedule_details_page,
|
||||
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,
|
||||
use_new_custom_pages,
|
||||
)
|
||||
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
|
||||
from cms.djangoapps.models.settings.course_metadata import CourseMetadata
|
||||
from xmodule.library_tools import LegacyLibraryToolsService
|
||||
from xmodule.course_block import DEFAULT_START_DATE # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.data import CertificatesDisplayBehaviors
|
||||
@@ -190,31 +193,30 @@ def get_lms_link_for_item(location, preview=False):
|
||||
"""
|
||||
assert isinstance(location, UsageKey)
|
||||
|
||||
# checks LMS_BASE value in site configuration for the given course_org_filter(org)
|
||||
# if not found returns settings.LMS_BASE
|
||||
# checks LMS_ROOT_URL value in site configuration for the given course_org_filter(org)
|
||||
# if not found returns settings.LMS_ROOT_URL
|
||||
lms_base = SiteConfiguration.get_value_for_org(
|
||||
location.org,
|
||||
"LMS_BASE",
|
||||
settings.LMS_BASE
|
||||
"LMS_ROOT_URL",
|
||||
settings.LMS_ROOT_URL
|
||||
)
|
||||
query_string = ''
|
||||
|
||||
if lms_base is None:
|
||||
return None
|
||||
|
||||
if preview:
|
||||
# checks PREVIEW_LMS_BASE value in site configuration for the given course_org_filter(org)
|
||||
# if not found returns settings.FEATURES.get('PREVIEW_LMS_BASE')
|
||||
lms_base = SiteConfiguration.get_value_for_org(
|
||||
location.org,
|
||||
"PREVIEW_LMS_BASE",
|
||||
settings.FEATURES.get('PREVIEW_LMS_BASE')
|
||||
)
|
||||
params = {'preview': '1'}
|
||||
query_string = urlencode(params)
|
||||
|
||||
return "//{lms_base}/courses/{course_key}/jump_to/{location}".format(
|
||||
lms_base=lms_base,
|
||||
url_parts = list(urlparse(lms_base))
|
||||
url_parts[2] = '/courses/{course_key}/jump_to/{location}'.format(
|
||||
course_key=str(location.course_key),
|
||||
location=str(location),
|
||||
)
|
||||
url_parts[4] = query_string
|
||||
|
||||
return urlunparse(url_parts)
|
||||
|
||||
|
||||
def get_lms_link_for_certificate_web_view(course_key, mode):
|
||||
@@ -427,6 +429,18 @@ def get_course_outline_url(course_locator) -> str:
|
||||
return course_outline_url
|
||||
|
||||
|
||||
def get_library_content_picker_url(course_locator) -> str:
|
||||
"""
|
||||
Gets course authoring microfrontend library content picker URL for the given parent block.
|
||||
"""
|
||||
content_picker_url = None
|
||||
if libraries_v2_enabled():
|
||||
mfe_base_url = get_course_authoring_url(course_locator)
|
||||
content_picker_url = f'{mfe_base_url}/component-picker?variant=published'
|
||||
|
||||
return content_picker_url
|
||||
|
||||
|
||||
def get_unit_url(course_locator, unit_locator) -> str:
|
||||
"""
|
||||
Gets course authoring microfrontend URL for unit page view.
|
||||
@@ -1536,11 +1550,13 @@ def get_library_context(request, request_is_json=False):
|
||||
_format_library_for_view,
|
||||
)
|
||||
from cms.djangoapps.contentstore.views.library import (
|
||||
LIBRARIES_ENABLED,
|
||||
user_can_view_create_library_button,
|
||||
)
|
||||
from openedx.core.djangoapps.content_libraries.api import (
|
||||
user_can_create_library,
|
||||
)
|
||||
|
||||
libraries = _accessible_libraries_iter(request.user) if LIBRARIES_ENABLED else []
|
||||
libraries = _accessible_libraries_iter(request.user) if libraries_v1_enabled() else []
|
||||
data = {
|
||||
'libraries': [_format_library_for_view(lib, request) for lib in libraries],
|
||||
}
|
||||
@@ -1550,8 +1566,9 @@ def get_library_context(request, request_is_json=False):
|
||||
**data,
|
||||
'in_process_course_actions': [],
|
||||
'courses': [],
|
||||
'libraries_enabled': LIBRARIES_ENABLED,
|
||||
'libraries_enabled': libraries_v1_enabled(),
|
||||
'show_new_library_button': user_can_view_create_library_button(request.user) and request.user.is_active,
|
||||
'show_new_library_v2_button': user_can_create_library(request.user),
|
||||
'user': request.user,
|
||||
'request_course_creator_url': reverse('request_course_creator'),
|
||||
'course_creator_status': _get_course_creator_status(request.user),
|
||||
@@ -1671,9 +1688,11 @@ def get_home_context(request, no_course=False):
|
||||
ENABLE_GLOBAL_STAFF_OPTIMIZATION,
|
||||
)
|
||||
from cms.djangoapps.contentstore.views.library import (
|
||||
LIBRARIES_ENABLED,
|
||||
user_can_view_create_library_button,
|
||||
)
|
||||
from openedx.core.djangoapps.content_libraries.api import (
|
||||
user_can_create_library,
|
||||
)
|
||||
|
||||
active_courses = []
|
||||
archived_courses = []
|
||||
@@ -1687,7 +1706,7 @@ def get_home_context(request, no_course=False):
|
||||
if not no_course:
|
||||
active_courses, archived_courses, in_process_course_actions = get_course_context(request)
|
||||
|
||||
if not split_library_view_on_dashboard() and LIBRARIES_ENABLED and not no_course:
|
||||
if not split_library_view_on_dashboard() and libraries_v1_enabled() and not no_course:
|
||||
libraries = get_library_context(request, True)['libraries']
|
||||
|
||||
home_context = {
|
||||
@@ -1695,11 +1714,14 @@ def get_home_context(request, no_course=False):
|
||||
'split_studio_home': split_library_view_on_dashboard(),
|
||||
'archived_courses': archived_courses,
|
||||
'in_process_course_actions': in_process_course_actions,
|
||||
'libraries_enabled': LIBRARIES_ENABLED,
|
||||
'libraries_enabled': libraries_v1_enabled(),
|
||||
'libraries_v1_enabled': libraries_v1_enabled(),
|
||||
'libraries_v2_enabled': libraries_v2_enabled(),
|
||||
'taxonomies_enabled': not is_tagging_feature_disabled(),
|
||||
'taxonomy_list_mfe_url': get_taxonomy_list_url(),
|
||||
'libraries': libraries,
|
||||
'show_new_library_button': user_can_view_create_library_button(user),
|
||||
'show_new_library_v2_button': user_can_create_library(user),
|
||||
'user': user,
|
||||
'request_course_creator_url': reverse('request_course_creator'),
|
||||
'course_creator_status': _get_course_creator_status(user),
|
||||
@@ -2041,6 +2063,7 @@ def get_container_handler_context(request, usage_key, course, xblock): # pylint
|
||||
'user_clipboard': user_clipboard,
|
||||
'is_fullwidth_content': is_library_xblock,
|
||||
'course_sequence_ids': course_sequence_ids,
|
||||
'library_content_picker_url': get_library_content_picker_url(course.id),
|
||||
}
|
||||
return context
|
||||
|
||||
@@ -2243,23 +2266,11 @@ def clean_html_body(html_body):
|
||||
"""
|
||||
Get html body, remove tags and limit to 500 characters
|
||||
"""
|
||||
html_body = html.unescape(html_body).strip()
|
||||
html_body = BeautifulSoup(Truncator(html_body).chars(500, html=True), 'html.parser')
|
||||
|
||||
tags_to_remove = [
|
||||
"a", "link", # Link Tags
|
||||
"img", "picture", "source", # Image Tags
|
||||
"video", "track", # Video Tags
|
||||
"audio", # Audio Tags
|
||||
"embed", "object", "iframe", # Embedded Content
|
||||
"script"
|
||||
]
|
||||
|
||||
# Remove the specified tags while keeping their content
|
||||
for tag in tags_to_remove:
|
||||
for match in html_body.find_all(tag):
|
||||
match.unwrap()
|
||||
|
||||
return str(html_body)
|
||||
text_content = html_body.get_text(separator=" ").strip()
|
||||
text_content = text_content.replace('\n', '').replace('\r', '')
|
||||
return text_content
|
||||
|
||||
|
||||
def send_course_update_notification(course_key, content, user):
|
||||
|
||||
@@ -967,26 +967,38 @@ def get_course_youtube_edx_video_ids(course_id):
|
||||
"""
|
||||
Get a list of youtube edx_video_ids
|
||||
"""
|
||||
error_msg = "Invalid course_key: '%s'." % course_id
|
||||
try:
|
||||
invalid_key_error_msg = "Invalid course_key: '%s'." % course_id
|
||||
unexpected_error_msg = "Unexpected error occurred for course_id: '%s'." % course_id
|
||||
|
||||
try: # lint-amnesty, pylint: disable=too-many-nested-blocks
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
course = modulestore().get_course(course_key)
|
||||
except InvalidKeyError:
|
||||
return JsonResponse({'error': error_msg}, status=500)
|
||||
blocks = []
|
||||
block_yt_field = 'youtube_id_1_0'
|
||||
block_edx_id_field = 'edx_video_id'
|
||||
if hasattr(course, 'get_children'):
|
||||
for section in course.get_children():
|
||||
for subsection in section.get_children():
|
||||
for vertical in subsection.get_children():
|
||||
for block in vertical.get_children():
|
||||
blocks.append(block)
|
||||
|
||||
edx_video_ids = []
|
||||
for block in blocks:
|
||||
if hasattr(block, block_yt_field) and getattr(block, block_yt_field):
|
||||
if getattr(block, block_edx_id_field):
|
||||
edx_video_ids.append(getattr(block, block_edx_id_field))
|
||||
blocks = []
|
||||
block_yt_field = 'youtube_id_1_0'
|
||||
block_edx_id_field = 'edx_video_id'
|
||||
if hasattr(course, 'get_children'):
|
||||
for section in course.get_children():
|
||||
for subsection in section.get_children():
|
||||
for vertical in subsection.get_children():
|
||||
for block in vertical.get_children():
|
||||
blocks.append(block)
|
||||
|
||||
edx_video_ids = []
|
||||
for block in blocks:
|
||||
if hasattr(block, block_yt_field) and getattr(block, block_yt_field):
|
||||
if getattr(block, block_edx_id_field):
|
||||
edx_video_ids.append(getattr(block, block_edx_id_field))
|
||||
|
||||
except InvalidKeyError as error:
|
||||
LOGGER.exception(
|
||||
f"InvalidKeyError occurred while getting YouTube video IDs for course_id: {course_id}: {error}"
|
||||
)
|
||||
return JsonResponse({'error': invalid_key_error_msg}, status=500)
|
||||
except Exception as error:
|
||||
LOGGER.exception(
|
||||
f"Unexpected error occurred while getting YouTube video IDs for course_id: {course_id}: {error}"
|
||||
)
|
||||
return JsonResponse({'error': unexpected_error_msg}, status=500)
|
||||
|
||||
return JsonResponse({'edx_video_ids': edx_video_ids}, status=200)
|
||||
|
||||
@@ -135,6 +135,8 @@ def xblock_handler(request, usage_key_string=None):
|
||||
if duplicate_source_locator is not present
|
||||
:staged_content: use "clipboard" to paste from the OLX user's clipboard. (Incompatible with all other
|
||||
fields except parent_locator)
|
||||
:library_content_key: the key of the library content to add. (Incompatible with
|
||||
all other fields except parent_locator)
|
||||
The locator (unicode representation of a UsageKey) for the created xblock (minus children) is returned.
|
||||
"""
|
||||
return handle_xblock(request, usage_key_string)
|
||||
|
||||
@@ -11,6 +11,7 @@ from django.core.exceptions import PermissionDenied
|
||||
from django.http import Http404, HttpResponseBadRequest
|
||||
from django.shortcuts import redirect
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.decorators.clickjacking import xframe_options_exempt
|
||||
from django.views.decorators.http import require_GET
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
@@ -25,7 +26,12 @@ from common.djangoapps.student.auth import has_course_author_access
|
||||
from common.djangoapps.xblock_django.api import authorable_xblocks, disabled_xblocks
|
||||
from common.djangoapps.xblock_django.models import XBlockStudioConfigurationFlag
|
||||
from cms.djangoapps.contentstore.helpers import is_unit
|
||||
from cms.djangoapps.contentstore.toggles import use_new_problem_editor, use_new_unit_page
|
||||
from cms.djangoapps.contentstore.toggles import (
|
||||
libraries_v1_enabled,
|
||||
libraries_v2_enabled,
|
||||
use_new_problem_editor,
|
||||
use_new_unit_page,
|
||||
)
|
||||
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import load_services_for_studio
|
||||
from openedx.core.lib.xblock_utils import get_aside_from_xblock, is_xblock_aside
|
||||
from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration
|
||||
@@ -35,13 +41,26 @@ from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, py
|
||||
|
||||
__all__ = [
|
||||
'container_handler',
|
||||
'component_handler'
|
||||
'component_handler',
|
||||
'container_embed_handler',
|
||||
]
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# NOTE: This list is disjoint from ADVANCED_COMPONENT_TYPES
|
||||
COMPONENT_TYPES = ['discussion', 'library', 'html', 'openassessment', 'problem', 'video', 'drag-and-drop-v2']
|
||||
COMPONENT_TYPES = [
|
||||
'html',
|
||||
'video',
|
||||
'problem',
|
||||
'itembank',
|
||||
'library_v2', # Not an XBlock
|
||||
'library',
|
||||
'discussion',
|
||||
'openassessment',
|
||||
'drag-and-drop-v2',
|
||||
]
|
||||
|
||||
BETA_COMPONENT_TYPES = ['library_v2', 'itembank']
|
||||
|
||||
ADVANCED_COMPONENT_TYPES = sorted({name for name, class_ in XBlock.load_classes()} - set(COMPONENT_TYPES))
|
||||
|
||||
@@ -56,7 +75,7 @@ CONTAINER_TEMPLATES = [
|
||||
"add-xblock-component-support-legend", "add-xblock-component-support-level", "add-xblock-component-menu-problem",
|
||||
"xblock-string-field-editor", "xblock-access-editor", "publish-xblock", "publish-history", "tag-list",
|
||||
"unit-outline", "container-message", "container-access", "license-selector", "copy-clipboard-button",
|
||||
"edit-title-button",
|
||||
"edit-title-button", "edit-upstream-alert",
|
||||
]
|
||||
|
||||
|
||||
@@ -95,6 +114,10 @@ def _load_mixed_class(category):
|
||||
"""
|
||||
Load an XBlock by category name, and apply all defined mixins
|
||||
"""
|
||||
# Libraries v2 content doesn't have an XBlock.
|
||||
if category == 'library_v2':
|
||||
return None
|
||||
|
||||
component_class = XBlock.load_class(category)
|
||||
mixologist = Mixologist(settings.XBLOCK_MIXINS)
|
||||
return mixologist.mix(component_class)
|
||||
@@ -141,6 +164,36 @@ def container_handler(request, usage_key_string): # pylint: disable=too-many-st
|
||||
return HttpResponseBadRequest("Only supports HTML requests")
|
||||
|
||||
|
||||
@require_GET
|
||||
@login_required
|
||||
@xframe_options_exempt
|
||||
def container_embed_handler(request, usage_key_string): # pylint: disable=too-many-statements
|
||||
"""
|
||||
Returns an HttpResponse with HTML content for the container XBlock.
|
||||
The returned HTML is a chromeless rendering of the XBlock.
|
||||
|
||||
GET
|
||||
html: returns the HTML page for editing a container
|
||||
json: not currently supported
|
||||
"""
|
||||
|
||||
# Avoiding a circular dependency
|
||||
from ..utils import get_container_handler_context
|
||||
|
||||
try:
|
||||
usage_key = UsageKey.from_string(usage_key_string)
|
||||
except InvalidKeyError: # Raise Http404 on invalid 'usage_key_string'
|
||||
return HttpResponseBadRequest()
|
||||
with modulestore().bulk_operations(usage_key.course_key):
|
||||
try:
|
||||
course, xblock, lms_link, preview_lms_link = _get_item_in_course(request, usage_key)
|
||||
except ItemNotFoundError:
|
||||
raise Http404 # lint-amnesty, pylint: disable=raise-missing-from
|
||||
|
||||
container_handler_context = get_container_handler_context(request, usage_key, course, xblock)
|
||||
return render_to_response('container_chromeless.html', container_handler_context)
|
||||
|
||||
|
||||
def get_component_templates(courselike, library=False): # lint-amnesty, pylint: disable=too-many-statements
|
||||
"""
|
||||
Returns the applicable component templates that can be used by the specified course or library.
|
||||
@@ -215,7 +268,9 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint:
|
||||
'problem': _("Problem"),
|
||||
'video': _("Video"),
|
||||
'openassessment': _("Open Response"),
|
||||
'library': _("Library Content"),
|
||||
'library': _("Legacy Library"),
|
||||
'library_v2': _("Library Content"),
|
||||
'itembank': _("Problem Bank"),
|
||||
'drag-and-drop-v2': _("Drag and Drop"),
|
||||
}
|
||||
|
||||
@@ -226,7 +281,14 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint:
|
||||
component_types = COMPONENT_TYPES[:]
|
||||
|
||||
# Libraries do not support discussions, drag-and-drop, and openassessment and other libraries
|
||||
component_not_supported_by_library = ['discussion', 'library', 'openassessment', 'drag-and-drop-v2']
|
||||
component_not_supported_by_library = [
|
||||
'discussion',
|
||||
'library',
|
||||
'openassessment',
|
||||
'drag-and-drop-v2',
|
||||
'library_v2',
|
||||
'itembank',
|
||||
]
|
||||
if library:
|
||||
component_types = [component for component in component_types
|
||||
if component not in set(component_not_supported_by_library)]
|
||||
@@ -245,7 +307,7 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint:
|
||||
templates_for_category = []
|
||||
component_class = _load_mixed_class(category)
|
||||
|
||||
if support_level_without_template and category != 'library':
|
||||
if support_level_without_template and category not in ['library']:
|
||||
# add the default template with localized display name
|
||||
# TODO: Once mixins are defined per-application, rather than per-runtime,
|
||||
# this should use a cms mixed-in class. (cpennington)
|
||||
@@ -366,7 +428,8 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint:
|
||||
"type": category,
|
||||
"templates": templates_for_category,
|
||||
"display_name": component_display_names[category],
|
||||
"support_legend": create_support_legend_dict()
|
||||
"support_legend": create_support_legend_dict(),
|
||||
"beta": category in BETA_COMPONENT_TYPES,
|
||||
})
|
||||
|
||||
# Libraries do not support advanced components at this time.
|
||||
@@ -416,7 +479,7 @@ def get_component_templates(courselike, library=False): # lint-amnesty, pylint:
|
||||
course_advanced_keys
|
||||
)
|
||||
if advanced_component_templates['templates']:
|
||||
component_templates.insert(0, advanced_component_templates)
|
||||
component_templates.append(advanced_component_templates)
|
||||
|
||||
return component_templates
|
||||
|
||||
@@ -440,6 +503,11 @@ def _filter_disabled_blocks(all_blocks):
|
||||
Filter out disabled xblocks from the provided list of xblock names.
|
||||
"""
|
||||
disabled_block_names = [block.name for block in disabled_xblocks()]
|
||||
if not libraries_v1_enabled():
|
||||
disabled_block_names.append('library')
|
||||
if not libraries_v2_enabled():
|
||||
disabled_block_names.append('library_v2')
|
||||
disabled_block_names.append('itembank')
|
||||
return [block_name for block_name in all_blocks if block_name not in disabled_block_names]
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ from django.conf import settings
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.core.exceptions import FieldError, PermissionDenied, ValidationError as DjangoValidationError
|
||||
from django.db.models import QuerySet
|
||||
from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseNotFound
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse
|
||||
@@ -575,6 +576,10 @@ def _accessible_courses_list_from_groups(request):
|
||||
|
||||
if course_keys:
|
||||
courses_list = CourseOverview.get_all_courses(filter_={'id__in': course_keys})
|
||||
else:
|
||||
# If no course keys are found for the current user, then return without filtering
|
||||
# or ordering the courses list.
|
||||
return courses_list, []
|
||||
|
||||
search_query, order, active_only, archived_only = get_query_params_if_present(request)
|
||||
courses_list = get_filtered_and_ordered_courses(
|
||||
@@ -588,7 +593,11 @@ def _accessible_courses_list_from_groups(request):
|
||||
return courses_list, []
|
||||
|
||||
|
||||
def get_courses_by_status(active_only, archived_only, course_overviews):
|
||||
def get_courses_by_status(
|
||||
active_only: bool,
|
||||
archived_only: bool,
|
||||
course_overviews: QuerySet[CourseOverview]
|
||||
) -> QuerySet[CourseOverview]:
|
||||
"""
|
||||
Return course overviews based on a base queryset filtered by a status.
|
||||
|
||||
@@ -602,7 +611,10 @@ def get_courses_by_status(active_only, archived_only, course_overviews):
|
||||
return CourseOverview.get_courses_by_status(active_only, archived_only, course_overviews)
|
||||
|
||||
|
||||
def get_courses_by_search_query(search_query, course_overviews):
|
||||
def get_courses_by_search_query(
|
||||
search_query: str | None,
|
||||
course_overviews: QuerySet[CourseOverview]
|
||||
) -> QuerySet[CourseOverview]:
|
||||
"""Return course overviews based on a base queryset filtered by a search query.
|
||||
|
||||
Args:
|
||||
@@ -614,7 +626,10 @@ def get_courses_by_search_query(search_query, course_overviews):
|
||||
return CourseOverview.get_courses_matching_query(search_query, course_overviews=course_overviews)
|
||||
|
||||
|
||||
def get_courses_order_by(order_query, course_overviews):
|
||||
def get_courses_order_by(
|
||||
order_query: str | None,
|
||||
course_overviews: QuerySet[CourseOverview]
|
||||
) -> QuerySet[CourseOverview]:
|
||||
"""Return course overviews based on a base queryset ordered by a query.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -42,6 +42,7 @@ from common.djangoapps.student.roles import (
|
||||
from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json
|
||||
|
||||
from ..utils import add_instructor, reverse_library_url
|
||||
from ..toggles import libraries_v1_enabled
|
||||
from .component import CONTAINER_TEMPLATES, get_component_templates
|
||||
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import create_xblock_info
|
||||
from .user import user_with_role
|
||||
@@ -50,13 +51,11 @@ __all__ = ['library_handler', 'manage_library_users']
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
LIBRARIES_ENABLED = settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES', False)
|
||||
|
||||
|
||||
def _user_can_create_library_for_org(user, org=None):
|
||||
"""
|
||||
Helper method for returning the library creation status for a particular user,
|
||||
taking into account the value LIBRARIES_ENABLED.
|
||||
taking into account the libraries_v1_enabled toggle.
|
||||
|
||||
if the ENABLE_CREATOR_GROUP value is False, then any user can create a library (in any org),
|
||||
if library creation is enabled.
|
||||
@@ -69,7 +68,7 @@ def _user_can_create_library_for_org(user, org=None):
|
||||
Course Staff: Can make libraries in the organization which has courses of which they are staff.
|
||||
Course Admin: Can make libraries in the organization which has courses of which they are Admin.
|
||||
"""
|
||||
if not LIBRARIES_ENABLED:
|
||||
if not libraries_v1_enabled():
|
||||
return False
|
||||
elif user.is_staff:
|
||||
return True
|
||||
@@ -125,7 +124,7 @@ def library_handler(request, library_key_string=None):
|
||||
"""
|
||||
RESTful interface to most content library related functionality.
|
||||
"""
|
||||
if not LIBRARIES_ENABLED:
|
||||
if not libraries_v1_enabled():
|
||||
log.exception("Attempted to use the content library API when the libraries feature is disabled.")
|
||||
raise Http404 # Should never happen because we test the feature in urls.py also
|
||||
|
||||
|
||||
@@ -300,8 +300,9 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
|
||||
selected_groups_label = _('Access restricted to: {list_of_groups}').format(list_of_groups=selected_groups_label) # lint-amnesty, pylint: disable=line-too-long
|
||||
course = modulestore().get_course(xblock.location.course_key)
|
||||
can_edit = context.get('can_edit', True)
|
||||
can_add = context.get('can_add', True)
|
||||
# Is this a course or a library?
|
||||
is_course = xblock.scope_ids.usage_id.context_key.is_course
|
||||
is_course = xblock.context_key.is_course
|
||||
tags_count_map = context.get('tags_count_map')
|
||||
tags_count = 0
|
||||
if tags_count_map:
|
||||
@@ -320,7 +321,10 @@ def _studio_wrap_xblock(xblock, view, frag, context, display_name_only=False):
|
||||
'is_selected': context.get('is_selected', False),
|
||||
'selectable': context.get('selectable', False),
|
||||
'selected_groups_label': selected_groups_label,
|
||||
'can_add': context.get('can_add', True),
|
||||
'can_add': can_add,
|
||||
# Generally speaking, "if you can add, you can delete". One exception is itembank (Problem Bank)
|
||||
# which has its own separate "add" workflow but uses the normal delete workflow for its child blocks.
|
||||
'can_delete': can_add or (root_xblock and root_xblock.scope_ids.block_type == "itembank" and can_edit),
|
||||
'can_move': context.get('can_move', is_course),
|
||||
'language': getattr(course, 'language', None),
|
||||
'is_course': is_course,
|
||||
|
||||
@@ -312,12 +312,12 @@ class GetItemTest(ItemTest):
|
||||
resp = self.create_xblock(
|
||||
parent_usage_key=split_test_usage_key,
|
||||
category="html",
|
||||
boilerplate="zooming_image.yaml",
|
||||
boilerplate="latex_html.yaml",
|
||||
)
|
||||
self.assertEqual(resp.status_code, 200)
|
||||
html, __ = self._get_container_preview(split_test_usage_key)
|
||||
self.assertIn("Announcement", html)
|
||||
self.assertIn("Zooming", html)
|
||||
self.assertIn("LaTeX", html)
|
||||
|
||||
def test_split_test_edited(self):
|
||||
"""
|
||||
@@ -3184,13 +3184,13 @@ class TestComponentTemplates(CourseTestCase):
|
||||
templates = get_component_templates(self.course)
|
||||
button_names = [template["display_name"] for template in templates]
|
||||
self.assertIn("Advanced", button_names)
|
||||
self.assertEqual(len(templates[0]["templates"]), len(expected_xblocks))
|
||||
self.assertEqual(len(templates[-1]["templates"]), len(expected_xblocks))
|
||||
template_display_names = [
|
||||
template["display_name"] for template in templates[0]["templates"]
|
||||
template["display_name"] for template in templates[-1]["templates"]
|
||||
]
|
||||
self.assertEqual(template_display_names, expected_xblocks)
|
||||
template_support_levels = [
|
||||
template["support_level"] for template in templates[0]["templates"]
|
||||
template["support_level"] for template in templates[-1]["templates"]
|
||||
]
|
||||
self.assertEqual(template_support_levels, expected_support_levels)
|
||||
|
||||
|
||||
@@ -7,11 +7,13 @@ import ddt
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from rest_framework.test import APIClient
|
||||
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.factories import BlockFactory, CourseFactory, ToyCourseFactory, LibraryFactory
|
||||
|
||||
from cms.djangoapps.contentstore.utils import reverse_usage_url
|
||||
from openedx.core.djangoapps.content_libraries import api as library_api
|
||||
from openedx.core.djangoapps.content_tagging import api as tagging_api
|
||||
|
||||
CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/"
|
||||
@@ -182,7 +184,7 @@ class ClipboardPasteTestCase(ModuleStoreTestCase):
|
||||
|
||||
tagging_api.set_taxonomy_orgs(taxonomy_all_org, all_orgs=True)
|
||||
for tag_value in ('tag_1', 'tag_2', 'tag_3', 'tag_4', 'tag_5', 'tag_6', 'tag_7'):
|
||||
Tag.objects.create(taxonomy=taxonomy_all_org, value=tag_value)
|
||||
tagging_api.add_tag_to_taxonomy(taxonomy_all_org, tag_value)
|
||||
tagging_api.tag_object(
|
||||
object_id=str(unit_key),
|
||||
taxonomy=taxonomy_all_org,
|
||||
@@ -391,6 +393,177 @@ class ClipboardPasteTestCase(ModuleStoreTestCase):
|
||||
assert source_pic2_hash != dest_pic2_hash # Because there was a conflict, this file was unchanged.
|
||||
|
||||
|
||||
class ClipboardPasteFromV2LibraryTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
Test Clipboard Paste functionality with a "new" (as of Sumac) library
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Set up a v2 Content Library and a library content block
|
||||
"""
|
||||
super().setUp()
|
||||
self.client = APIClient()
|
||||
self.client.login(username=self.user.username, password=self.user_password)
|
||||
self.store = modulestore()
|
||||
|
||||
self.library = library_api.create_library(
|
||||
org=Organization.objects.create(name="Test Org", short_name="CL-TEST"),
|
||||
slug="lib",
|
||||
title="Library",
|
||||
)
|
||||
|
||||
self.lib_block_key = library_api.create_library_block(self.library.key, "problem", "p1").usage_key # v==1
|
||||
library_api.set_library_block_olx(self.lib_block_key, """
|
||||
<problem display_name="MCQ-published" max_attempts="1">
|
||||
<multiplechoiceresponse>
|
||||
<label>Q</label>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">Wrong</choice>
|
||||
<choice correct="true">Right</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
""") # v==2
|
||||
library_api.publish_changes(self.library.key)
|
||||
library_api.set_library_block_olx(self.lib_block_key, """
|
||||
<problem display_name="MCQ-draft" max_attempts="5">
|
||||
<multiplechoiceresponse>
|
||||
<label>Q</label>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">Wrong</choice>
|
||||
<choice correct="true">Right</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
""") # v==3
|
||||
lib_block_meta = library_api.get_library_block(self.lib_block_key)
|
||||
assert lib_block_meta.published_version_num == 2
|
||||
assert lib_block_meta.draft_version_num == 3
|
||||
|
||||
self.course = CourseFactory.create(display_name='Course')
|
||||
|
||||
taxonomy_all_org = tagging_api.create_taxonomy(
|
||||
"test_taxonomy",
|
||||
"Test Taxonomy",
|
||||
export_id="ALL_ORGS",
|
||||
)
|
||||
tagging_api.set_taxonomy_orgs(taxonomy_all_org, all_orgs=True)
|
||||
for tag_value in ('tag_1', 'tag_2', 'tag_3', 'tag_4', 'tag_5', 'tag_6', 'tag_7'):
|
||||
tagging_api.add_tag_to_taxonomy(taxonomy_all_org, tag_value)
|
||||
|
||||
self.lib_block_tags = ['tag_1', 'tag_5']
|
||||
tagging_api.tag_object(str(self.lib_block_key), taxonomy_all_org, self.lib_block_tags)
|
||||
|
||||
def test_paste_from_library_read_only_tags(self):
|
||||
"""
|
||||
When we copy a v2 lib block into a course, the dest block should have read-only copied tags.
|
||||
"""
|
||||
|
||||
copy_response = self.client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(self.lib_block_key)}, format="json")
|
||||
assert copy_response.status_code == 200
|
||||
|
||||
paste_response = self.client.post(XBLOCK_ENDPOINT, {
|
||||
"parent_locator": str(self.course.usage_key),
|
||||
"staged_content": "clipboard",
|
||||
}, format="json")
|
||||
assert paste_response.status_code == 200
|
||||
|
||||
new_block_key = paste_response.json()["locator"]
|
||||
|
||||
object_tags = tagging_api.get_object_tags(new_block_key)
|
||||
assert len(object_tags) == len(self.lib_block_tags)
|
||||
for object_tag in object_tags:
|
||||
assert object_tag.value in self.lib_block_tags
|
||||
assert object_tag.is_copied
|
||||
|
||||
def test_paste_from_library_copies_asset(self):
|
||||
"""
|
||||
Assets from a library component copied into a subdir of Files & Uploads.
|
||||
"""
|
||||
# This is the binary for a real, 1px webp file – we need actual image
|
||||
# data because contentstore will try to make a thumbnail and grab
|
||||
# metadata.
|
||||
webp_raw_data = b'RIFF\x16\x00\x00\x00WEBPVP8L\n\x00\x00\x00/\x00\x00\x00\x00E\xff#\xfa\x1f'
|
||||
|
||||
# First add the asset.
|
||||
library_api.add_library_block_static_asset_file(
|
||||
self.lib_block_key,
|
||||
"static/1px.webp",
|
||||
webp_raw_data,
|
||||
) # v==4
|
||||
|
||||
# Now add the reference to the asset
|
||||
library_api.set_library_block_olx(self.lib_block_key, """
|
||||
<problem display_name="MCQ-draft" max_attempts="5">
|
||||
<p>Including this totally real image: <img src="/static/1px.webp" /></p>
|
||||
<multiplechoiceresponse>
|
||||
<label>Q</label>
|
||||
<choicegroup type="MultipleChoice">
|
||||
<choice correct="false">Wrong</choice>
|
||||
<choice correct="true">Right</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
""") # v==5
|
||||
|
||||
copy_response = self.client.post(
|
||||
CLIPBOARD_ENDPOINT,
|
||||
{"usage_key": str(self.lib_block_key)},
|
||||
format="json"
|
||||
)
|
||||
assert copy_response.status_code == 200
|
||||
|
||||
paste_response = self.client.post(XBLOCK_ENDPOINT, {
|
||||
"parent_locator": str(self.course.usage_key),
|
||||
"staged_content": "clipboard",
|
||||
}, format="json")
|
||||
assert paste_response.status_code == 200
|
||||
|
||||
new_block_key = UsageKey.from_string(paste_response.json()["locator"])
|
||||
new_block = modulestore().get_item(new_block_key)
|
||||
|
||||
# Check that the substitution worked.
|
||||
expected_import_path = f"components/{new_block_key.block_type}/{new_block_key.block_id}/1px.webp"
|
||||
assert f"/static/{expected_import_path}" in new_block.data
|
||||
|
||||
# Check that the asset was copied over properly
|
||||
image_asset = contentstore().find(
|
||||
self.course.id.make_asset_key("asset", expected_import_path.replace('/', '_'))
|
||||
)
|
||||
assert image_asset.import_path == expected_import_path
|
||||
assert image_asset.name == "1px.webp"
|
||||
assert image_asset.length == len(webp_raw_data)
|
||||
|
||||
def test_paste_from_course_block_imported_from_library_creates_link(self):
|
||||
"""
|
||||
When we copy a course xblock which was imported or copied from v2 lib block into a course,
|
||||
the dest block should be linked up to the original lib block.
|
||||
"""
|
||||
def _copy_paste_and_assert_link(key_to_copy):
|
||||
copy_response = self.client.post(CLIPBOARD_ENDPOINT, {"usage_key": str(key_to_copy)}, format="json")
|
||||
assert copy_response.status_code == 200
|
||||
|
||||
paste_response = self.client.post(XBLOCK_ENDPOINT, {
|
||||
"parent_locator": str(self.course.usage_key),
|
||||
"staged_content": "clipboard",
|
||||
}, format="json")
|
||||
assert paste_response.status_code == 200
|
||||
|
||||
new_block_key = UsageKey.from_string(paste_response.json()["locator"])
|
||||
new_block = modulestore().get_item(new_block_key)
|
||||
assert new_block.upstream == str(self.lib_block_key)
|
||||
assert new_block.upstream_version == 3
|
||||
assert new_block.upstream_display_name == "MCQ-draft"
|
||||
assert new_block.upstream_max_attempts == 5
|
||||
return new_block_key
|
||||
|
||||
# first verify link for copied block from library
|
||||
new_block_key = _copy_paste_and_assert_link(self.lib_block_key)
|
||||
# next verify link for copied block from the pasted block
|
||||
_copy_paste_and_assert_link(new_block_key)
|
||||
|
||||
|
||||
class ClipboardPasteFromV1LibraryTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
Test Clipboard Paste functionality with legacy (v1) library content
|
||||
|
||||
@@ -242,3 +242,61 @@ class ContainerPageTestCase(StudioPageTestCase, LibraryTestCase):
|
||||
usage_key_string=str(self.vertical.location)
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
|
||||
class ContainerEmbedPageTestCase(ContainerPageTestCase): # lint-amnesty, pylint: disable=test-inherits-tests
|
||||
"""
|
||||
Unit tests for the container embed page.
|
||||
"""
|
||||
|
||||
def test_container_html(self):
|
||||
assets_url = reverse(
|
||||
'assets_handler', kwargs={'course_key_string': str(self.child_container.location.course_key)}
|
||||
)
|
||||
self._test_html_content(
|
||||
self.child_container,
|
||||
expected_section_tag=(
|
||||
'<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" '
|
||||
'data-locator="{0}" data-course-key="{0.course_key}" data-course-assets="{1}">'.format(
|
||||
self.child_container.location, assets_url
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
def test_container_on_container_html(self):
|
||||
"""
|
||||
Create the scenario of an xblock with children (non-vertical) on the container page.
|
||||
This should create a container page that is a child of another container page.
|
||||
"""
|
||||
draft_container = self._create_block(self.child_container, "wrapper", "Wrapper")
|
||||
self._create_block(draft_container, "html", "Child HTML")
|
||||
|
||||
def test_container_html(xblock):
|
||||
assets_url = reverse(
|
||||
'assets_handler', kwargs={'course_key_string': str(draft_container.location.course_key)}
|
||||
)
|
||||
self._test_html_content(
|
||||
xblock,
|
||||
expected_section_tag=(
|
||||
'<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" '
|
||||
'data-locator="{0}" data-course-key="{0.course_key}" data-course-assets="{1}">'.format(
|
||||
draft_container.location, assets_url
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
# Test the draft version of the container
|
||||
test_container_html(draft_container)
|
||||
|
||||
# Now publish the unit and validate again
|
||||
self.store.publish(self.vertical.location, self.user.id)
|
||||
draft_container = self.store.get_item(draft_container.location)
|
||||
test_container_html(draft_container)
|
||||
|
||||
def _test_html_content(self, xblock, expected_section_tag): # lint-amnesty, pylint: disable=arguments-differ
|
||||
"""
|
||||
Get the HTML for a container page and verify the section tag is correct
|
||||
and the breadcrumbs trail is correct.
|
||||
"""
|
||||
html = self.get_page_html(xblock)
|
||||
self.assertIn(expected_section_tag, html)
|
||||
|
||||
@@ -433,13 +433,13 @@ class TestCourseIndexArchived(CourseTestCase):
|
||||
@override_settings(FEATURES=FEATURES_WITHOUT_HOME_PAGE_COURSE_V2_API)
|
||||
@ddt.data(
|
||||
# Staff user has course staff access
|
||||
(True, 'staff', None, 0, 21),
|
||||
(False, 'staff', None, 0, 21),
|
||||
(True, 'staff', None, 0, 23),
|
||||
(False, 'staff', None, 0, 23),
|
||||
# Base user has global staff access
|
||||
(True, 'user', ORG, 2, 21),
|
||||
(False, 'user', ORG, 2, 21),
|
||||
(True, 'user', None, 2, 21),
|
||||
(False, 'user', None, 2, 21),
|
||||
(True, 'user', ORG, 2, 23),
|
||||
(False, 'user', ORG, 2, 23),
|
||||
(True, 'user', None, 2, 23),
|
||||
(False, 'user', None, 2, 23),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_separate_archived_courses(self, separate_archived_courses, username, org, mongo_queries, sql_queries):
|
||||
@@ -464,13 +464,13 @@ class TestCourseIndexArchived(CourseTestCase):
|
||||
@override_settings(FEATURES=FEATURES_WITH_HOME_PAGE_COURSE_V2_API)
|
||||
@ddt.data(
|
||||
# Staff user has course staff access
|
||||
(True, 'staff', None, 0, 21),
|
||||
(False, 'staff', None, 0, 21),
|
||||
(True, 'staff', None, 0, 23),
|
||||
(False, 'staff', None, 0, 23),
|
||||
# Base user has global staff access
|
||||
(True, 'user', ORG, 0, 21),
|
||||
(False, 'user', ORG, 0, 21),
|
||||
(True, 'user', None, 0, 21),
|
||||
(False, 'user', None, 0, 21),
|
||||
(True, 'user', ORG, 0, 23),
|
||||
(False, 'user', ORG, 0, 23),
|
||||
(True, 'user', None, 0, 23),
|
||||
(False, 'user', None, 0, 23),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_separate_archived_courses_with_home_page_course_v2_api(
|
||||
|
||||
@@ -56,44 +56,44 @@ class UnitTestLibraries(CourseTestCase):
|
||||
# Tests for /library/ - list and create libraries:
|
||||
|
||||
# When libraries are disabled, nobody can create libraries
|
||||
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", False)
|
||||
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", False)
|
||||
def test_library_creator_status_libraries_not_enabled(self):
|
||||
_, nostaff_user = self.create_non_staff_authed_user_client()
|
||||
self.assertEqual(user_can_create_library(nostaff_user, None), False)
|
||||
|
||||
# When creator group is disabled, non-staff users can create libraries
|
||||
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
|
||||
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
|
||||
def test_library_creator_status_with_no_course_creator_role(self):
|
||||
_, nostaff_user = self.create_non_staff_authed_user_client()
|
||||
self.assertEqual(user_can_create_library(nostaff_user, 'An Org'), True)
|
||||
|
||||
# When creator group is enabled, Non staff users cannot create libraries
|
||||
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
|
||||
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
|
||||
def test_library_creator_status_for_enabled_creator_group_setting_for_non_staff_users(self):
|
||||
_, nostaff_user = self.create_non_staff_authed_user_client()
|
||||
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
|
||||
self.assertEqual(user_can_create_library(nostaff_user, None), False)
|
||||
|
||||
# Global staff can create libraries for any org, even ones that don't exist.
|
||||
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
|
||||
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
|
||||
def test_library_creator_status_with_is_staff_user(self):
|
||||
print(self.user.is_staff)
|
||||
self.assertEqual(user_can_create_library(self.user, 'aNyOrg'), True)
|
||||
|
||||
# Global staff can create libraries for any org, but an org has to be supplied.
|
||||
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
|
||||
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
|
||||
def test_library_creator_status_with_is_staff_user_no_org(self):
|
||||
print(self.user.is_staff)
|
||||
self.assertEqual(user_can_create_library(self.user, None), False)
|
||||
|
||||
# When creator groups are enabled, global staff can create libraries in any org
|
||||
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
|
||||
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
|
||||
def test_library_creator_status_for_enabled_creator_group_setting_with_is_staff_user(self):
|
||||
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
|
||||
self.assertEqual(user_can_create_library(self.user, 'RandomOrg'), True)
|
||||
|
||||
# When creator groups are enabled, course creators can create libraries in any org.
|
||||
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
|
||||
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
|
||||
def test_library_creator_status_with_course_creator_role_for_enabled_creator_group_setting(self):
|
||||
_, nostaff_user = self.create_non_staff_authed_user_client()
|
||||
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
|
||||
@@ -102,7 +102,7 @@ class UnitTestLibraries(CourseTestCase):
|
||||
|
||||
# When creator groups are enabled, course staff members can create libraries
|
||||
# but only in the org they are course staff for.
|
||||
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
|
||||
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
|
||||
def test_library_creator_status_with_course_staff_role_for_enabled_creator_group_setting(self):
|
||||
_, nostaff_user = self.create_non_staff_authed_user_client()
|
||||
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
|
||||
@@ -112,7 +112,7 @@ class UnitTestLibraries(CourseTestCase):
|
||||
|
||||
# When creator groups are enabled, course instructor members can create libraries
|
||||
# but only in the org they are course staff for.
|
||||
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
|
||||
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
|
||||
def test_library_creator_status_with_course_instructor_role_for_enabled_creator_group_setting(self):
|
||||
_, nostaff_user = self.create_non_staff_authed_user_client()
|
||||
with mock.patch.dict('django.conf.settings.FEATURES', {"ENABLE_CREATOR_GROUP": True}):
|
||||
@@ -134,7 +134,7 @@ class UnitTestLibraries(CourseTestCase):
|
||||
Ensure that the setting DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION as expected.
|
||||
"""
|
||||
_, nostaff_user = self.create_non_staff_authed_user_client()
|
||||
with mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True):
|
||||
with mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True):
|
||||
with mock.patch.dict(
|
||||
"django.conf.settings.FEATURES",
|
||||
{
|
||||
@@ -145,7 +145,7 @@ class UnitTestLibraries(CourseTestCase):
|
||||
self.assertEqual(user_can_create_library(nostaff_user, 'SomEOrg'), expected_status)
|
||||
|
||||
@mock.patch.dict('django.conf.settings.FEATURES', {'DISABLE_COURSE_CREATION': True})
|
||||
@mock.patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", True)
|
||||
@mock.patch("cms.djangoapps.contentstore.toggles.libraries_v1_enabled", True)
|
||||
def test_library_creator_status_with_no_course_creator_role_and_disabled_nonstaff_course_creation(self):
|
||||
"""
|
||||
Ensure that `DISABLE_COURSE_CREATION` feature works with libraries as well.
|
||||
@@ -161,7 +161,7 @@ class UnitTestLibraries(CourseTestCase):
|
||||
self.assertEqual(get_response.status_code, 200)
|
||||
self.assertEqual(post_response.status_code, 403)
|
||||
|
||||
@patch("cms.djangoapps.contentstore.views.library.LIBRARIES_ENABLED", False)
|
||||
@mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_CONTENT_LIBRARIES': False})
|
||||
def test_with_libraries_disabled(self):
|
||||
"""
|
||||
The library URLs should return 404 if libraries are disabled.
|
||||
@@ -403,6 +403,8 @@ class UnitTestLibraries(CourseTestCase):
|
||||
self.assertNotIn('advanced', templates)
|
||||
self.assertNotIn('openassessment', templates)
|
||||
self.assertNotIn('library', templates)
|
||||
self.assertNotIn('library_v2', templates)
|
||||
self.assertNotIn('itembank', templates)
|
||||
|
||||
def test_advanced_problem_types(self):
|
||||
"""
|
||||
|
||||
@@ -36,6 +36,7 @@ from cms.djangoapps.contentstore.config.waffle import SHOW_REVIEW_RULES_FLAG
|
||||
from cms.djangoapps.contentstore.toggles import ENABLE_DEFAULT_ADVANCED_PROBLEM_EDITOR_FLAG
|
||||
from cms.djangoapps.models.settings.course_grading import CourseGradingModel
|
||||
from cms.lib.ai_aside_summary_config import AiAsideSummaryConfig
|
||||
from cms.lib.xblock.upstream_sync import BadUpstream, sync_from_upstream
|
||||
from common.djangoapps.static_replace import replace_static_urls
|
||||
from common.djangoapps.student.auth import (
|
||||
has_studio_read_access,
|
||||
@@ -539,7 +540,8 @@ def _create_block(request):
|
||||
# Paste from the user's clipboard (content_staging app clipboard, not browser clipboard) into 'usage_key':
|
||||
try:
|
||||
created_xblock, notices = import_staged_content_from_user_clipboard(
|
||||
parent_key=usage_key, request=request
|
||||
parent_key=usage_key,
|
||||
request=request,
|
||||
)
|
||||
except Exception: # pylint: disable=broad-except
|
||||
log.exception(
|
||||
@@ -556,6 +558,7 @@ def _create_block(request):
|
||||
"locator": str(created_xblock.location),
|
||||
"courseKey": str(created_xblock.location.course_key),
|
||||
"static_file_notices": asdict(notices),
|
||||
"upstreamRef": str(created_xblock.upstream),
|
||||
})
|
||||
|
||||
category = request.json["category"]
|
||||
@@ -585,12 +588,28 @@ def _create_block(request):
|
||||
boilerplate=request.json.get("boilerplate"),
|
||||
)
|
||||
|
||||
return JsonResponse(
|
||||
{
|
||||
"locator": str(created_block.location),
|
||||
"courseKey": str(created_block.location.course_key),
|
||||
}
|
||||
)
|
||||
response = {
|
||||
"locator": str(created_block.location),
|
||||
"courseKey": str(created_block.location.course_key),
|
||||
}
|
||||
# If it contains library_content_key, the block is being imported from a v2 library
|
||||
# so it needs to be synced with upstream block.
|
||||
if upstream_ref := request.json.get("library_content_key"):
|
||||
try:
|
||||
# Set `created_block.upstream` and then sync this with the upstream (library) version.
|
||||
created_block.upstream = upstream_ref
|
||||
sync_from_upstream(downstream=created_block, user=request.user)
|
||||
except BadUpstream as exc:
|
||||
_delete_item(created_block.location, request.user)
|
||||
log.exception(
|
||||
f"Could not sync to new block at '{created_block.usage_key}' "
|
||||
f"using provided library_content_key='{upstream_ref}'"
|
||||
)
|
||||
return JsonResponse({"error": str(exc)}, status=400)
|
||||
modulestore().update_item(created_block, request.user.id)
|
||||
response['upstreamRef'] = upstream_ref
|
||||
|
||||
return JsonResponse(response)
|
||||
|
||||
|
||||
def _get_source_index(source_usage_key, source_parent):
|
||||
|
||||
@@ -1,311 +0,0 @@
|
||||
"""
|
||||
Tests for the maintenance app views.
|
||||
"""
|
||||
|
||||
|
||||
import json
|
||||
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
|
||||
from cms.djangoapps.contentstore.management.commands.utils import get_course_versions
|
||||
from common.djangoapps.student.tests.factories import AdminFactory, UserFactory
|
||||
from openedx.features.announcements.models import Announcement
|
||||
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
from .views import COURSE_KEY_ERROR_MESSAGES, 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 TestForcePublish(MaintenanceViewTestCase):
|
||||
"""
|
||||
Tests for the force publish view.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.view_url = reverse('maintenance:force_publish_course')
|
||||
|
||||
def setup_test_course(self):
|
||||
"""
|
||||
Creates the course and add some changes to it.
|
||||
|
||||
Returns:
|
||||
course: a course object
|
||||
"""
|
||||
course = CourseFactory.create()
|
||||
# Add some changes to course
|
||||
chapter = BlockFactory.create(category='chapter', parent_location=course.location)
|
||||
self.store.create_child(
|
||||
self.user.id,
|
||||
chapter.location,
|
||||
'html',
|
||||
block_id='html_component'
|
||||
)
|
||||
# verify that course has changes.
|
||||
self.assertTrue(self.store.has_changes(self.store.get_item(course.location)))
|
||||
return course
|
||||
|
||||
@ddt.data(
|
||||
('', COURSE_KEY_ERROR_MESSAGES['empty_course_key']),
|
||||
('edx', COURSE_KEY_ERROR_MESSAGES['invalid_course_key']),
|
||||
('course-v1:e+d+X', COURSE_KEY_ERROR_MESSAGES['course_key_not_found']),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_invalid_course_key_messages(self, course_key, error_message):
|
||||
"""
|
||||
Test all error messages for invalid course keys.
|
||||
"""
|
||||
# validate that course key contains error message
|
||||
self.verify_error_message(
|
||||
data={'course-id': course_key},
|
||||
error_message=error_message
|
||||
)
|
||||
|
||||
def test_already_published(self):
|
||||
"""
|
||||
Test that when a course is forcefully publish, we get a 'course is already published' message.
|
||||
"""
|
||||
course = self.setup_test_course()
|
||||
|
||||
# publish the course
|
||||
source_store = modulestore()._get_modulestore_for_courselike(course.id) # pylint: disable=protected-access
|
||||
source_store.force_publish_course(course.id, self.user.id, commit=True)
|
||||
|
||||
# now course is published, we should get `already published course` error.
|
||||
self.verify_error_message(
|
||||
data={'course-id': str(course.id)},
|
||||
error_message='Course is already in published state.'
|
||||
)
|
||||
|
||||
def verify_versions_are_different(self, course):
|
||||
"""
|
||||
Verify draft and published versions point to different locations.
|
||||
|
||||
Arguments:
|
||||
course (object): a course object.
|
||||
"""
|
||||
# get draft and publish branch versions
|
||||
versions = get_course_versions(str(course.id))
|
||||
|
||||
# verify that draft and publish point to different versions
|
||||
self.assertNotEqual(versions['draft-branch'], versions['published-branch'])
|
||||
|
||||
def get_force_publish_course_response(self, course):
|
||||
"""
|
||||
Get force publish the course response.
|
||||
|
||||
Arguments:
|
||||
course (object): a course object.
|
||||
|
||||
Returns:
|
||||
response : response from force publish post view.
|
||||
"""
|
||||
# Verify versions point to different locations initially
|
||||
self.verify_versions_are_different(course)
|
||||
|
||||
# force publish course view
|
||||
data = {
|
||||
'course-id': str(course.id)
|
||||
}
|
||||
response = self.client.post(self.view_url, data=data, HTTP_X_REQUESTED_WITH='XMLHttpRequest')
|
||||
response_data = json.loads(response.content.decode('utf-8'))
|
||||
return response_data
|
||||
|
||||
def test_force_publish_dry_run(self):
|
||||
"""
|
||||
Test that dry run does not publishes the course but shows possible outcome if force published is executed.
|
||||
"""
|
||||
course = self.setup_test_course()
|
||||
response = self.get_force_publish_course_response(course)
|
||||
|
||||
self.assertIn('current_versions', response)
|
||||
|
||||
# verify that course still has changes as we just dry ran force publish course.
|
||||
self.assertTrue(self.store.has_changes(self.store.get_item(course.location)))
|
||||
|
||||
# verify that both branch versions are still different
|
||||
self.verify_versions_are_different(course)
|
||||
|
||||
|
||||
@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, '<div class="announcement-container">')
|
||||
|
||||
def test_create(self):
|
||||
"""
|
||||
Test create announcement view
|
||||
"""
|
||||
url = reverse("maintenance:announcement_create")
|
||||
self.client.post(url, {"content": "Test Create Announcement", "active": True})
|
||||
result = Announcement.objects.filter(content="Test Create Announcement").exists()
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_edit(self):
|
||||
"""
|
||||
Test edit announcement view
|
||||
"""
|
||||
announcement = Announcement.objects.create(content="test")
|
||||
announcement.save()
|
||||
url = reverse("maintenance:announcement_edit", kwargs={"pk": announcement.pk})
|
||||
response = self.client.get(url)
|
||||
self.assertContains(response, '<div class="wrapper-form announcement-container">')
|
||||
self.client.post(url, {"content": "Test Edit Announcement", "active": True})
|
||||
announcement = Announcement.objects.get(pk=announcement.pk)
|
||||
self.assertEqual(announcement.content, "Test Edit Announcement")
|
||||
|
||||
def test_delete(self):
|
||||
"""
|
||||
Test delete announcement view
|
||||
"""
|
||||
announcement = Announcement.objects.create(content="Test Delete")
|
||||
announcement.save()
|
||||
url = reverse("maintenance:announcement_delete", kwargs={"pk": announcement.pk})
|
||||
self.client.post(url)
|
||||
result = Announcement.objects.filter(content="Test Edit Announcement").exists()
|
||||
self.assertFalse(result)
|
||||
|
||||
def _test_403(self, viewname, kwargs=None):
|
||||
url = reverse("maintenance:%s" % viewname, kwargs=kwargs)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
def test_authorization(self):
|
||||
self.client.login(username=self.non_staff_user, password=self.TEST_PASSWORD)
|
||||
announcement = Announcement.objects.create(content="Test Delete")
|
||||
announcement.save()
|
||||
|
||||
self._test_403("announcement_index")
|
||||
self._test_403("announcement_create")
|
||||
self._test_403("announcement_edit", {"pk": announcement.pk})
|
||||
self._test_403("announcement_delete", {"pk": announcement.pk})
|
||||
@@ -1,25 +0,0 @@
|
||||
"""
|
||||
URLs for the maintenance app.
|
||||
"""
|
||||
|
||||
from django.urls import path, re_path
|
||||
|
||||
from .views import (
|
||||
AnnouncementCreateView,
|
||||
AnnouncementDeleteView,
|
||||
AnnouncementEditView,
|
||||
AnnouncementIndexView,
|
||||
ForcePublishCourseView,
|
||||
MaintenanceIndexView
|
||||
)
|
||||
|
||||
app_name = 'cms.djangoapps.maintenance'
|
||||
|
||||
urlpatterns = [
|
||||
path('', MaintenanceIndexView.as_view(), name='maintenance_index'),
|
||||
re_path(r'^force_publish_course/?$', ForcePublishCourseView.as_view(), name='force_publish_course'),
|
||||
re_path(r'^announcements/(?P<page>\d+)?$', AnnouncementIndexView.as_view(), name='announcement_index'),
|
||||
path('announcements/create', AnnouncementCreateView.as_view(), name='announcement_create'),
|
||||
re_path(r'^announcements/edit/(?P<pk>\d+)?$', AnnouncementEditView.as_view(), name='announcement_edit'),
|
||||
path('announcements/delete/<int:pk>', AnnouncementDeleteView.as_view(), name='announcement_delete'),
|
||||
]
|
||||
@@ -1,301 +0,0 @@
|
||||
"""
|
||||
Views for the maintenance app.
|
||||
"""
|
||||
|
||||
|
||||
import logging
|
||||
|
||||
from django.core.validators import ValidationError
|
||||
from django.db import transaction
|
||||
from django.urls import reverse, reverse_lazy
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import View
|
||||
from django.views.generic.edit import CreateView, DeleteView, UpdateView
|
||||
from django.views.generic.list import ListView
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
|
||||
from cms.djangoapps.contentstore.management.commands.utils import get_course_versions
|
||||
from common.djangoapps.edxmako.shortcuts import render_to_response
|
||||
from common.djangoapps.util.json_request import JsonResponse
|
||||
from common.djangoapps.util.views import require_global_staff
|
||||
from openedx.features.announcements.forms import AnnouncementForm
|
||||
from openedx.features.announcements.models import Announcement
|
||||
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
|
||||
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# This dict maintains all the views that will be used Maintenance app.
|
||||
MAINTENANCE_VIEWS = {
|
||||
'force_publish_course': {
|
||||
'url': 'maintenance:force_publish_course',
|
||||
'name': _('Force Publish Course'),
|
||||
'slug': 'force_publish_course',
|
||||
'description': _(
|
||||
'Sometimes the draft and published branches of a course can get out of sync. Force publish course command '
|
||||
'resets the published branch of a course to point to the draft branch, effectively force publishing the '
|
||||
'course. This view dry runs the force publish command'
|
||||
),
|
||||
},
|
||||
'announcement_index': {
|
||||
'url': 'maintenance:announcement_index',
|
||||
'name': _('Edit Announcements'),
|
||||
'slug': 'announcement_index',
|
||||
'description': _(
|
||||
'This view shows the announcement editor to create or alter announcements that are shown on the right'
|
||||
'side of the dashboard.'
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
COURSE_KEY_ERROR_MESSAGES = {
|
||||
'empty_course_key': _('Please provide course id.'),
|
||||
'invalid_course_key': _('Invalid course key.'),
|
||||
'course_key_not_found': _('No matching course found.')
|
||||
}
|
||||
|
||||
|
||||
class MaintenanceIndexView(View):
|
||||
"""
|
||||
Index view for maintenance dashboard, used by global staff.
|
||||
|
||||
This view lists some commands/tasks that can be used to dry run or execute directly.
|
||||
"""
|
||||
|
||||
@method_decorator(require_global_staff)
|
||||
def get(self, request):
|
||||
"""Render the maintenance index view. """
|
||||
return render_to_response('maintenance/index.html', {
|
||||
'views': MAINTENANCE_VIEWS,
|
||||
})
|
||||
|
||||
|
||||
class MaintenanceBaseView(View):
|
||||
"""
|
||||
Base class for Maintenance views.
|
||||
"""
|
||||
|
||||
template = 'maintenance/container.html'
|
||||
|
||||
def __init__(self, view=None):
|
||||
super().__init__()
|
||||
self.context = {
|
||||
'view': view if view else '',
|
||||
'form_data': {},
|
||||
'error': False,
|
||||
'msg': ''
|
||||
}
|
||||
|
||||
def render_response(self):
|
||||
"""
|
||||
A short method to render_to_response that renders response.
|
||||
"""
|
||||
if self.request.headers.get('x-requested-with') == 'XMLHttpRequest':
|
||||
return JsonResponse(self.context)
|
||||
return render_to_response(self.template, self.context)
|
||||
|
||||
@method_decorator(require_global_staff)
|
||||
def get(self, request):
|
||||
"""
|
||||
Render get view.
|
||||
"""
|
||||
return self.render_response()
|
||||
|
||||
def validate_course_key(self, course_key, branch=ModuleStoreEnum.BranchName.draft):
|
||||
"""
|
||||
Validates the course_key that would be used by maintenance app views.
|
||||
|
||||
Arguments:
|
||||
course_key (string): a course key
|
||||
branch: a course locator branch, default value is ModuleStoreEnum.BranchName.draft .
|
||||
values can be either ModuleStoreEnum.BranchName.draft or ModuleStoreEnum.BranchName.published.
|
||||
|
||||
Returns:
|
||||
course_usage_key (CourseLocator): course usage locator
|
||||
"""
|
||||
if not course_key:
|
||||
raise ValidationError(COURSE_KEY_ERROR_MESSAGES['empty_course_key'])
|
||||
|
||||
course_usage_key = CourseKey.from_string(course_key)
|
||||
|
||||
if not modulestore().has_course(course_usage_key):
|
||||
raise ItemNotFoundError(COURSE_KEY_ERROR_MESSAGES['course_key_not_found'])
|
||||
|
||||
# get branch specific locator
|
||||
course_usage_key = course_usage_key.for_branch(branch)
|
||||
|
||||
return course_usage_key
|
||||
|
||||
|
||||
class ForcePublishCourseView(MaintenanceBaseView):
|
||||
"""
|
||||
View for force publishing state of the course, used by the global staff.
|
||||
|
||||
This view uses `force_publish_course` method of modulestore which publishes the draft state of the course. After
|
||||
the course has been forced published, both draft and publish draft point to same location.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(MAINTENANCE_VIEWS['force_publish_course'])
|
||||
self.context.update({
|
||||
'current_versions': [],
|
||||
'updated_versions': [],
|
||||
'form_data': {
|
||||
'course_id': '',
|
||||
'is_dry_run': True
|
||||
}
|
||||
})
|
||||
|
||||
def get_course_branch_versions(self, versions):
|
||||
"""
|
||||
Returns a dict containing unicoded values of draft and published draft versions.
|
||||
"""
|
||||
return {
|
||||
'draft-branch': str(versions['draft-branch']),
|
||||
'published-branch': str(versions['published-branch'])
|
||||
}
|
||||
|
||||
@transaction.atomic
|
||||
@method_decorator(require_global_staff)
|
||||
def post(self, request):
|
||||
"""
|
||||
This method force publishes a course if dry-run argument is not selected. If dry-run is selected, this view
|
||||
shows possible outcome if the `force_publish_course` modulestore method is executed.
|
||||
|
||||
Arguments:
|
||||
course_id (string): a request parameter containing course id
|
||||
is_dry_run (string): a request parameter containing dry run value.
|
||||
It is obtained from checkbox so it has either values 'on' or ''.
|
||||
"""
|
||||
course_id = request.POST.get('course-id')
|
||||
|
||||
self.context.update({
|
||||
'form_data': {
|
||||
'course_id': course_id
|
||||
}
|
||||
})
|
||||
|
||||
try:
|
||||
course_usage_key = self.validate_course_key(course_id)
|
||||
except InvalidKeyError:
|
||||
self.context['error'] = True
|
||||
self.context['msg'] = COURSE_KEY_ERROR_MESSAGES['invalid_course_key']
|
||||
except ItemNotFoundError as exc:
|
||||
self.context['error'] = True
|
||||
self.context['msg'] = str(exc)
|
||||
except ValidationError as exc:
|
||||
self.context['error'] = True
|
||||
self.context['msg'] = str(exc)
|
||||
|
||||
if self.context['error']:
|
||||
return self.render_response()
|
||||
|
||||
source_store = modulestore()._get_modulestore_for_courselike(course_usage_key) # pylint: disable=protected-access
|
||||
if not hasattr(source_store, 'force_publish_course'):
|
||||
self.context['msg'] = _('Force publishing course is not supported with old mongo courses.')
|
||||
log.warning(
|
||||
'Force publishing course is not supported with old mongo courses. \
|
||||
%s attempted to force publish the course %s.',
|
||||
request.user,
|
||||
course_id,
|
||||
exc_info=True
|
||||
)
|
||||
return self.render_response()
|
||||
|
||||
current_versions = self.get_course_branch_versions(get_course_versions(course_id))
|
||||
|
||||
# if publish and draft are NOT different
|
||||
if current_versions['published-branch'] == current_versions['draft-branch']:
|
||||
self.context['msg'] = _('Course is already in published state.')
|
||||
log.warning(
|
||||
'Course is already in published state. %s attempted to force publish the course %s.',
|
||||
request.user,
|
||||
course_id,
|
||||
exc_info=True
|
||||
)
|
||||
return self.render_response()
|
||||
|
||||
self.context['current_versions'] = current_versions
|
||||
log.info(
|
||||
'%s dry ran force publish the course %s.',
|
||||
request.user,
|
||||
course_id,
|
||||
exc_info=True
|
||||
)
|
||||
return self.render_response()
|
||||
|
||||
|
||||
class AnnouncementBaseView(View):
|
||||
"""
|
||||
Base view for Announcements pages
|
||||
"""
|
||||
|
||||
@method_decorator(require_global_staff)
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
return super().dispatch(request, *args, **kwargs)
|
||||
|
||||
|
||||
class AnnouncementIndexView(ListView, MaintenanceBaseView):
|
||||
"""
|
||||
View for viewing the announcements shown on the dashboard, used by the global staff.
|
||||
"""
|
||||
model = Announcement
|
||||
object_list = Announcement.objects.order_by('-active')
|
||||
context_object_name = 'announcement_list'
|
||||
paginate_by = 8
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(MAINTENANCE_VIEWS['announcement_index'])
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['view'] = MAINTENANCE_VIEWS['announcement_index']
|
||||
return context
|
||||
|
||||
@method_decorator(require_global_staff)
|
||||
def get(self, request, *args, **kwargs):
|
||||
context = self.get_context_data()
|
||||
return render_to_response(self.template, context)
|
||||
|
||||
|
||||
class AnnouncementEditView(UpdateView, AnnouncementBaseView):
|
||||
"""
|
||||
View for editing an announcement.
|
||||
"""
|
||||
model = Announcement
|
||||
form_class = AnnouncementForm
|
||||
success_url = reverse_lazy('maintenance:announcement_index')
|
||||
template_name = '/maintenance/_announcement_edit.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['action_url'] = reverse('maintenance:announcement_edit', kwargs={'pk': context['announcement'].pk})
|
||||
return context
|
||||
|
||||
|
||||
class AnnouncementCreateView(CreateView, AnnouncementBaseView):
|
||||
"""
|
||||
View for creating an announcement.
|
||||
"""
|
||||
model = Announcement
|
||||
form_class = AnnouncementForm
|
||||
success_url = reverse_lazy('maintenance:announcement_index')
|
||||
template_name = '/maintenance/_announcement_edit.html'
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
context = super().get_context_data(**kwargs)
|
||||
context['action_url'] = reverse('maintenance:announcement_create')
|
||||
return context
|
||||
|
||||
|
||||
class AnnouncementDeleteView(DeleteView, AnnouncementBaseView):
|
||||
"""
|
||||
View for deleting an announcement.
|
||||
"""
|
||||
model = Announcement
|
||||
success_url = reverse_lazy('maintenance:announcement_index')
|
||||
template_name = '/maintenance/_announcement_delete.html'
|
||||
@@ -122,11 +122,22 @@ from lms.envs.common import (
|
||||
# Password Validator Settings
|
||||
AUTH_PASSWORD_VALIDATORS
|
||||
)
|
||||
from lms.envs.common import (
|
||||
USE_EXTRACTED_WORD_CLOUD_BLOCK,
|
||||
USE_EXTRACTED_ANNOTATABLE_BLOCK,
|
||||
USE_EXTRACTED_POLL_QUESTION_BLOCK,
|
||||
USE_EXTRACTED_LTI_BLOCK,
|
||||
USE_EXTRACTED_HTML_BLOCK,
|
||||
USE_EXTRACTED_DISCUSSION_BLOCK,
|
||||
USE_EXTRACTED_PROBLEM_BLOCK,
|
||||
USE_EXTRACTED_VIDEO_BLOCK,
|
||||
)
|
||||
from path import Path as path
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
from lms.djangoapps.lms_xblock.mixin import LmsBlockMixin
|
||||
from cms.lib.xblock.authoring_mixin import AuthoringMixin
|
||||
from cms.lib.xblock.upstream_sync import UpstreamSyncMixin
|
||||
from xmodule.modulestore.edit_info import EditInfoMixin
|
||||
from openedx.core.djangoapps.theming.helpers_dirs import (
|
||||
get_themes_unchecked,
|
||||
@@ -565,6 +576,14 @@ FEATURES = {
|
||||
# .. toggle_use_cases: open_edx
|
||||
# .. toggle_creation_date: 2024-04-10
|
||||
'BADGES_ENABLED': False,
|
||||
|
||||
# .. toggle_name: FEATURES['IN_CONTEXT_DISCUSSION_ENABLED_DEFAULT']
|
||||
# .. toggle_implementation: DjangoSetting
|
||||
# .. toggle_default: True
|
||||
# .. toggle_description: Set to False to disable in-context discussion for units by default.
|
||||
# .. toggle_use_cases: open_edx
|
||||
# .. toggle_creation_date: 2024-09-02
|
||||
'IN_CONTEXT_DISCUSSION_ENABLED_DEFAULT': True,
|
||||
}
|
||||
|
||||
# .. toggle_name: ENABLE_COPPA_COMPLIANCE
|
||||
@@ -663,6 +682,7 @@ CMS_ROOT = REPO_ROOT / "cms"
|
||||
LMS_ROOT = REPO_ROOT / "lms"
|
||||
ENV_ROOT = REPO_ROOT.dirname() # virtualenv dir /edx-platform is in
|
||||
COURSES_ROOT = ENV_ROOT / "data"
|
||||
XMODULE_ROOT = REPO_ROOT / "xmodule"
|
||||
|
||||
GITHUB_REPO_ROOT = ENV_ROOT / "data"
|
||||
|
||||
@@ -995,6 +1015,7 @@ XBLOCK_MIXINS = (
|
||||
XModuleMixin,
|
||||
EditInfoMixin,
|
||||
AuthoringMixin,
|
||||
UpstreamSyncMixin,
|
||||
)
|
||||
|
||||
# .. setting_name: XBLOCK_EXTRA_MIXINS
|
||||
@@ -1268,6 +1289,10 @@ STATIC_ROOT = os.environ.get('STATIC_ROOT_CMS', ENV_ROOT / 'staticfiles' / 'stud
|
||||
STATICFILES_DIRS = [
|
||||
COMMON_ROOT / "static",
|
||||
PROJECT_ROOT / "static",
|
||||
# Temporarily adding the following static path as we are migrating the built-in blocks' Sass to vanilla CSS.
|
||||
# Once all of the built-in blocks are extracted from edx-platform, we can remove this static path.
|
||||
# Relevant ticket: https://github.com/openedx/edx-platform/issues/35300
|
||||
XMODULE_ROOT / "static",
|
||||
]
|
||||
|
||||
# Locale/Internationalization
|
||||
@@ -1405,6 +1430,12 @@ PIPELINE['STYLESHEETS'] = {
|
||||
],
|
||||
'output_filename': 'css/cms-style-xmodule-annotations.css',
|
||||
},
|
||||
'course-unit-mfe-iframe-bundle': {
|
||||
'source_filenames': [
|
||||
'css/course-unit-mfe-iframe-bundle.css',
|
||||
],
|
||||
'output_filename': 'css/course-unit-mfe-iframe-bundle.css',
|
||||
},
|
||||
}
|
||||
|
||||
base_vendor_js = [
|
||||
@@ -1640,6 +1671,9 @@ INSTALLED_APPS = [
|
||||
'corsheaders',
|
||||
'openedx.core.djangoapps.cors_csrf',
|
||||
|
||||
# Provides the 'django_markup' template library so we can use 'interpolate_html' in django templates
|
||||
'xss_utils',
|
||||
|
||||
# History tables
|
||||
'simple_history',
|
||||
|
||||
@@ -1671,8 +1705,6 @@ INSTALLED_APPS = [
|
||||
# New (Learning-Core-based) XBlock runtime
|
||||
'openedx.core.djangoapps.xblock.apps.StudioXBlockAppConfig',
|
||||
|
||||
# Maintenance tools
|
||||
'cms.djangoapps.maintenance',
|
||||
'openedx.core.djangoapps.util.apps.UtilConfig',
|
||||
|
||||
# Tracking
|
||||
@@ -2710,7 +2742,7 @@ PASSWORD_RESET_IP_RATE = '1/m'
|
||||
PASSWORD_RESET_EMAIL_RATE = '2/h'
|
||||
|
||||
######################## Setting for content libraries ########################
|
||||
MAX_BLOCKS_PER_CONTENT_LIBRARY = 1000
|
||||
MAX_BLOCKS_PER_CONTENT_LIBRARY = 100_000
|
||||
|
||||
################# Student Verification #################
|
||||
VERIFY_STUDENT = {
|
||||
|
||||
425
cms/lib/xblock/test/test_upstream_sync.py
Normal file
425
cms/lib/xblock/test/test_upstream_sync.py
Normal file
@@ -0,0 +1,425 @@
|
||||
"""
|
||||
Test CMS's upstream->downstream syncing system
|
||||
"""
|
||||
import ddt
|
||||
|
||||
from organizations.api import ensure_organization
|
||||
from organizations.models import Organization
|
||||
|
||||
from cms.lib.xblock.upstream_sync import (
|
||||
UpstreamLink,
|
||||
sync_from_upstream, decline_sync, fetch_customizable_fields, sever_upstream_link,
|
||||
NoUpstream, BadUpstream, BadDownstream,
|
||||
)
|
||||
from common.djangoapps.student.tests.factories import UserFactory
|
||||
from openedx.core.djangoapps.content_libraries import api as libs
|
||||
from openedx.core.djangoapps.content_tagging import api as tagging_api
|
||||
from openedx.core.djangoapps.xblock import api as xblock
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class UpstreamTestCase(ModuleStoreTestCase):
|
||||
"""
|
||||
Tests the upstream_sync mixin, data object, and Python APIs.
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Create a simple course with one unit, and simple V2 library with two blocks.
|
||||
"""
|
||||
super().setUp()
|
||||
course = CourseFactory.create()
|
||||
chapter = BlockFactory.create(category='chapter', parent=course)
|
||||
sequential = BlockFactory.create(category='sequential', parent=chapter)
|
||||
self.unit = BlockFactory.create(category='vertical', parent=sequential)
|
||||
|
||||
ensure_organization("TestX")
|
||||
self.library = libs.create_library(
|
||||
org=Organization.objects.get(short_name="TestX"),
|
||||
slug="TestLib",
|
||||
title="Test Upstream Library",
|
||||
)
|
||||
self.upstream_key = libs.create_library_block(self.library.key, "html", "test-upstream").usage_key
|
||||
libs.create_library_block(self.library.key, "video", "video-upstream")
|
||||
|
||||
upstream = xblock.load_block(self.upstream_key, self.user)
|
||||
upstream.display_name = "Upstream Title V2"
|
||||
upstream.data = "<html><body>Upstream content V2</body></html>"
|
||||
upstream.save()
|
||||
|
||||
libs.publish_changes(self.library.key, self.user.id)
|
||||
|
||||
self.taxonomy_all_org = tagging_api.create_taxonomy(
|
||||
"test_taxonomy",
|
||||
"Test Taxonomy",
|
||||
export_id="ALL_ORGS",
|
||||
)
|
||||
tagging_api.set_taxonomy_orgs(self.taxonomy_all_org, all_orgs=True)
|
||||
for tag_value in ('tag_1', 'tag_2', 'tag_3', 'tag_4', 'tag_5', 'tag_6', 'tag_7'):
|
||||
tagging_api.add_tag_to_taxonomy(self.taxonomy_all_org, tag_value)
|
||||
|
||||
self.upstream_tags = ['tag_1', 'tag_5']
|
||||
tagging_api.tag_object(str(self.upstream_key), self.taxonomy_all_org, self.upstream_tags)
|
||||
|
||||
def test_sync_bad_downstream(self):
|
||||
"""
|
||||
Syncing into an unsupported downstream (such as a another Content Library block) raises BadDownstream, but
|
||||
doesn't affect the block.
|
||||
"""
|
||||
downstream_lib_block_key = libs.create_library_block(self.library.key, "html", "bad-downstream").usage_key
|
||||
downstream_lib_block = xblock.load_block(downstream_lib_block_key, self.user)
|
||||
downstream_lib_block.display_name = "Another lib block"
|
||||
downstream_lib_block.data = "<html>another lib block</html>"
|
||||
downstream_lib_block.upstream = str(self.upstream_key)
|
||||
downstream_lib_block.save()
|
||||
|
||||
with self.assertRaises(BadDownstream):
|
||||
sync_from_upstream(downstream_lib_block, self.user)
|
||||
|
||||
assert downstream_lib_block.display_name == "Another lib block"
|
||||
assert downstream_lib_block.data == "<html>another lib block</html>"
|
||||
|
||||
def test_sync_no_upstream(self):
|
||||
"""
|
||||
Trivial case: Syncing a block with no upstream is a no-op
|
||||
"""
|
||||
block = BlockFactory.create(category='html', parent=self.unit)
|
||||
block.display_name = "Block Title"
|
||||
block.data = "Block content"
|
||||
|
||||
with self.assertRaises(NoUpstream):
|
||||
sync_from_upstream(block, self.user)
|
||||
|
||||
assert block.display_name == "Block Title"
|
||||
assert block.data == "Block content"
|
||||
assert not block.upstream_display_name
|
||||
|
||||
@ddt.data(
|
||||
("not-a-key-at-all", ".*is malformed.*"),
|
||||
("course-v1:Oops+ItsA+CourseKey", ".*is malformed.*"),
|
||||
("block-v1:The+Wrong+KindOfUsageKey+type@html+block@nope", ".*is malformed.*"),
|
||||
("lb:TestX:NoSuchLib:html:block-id", ".*not found in the system.*"),
|
||||
("lb:TestX:TestLib:video:should-be-html-but-is-a-video", ".*type mismatch.*"),
|
||||
("lb:TestX:TestLib:html:no-such-html", ".*not found in the system.*"),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_sync_bad_upstream(self, upstream, message_regex):
|
||||
"""
|
||||
Syncing with a bad upstream raises BadUpstream, but doesn't affect the block
|
||||
"""
|
||||
block = BlockFactory.create(category='html', parent=self.unit, upstream=upstream)
|
||||
block.display_name = "Block Title"
|
||||
block.data = "Block content"
|
||||
|
||||
with self.assertRaisesRegex(BadUpstream, message_regex):
|
||||
sync_from_upstream(block, self.user)
|
||||
|
||||
assert block.display_name == "Block Title"
|
||||
assert block.data == "Block content"
|
||||
assert not block.upstream_display_name
|
||||
|
||||
def test_sync_not_accessible(self):
|
||||
"""
|
||||
Syncing with an block that exists, but is inaccessible, raises BadUpstream
|
||||
"""
|
||||
downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
|
||||
user_who_cannot_read_upstream = UserFactory.create(username="rando", is_staff=False, is_superuser=False)
|
||||
with self.assertRaisesRegex(BadUpstream, ".*could not be loaded.*") as exc:
|
||||
sync_from_upstream(downstream, user_who_cannot_read_upstream)
|
||||
|
||||
def test_sync_updates_happy_path(self):
|
||||
"""
|
||||
Can we sync updates from a content library block to a linked out-of-date course block?
|
||||
"""
|
||||
downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
|
||||
|
||||
# Initial sync
|
||||
sync_from_upstream(downstream, self.user)
|
||||
assert downstream.upstream_version == 2 # Library blocks start at version 2 (v1 is the empty new block)
|
||||
assert downstream.upstream_display_name == "Upstream Title V2"
|
||||
assert downstream.display_name == "Upstream Title V2"
|
||||
assert downstream.data == "<html><body>Upstream content V2</body></html>"
|
||||
|
||||
# Verify tags
|
||||
object_tags = tagging_api.get_object_tags(str(downstream.location))
|
||||
assert len(object_tags) == len(self.upstream_tags)
|
||||
for object_tag in object_tags:
|
||||
assert object_tag.value in self.upstream_tags
|
||||
|
||||
# Upstream updates
|
||||
upstream = xblock.load_block(self.upstream_key, self.user)
|
||||
upstream.display_name = "Upstream Title V3"
|
||||
upstream.data = "<html><body>Upstream content V3</body></html>"
|
||||
upstream.save()
|
||||
new_upstream_tags = self.upstream_tags + ['tag_2', 'tag_3']
|
||||
tagging_api.tag_object(str(self.upstream_key), self.taxonomy_all_org, new_upstream_tags)
|
||||
|
||||
# Assert that un-published updates are not yet pulled into downstream
|
||||
sync_from_upstream(downstream, self.user)
|
||||
assert downstream.upstream_version == 2 # Library blocks start at version 2 (v1 is the empty new block)
|
||||
assert downstream.upstream_display_name == "Upstream Title V2"
|
||||
assert downstream.display_name == "Upstream Title V2"
|
||||
assert downstream.data == "<html><body>Upstream content V2</body></html>"
|
||||
|
||||
# Publish changes
|
||||
libs.publish_changes(self.library.key, self.user.id)
|
||||
|
||||
# Follow-up sync. Assert that updates are pulled into downstream.
|
||||
sync_from_upstream(downstream, self.user)
|
||||
assert downstream.upstream_version == 3
|
||||
assert downstream.upstream_display_name == "Upstream Title V3"
|
||||
assert downstream.display_name == "Upstream Title V3"
|
||||
assert downstream.data == "<html><body>Upstream content V3</body></html>"
|
||||
|
||||
# Verify tags
|
||||
object_tags = tagging_api.get_object_tags(str(downstream.location))
|
||||
assert len(object_tags) == len(new_upstream_tags)
|
||||
for object_tag in object_tags:
|
||||
assert object_tag.value in new_upstream_tags
|
||||
|
||||
def test_sync_updates_to_modified_content(self):
|
||||
"""
|
||||
If we sync to modified content, will it preserve customizable fields, but overwrite the rest?
|
||||
"""
|
||||
downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
|
||||
|
||||
# Initial sync
|
||||
sync_from_upstream(downstream, self.user)
|
||||
assert downstream.upstream_display_name == "Upstream Title V2"
|
||||
assert downstream.display_name == "Upstream Title V2"
|
||||
assert downstream.data == "<html><body>Upstream content V2</body></html>"
|
||||
|
||||
# Upstream updates
|
||||
upstream = xblock.load_block(self.upstream_key, self.user)
|
||||
upstream.display_name = "Upstream Title V3"
|
||||
upstream.data = "<html><body>Upstream content V3</body></html>"
|
||||
upstream.save()
|
||||
libs.publish_changes(self.library.key, self.user.id)
|
||||
|
||||
# Downstream modifications
|
||||
downstream.display_name = "Downstream Title Override" # "safe" customization
|
||||
downstream.data = "Downstream content override" # "unsafe" override
|
||||
downstream.save()
|
||||
|
||||
# Follow-up sync. Assert that updates are pulled into downstream, but customizations are saved.
|
||||
sync_from_upstream(downstream, self.user)
|
||||
assert downstream.upstream_display_name == "Upstream Title V3"
|
||||
assert downstream.display_name == "Downstream Title Override" # "safe" customization survives
|
||||
assert downstream.data == "<html><body>Upstream content V3</body></html>" # "unsafe" override is gone
|
||||
|
||||
# For the Content Libraries Relaunch Beta, we do not yet need to support this edge case.
|
||||
# See "PRESERVING DOWNSTREAM CUSTOMIZATIONS and RESTORING UPSTREAM DEFAULTS" in cms/lib/xblock/upstream_sync.py.
|
||||
#
|
||||
# def test_sync_to_downstream_with_subtle_customization(self):
|
||||
# """
|
||||
# Edge case: If our downstream customizes a field, but then the upstream is changed to match the
|
||||
# customization do we still remember that the downstream field is customized? That is,
|
||||
# if the upstream later changes again, do we retain the downstream customization (rather than
|
||||
# following the upstream update?)
|
||||
# """
|
||||
# # Start with an uncustomized downstream block.
|
||||
# downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
|
||||
# sync_from_upstream(downstream, self.user)
|
||||
# assert downstream.downstream_customized == []
|
||||
# assert downstream.display_name == downstream.upstream_display_name == "Upstream Title V2"
|
||||
#
|
||||
# # Then, customize our downstream title.
|
||||
# downstream.display_name = "Title V3"
|
||||
# downstream.save()
|
||||
# assert downstream.downstream_customized == ["display_name"]
|
||||
#
|
||||
# # Syncing should retain the customization.
|
||||
# sync_from_upstream(downstream, self.user)
|
||||
# assert downstream.upstream_version == 2
|
||||
# assert downstream.upstream_display_name == "Upstream Title V2"
|
||||
# assert downstream.display_name == "Title V3"
|
||||
#
|
||||
# # Whoa, look at that, the upstream has updated itself to the exact same title...
|
||||
# upstream = xblock.load_block(self.upstream_key, self.user)
|
||||
# upstream.display_name = "Title V3"
|
||||
# upstream.save()
|
||||
#
|
||||
# # ...which is reflected when we sync.
|
||||
# sync_from_upstream(downstream, self.user)
|
||||
# assert downstream.upstream_version == 3
|
||||
# assert downstream.upstream_display_name == downstream.display_name == "Title V3"
|
||||
#
|
||||
# # But! Our downstream knows that its title is still customized.
|
||||
# assert downstream.downstream_customized == ["display_name"]
|
||||
# # So, if the upstream title changes again...
|
||||
# upstream.display_name = "Title V4"
|
||||
# upstream.save()
|
||||
#
|
||||
# # ...then the downstream title should remain put.
|
||||
# sync_from_upstream(downstream, self.user)
|
||||
# assert downstream.upstream_version == 4
|
||||
# assert downstream.upstream_display_name == "Title V4"
|
||||
# assert downstream.display_name == "Title V3"
|
||||
#
|
||||
# # Finally, if we "de-customize" the display_name field, then it should go back to syncing normally.
|
||||
# downstream.downstream_customized = []
|
||||
# upstream.display_name = "Title V5"
|
||||
# upstream.save()
|
||||
# sync_from_upstream(downstream, self.user)
|
||||
# assert downstream.upstream_version == 5
|
||||
# assert downstream.upstream_display_name == downstream.display_name == "Title V5"
|
||||
|
||||
@ddt.data(None, "Title From Some Other Upstream Version")
|
||||
def test_fetch_customizable_fields(self, initial_upstream_display_name):
|
||||
"""
|
||||
Can we fetch a block's upstream field values without syncing it?
|
||||
|
||||
Test both with and without a pre-"fetched" upstrema values on the downstream.
|
||||
"""
|
||||
downstream = BlockFactory.create(category='html', parent=self.unit)
|
||||
downstream.upstream_display_name = initial_upstream_display_name
|
||||
downstream.display_name = "Some Title"
|
||||
downstream.data = "<html><data>Some content</data></html>"
|
||||
|
||||
# Note that we're not linked to any upstream. fetch_customizable_fields shouldn't care.
|
||||
assert not downstream.upstream
|
||||
assert not downstream.upstream_version
|
||||
|
||||
# fetch!
|
||||
upstream = xblock.load_block(self.upstream_key, self.user)
|
||||
fetch_customizable_fields(upstream=upstream, downstream=downstream, user=self.user)
|
||||
|
||||
# Ensure: fetching doesn't affect the upstream link (or lack thereof).
|
||||
assert not downstream.upstream
|
||||
assert not downstream.upstream_version
|
||||
|
||||
# Ensure: fetching doesn't affect actual content or settings.
|
||||
assert downstream.display_name == "Some Title"
|
||||
assert downstream.data == "<html><data>Some content</data></html>"
|
||||
|
||||
# Ensure: fetching DOES set the upstream_* fields.
|
||||
assert downstream.upstream_display_name == "Upstream Title V2"
|
||||
|
||||
def test_prompt_and_decline_sync(self):
|
||||
"""
|
||||
Is the user prompted for sync when it's available? Does declining remove the prompt until a new sync is ready?
|
||||
"""
|
||||
# Initial conditions (pre-sync)
|
||||
downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
|
||||
link = UpstreamLink.get_for_block(downstream)
|
||||
assert link.version_synced is None
|
||||
assert link.version_declined is None
|
||||
assert link.version_available == 2 # Library block with content starts at version 2
|
||||
assert link.ready_to_sync is True
|
||||
|
||||
# Initial sync to V2
|
||||
sync_from_upstream(downstream, self.user)
|
||||
link = UpstreamLink.get_for_block(downstream)
|
||||
assert link.version_synced == 2
|
||||
assert link.version_declined is None
|
||||
assert link.version_available == 2
|
||||
assert link.ready_to_sync is False
|
||||
|
||||
# Upstream updated to V3, but not yet published
|
||||
upstream = xblock.load_block(self.upstream_key, self.user)
|
||||
upstream.data = "<html><body>Upstream content V3</body></html>"
|
||||
upstream.save()
|
||||
link = UpstreamLink.get_for_block(downstream)
|
||||
assert link.version_synced == 2
|
||||
assert link.version_declined is None
|
||||
assert link.version_available == 2
|
||||
assert link.ready_to_sync is False
|
||||
|
||||
# Publish changes
|
||||
libs.publish_changes(self.library.key, self.user.id)
|
||||
link = UpstreamLink.get_for_block(downstream)
|
||||
assert link.version_synced == 2
|
||||
assert link.version_declined is None
|
||||
assert link.version_available == 3
|
||||
assert link.ready_to_sync is True
|
||||
|
||||
# Decline to sync to V3 -- ready_to_sync becomes False.
|
||||
decline_sync(downstream)
|
||||
link = UpstreamLink.get_for_block(downstream)
|
||||
assert link.version_synced == 2
|
||||
assert link.version_declined == 3
|
||||
assert link.version_available == 3
|
||||
assert link.ready_to_sync is False
|
||||
|
||||
# Upstream updated to V4 -- ready_to_sync becomes True again.
|
||||
upstream = xblock.load_block(self.upstream_key, self.user)
|
||||
upstream.data = "<html><body>Upstream content V4</body></html>"
|
||||
upstream.save()
|
||||
libs.publish_changes(self.library.key, self.user.id)
|
||||
link = UpstreamLink.get_for_block(downstream)
|
||||
assert link.version_synced == 2
|
||||
assert link.version_declined == 3
|
||||
assert link.version_available == 4
|
||||
assert link.ready_to_sync is True
|
||||
|
||||
def test_sever_upstream_link(self):
|
||||
"""
|
||||
Does sever_upstream_link correctly disconnect a block from its upstream?
|
||||
"""
|
||||
# Start with a course block that is linked+synced to a content library block.
|
||||
downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(self.upstream_key))
|
||||
sync_from_upstream(downstream, self.user)
|
||||
|
||||
# (sanity checks)
|
||||
assert downstream.upstream == str(self.upstream_key)
|
||||
assert downstream.upstream_version == 2
|
||||
assert downstream.upstream_display_name == "Upstream Title V2"
|
||||
assert downstream.display_name == "Upstream Title V2"
|
||||
assert downstream.data == "<html><body>Upstream content V2</body></html>"
|
||||
assert downstream.copied_from_block is None
|
||||
|
||||
# Now, disconnect the course block.
|
||||
sever_upstream_link(downstream)
|
||||
|
||||
# All upstream metadata has been wiped out.
|
||||
assert downstream.upstream is None
|
||||
assert downstream.upstream_version is None
|
||||
assert downstream.upstream_display_name is None
|
||||
|
||||
# BUT, the content which was synced into the upstream remains.
|
||||
assert downstream.display_name == "Upstream Title V2"
|
||||
assert downstream.data == "<html><body>Upstream content V2</body></html>"
|
||||
|
||||
# AND, we have recorded the old upstream as our copied_from_block.
|
||||
assert downstream.copied_from_block == str(self.upstream_key)
|
||||
|
||||
def test_sync_library_block_tags(self):
|
||||
upstream_lib_block_key = libs.create_library_block(self.library.key, "html", "upstream").usage_key
|
||||
upstream_lib_block = xblock.load_block(upstream_lib_block_key, self.user)
|
||||
upstream_lib_block.display_name = "Another lib block"
|
||||
upstream_lib_block.data = "<html>another lib block</html>"
|
||||
upstream_lib_block.save()
|
||||
|
||||
libs.publish_changes(self.library.key, self.user.id)
|
||||
|
||||
expected_tags = self.upstream_tags
|
||||
tagging_api.tag_object(str(upstream_lib_block_key), self.taxonomy_all_org, expected_tags)
|
||||
|
||||
downstream = BlockFactory.create(category='html', parent=self.unit, upstream=str(upstream_lib_block_key))
|
||||
|
||||
# Initial sync
|
||||
sync_from_upstream(downstream, self.user)
|
||||
|
||||
# Verify tags
|
||||
object_tags = tagging_api.get_object_tags(str(downstream.location))
|
||||
assert len(object_tags) == len(expected_tags)
|
||||
for object_tag in object_tags:
|
||||
assert object_tag.value in expected_tags
|
||||
|
||||
# Upstream updates
|
||||
upstream_lib_block.display_name = "Upstream Title V3"
|
||||
upstream_lib_block.data = "<html><body>Upstream content V3</body></html>"
|
||||
upstream_lib_block.save()
|
||||
new_upstream_tags = self.upstream_tags + ['tag_2', 'tag_3']
|
||||
tagging_api.tag_object(str(upstream_lib_block_key), self.taxonomy_all_org, new_upstream_tags)
|
||||
|
||||
# Follow-up sync.
|
||||
sync_from_upstream(downstream, self.user)
|
||||
|
||||
#Verify tags
|
||||
object_tags = tagging_api.get_object_tags(str(downstream.location))
|
||||
assert len(object_tags) == len(new_upstream_tags)
|
||||
for object_tag in object_tags:
|
||||
assert object_tag.value in new_upstream_tags
|
||||
492
cms/lib/xblock/upstream_sync.py
Normal file
492
cms/lib/xblock/upstream_sync.py
Normal file
@@ -0,0 +1,492 @@
|
||||
"""
|
||||
Synchronize content and settings from upstream blocks to their downstream usages.
|
||||
|
||||
At the time of writing, we assume that for any upstream-downstream linkage:
|
||||
* The upstream is a Component from a Learning Core-backed Content Library.
|
||||
* The downstream is a block of matching type in a SplitModuleStore-backed Course.
|
||||
* They are both on the same Open edX instance.
|
||||
|
||||
HOWEVER, those assumptions may loosen in the future. So, we consider these to be INTERNAL ASSUMPIONS that should not be
|
||||
exposed through this module's public Python interface.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import typing as t
|
||||
from dataclasses import dataclass, asdict
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework.exceptions import NotFound
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from opaque_keys.edx.locator import LibraryUsageLocatorV2
|
||||
from xblock.exceptions import XBlockNotFoundError
|
||||
from xblock.fields import Scope, String, Integer
|
||||
from xblock.core import XBlockMixin, XBlock
|
||||
|
||||
if t.TYPE_CHECKING:
|
||||
from django.contrib.auth.models import User # pylint: disable=imported-auth-user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UpstreamLinkException(Exception):
|
||||
"""
|
||||
Raised whenever we try to inspect, sync-from, fetch-from, or delete a block's link to upstream content.
|
||||
|
||||
There are three flavors (defined below): BadDownstream, BadUpstream, NoUpstream.
|
||||
|
||||
Should be constructed with a human-friendly, localized, PII-free message, suitable for API responses and UI display.
|
||||
For now, at least, the message can assume that upstreams are Content Library blocks and downstreams are Course
|
||||
blocks, although that may need to change (see module docstring).
|
||||
"""
|
||||
|
||||
|
||||
class BadDownstream(UpstreamLinkException):
|
||||
"""
|
||||
Downstream content does not support sync.
|
||||
"""
|
||||
|
||||
|
||||
class BadUpstream(UpstreamLinkException):
|
||||
"""
|
||||
Reference to upstream content is malformed, invalid, and/or inaccessible.
|
||||
"""
|
||||
|
||||
|
||||
class NoUpstream(UpstreamLinkException):
|
||||
"""
|
||||
The downstream content does not have an upstream link at all (...as is the case for most XBlocks usages).
|
||||
|
||||
(This isn't so much an "error" like the other two-- it's just a case that needs to be handled exceptionally,
|
||||
usually by logging a message and then doing nothing.)
|
||||
"""
|
||||
def __init__(self):
|
||||
super().__init__(_("Content is not linked to a Content Library."))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class UpstreamLink:
|
||||
"""
|
||||
Metadata about some downstream content's relationship with its linked upstream content.
|
||||
"""
|
||||
upstream_ref: str | None # Reference to the upstream content, e.g., a serialized library block usage key.
|
||||
version_synced: int | None # Version of the upstream to which the downstream was last synced.
|
||||
version_available: int | None # Latest version of the upstream that's available, or None if it couldn't be loaded.
|
||||
version_declined: int | None # Latest version which the user has declined to sync with, if any.
|
||||
error_message: str | None # If link is valid, None. Otherwise, a localized, human-friendly error message.
|
||||
|
||||
@property
|
||||
def ready_to_sync(self) -> bool:
|
||||
"""
|
||||
Should we invite the downstream's authors to sync the latest upstream updates?
|
||||
"""
|
||||
return bool(
|
||||
self.upstream_ref and
|
||||
self.version_available and
|
||||
self.version_available > (self.version_synced or 0) and
|
||||
self.version_available > (self.version_declined or 0)
|
||||
)
|
||||
|
||||
@property
|
||||
def upstream_link(self) -> str | None:
|
||||
"""
|
||||
Link to edit/view upstream block in library.
|
||||
"""
|
||||
if self.version_available is None or self.upstream_ref is None:
|
||||
return None
|
||||
try:
|
||||
usage_key = LibraryUsageLocatorV2.from_string(self.upstream_ref)
|
||||
except InvalidKeyError:
|
||||
return None
|
||||
return _get_library_xblock_url(usage_key)
|
||||
|
||||
def to_json(self) -> dict[str, t.Any]:
|
||||
"""
|
||||
Get an JSON-API-friendly representation of this upstream link.
|
||||
"""
|
||||
return {
|
||||
**asdict(self),
|
||||
"ready_to_sync": self.ready_to_sync,
|
||||
"upstream_link": self.upstream_link,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def try_get_for_block(cls, downstream: XBlock) -> t.Self:
|
||||
"""
|
||||
Same as `get_for_block`, but upon failure, sets `.error_message` instead of raising an exception.
|
||||
"""
|
||||
try:
|
||||
return cls.get_for_block(downstream)
|
||||
except UpstreamLinkException as exc:
|
||||
logger.exception(
|
||||
"Tried to inspect an unsupported, broken, or missing downstream->upstream link: '%s'->'%s'",
|
||||
downstream.usage_key,
|
||||
downstream.upstream,
|
||||
)
|
||||
return cls(
|
||||
upstream_ref=downstream.upstream,
|
||||
version_synced=downstream.upstream_version,
|
||||
version_available=None,
|
||||
version_declined=None,
|
||||
error_message=str(exc),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_for_block(cls, downstream: XBlock) -> t.Self:
|
||||
"""
|
||||
Get info on a block's relationship with its linked upstream content (without actually loading the content).
|
||||
|
||||
Currently, the only supported upstreams are LC-backed Library Components. This may change in the future (see
|
||||
module docstring).
|
||||
|
||||
If link exists, is supported, and is followable, returns UpstreamLink.
|
||||
Otherwise, raises an UpstreamLinkException.
|
||||
"""
|
||||
if not downstream.upstream:
|
||||
raise NoUpstream()
|
||||
if not isinstance(downstream.usage_key.context_key, CourseKey):
|
||||
raise BadDownstream(_("Cannot update content because it does not belong to a course."))
|
||||
if downstream.has_children:
|
||||
raise BadDownstream(_("Updating content with children is not yet supported."))
|
||||
try:
|
||||
upstream_key = LibraryUsageLocatorV2.from_string(downstream.upstream)
|
||||
except InvalidKeyError as exc:
|
||||
raise BadUpstream(_("Reference to linked library item is malformed")) from exc
|
||||
downstream_type = downstream.usage_key.block_type
|
||||
if upstream_key.block_type != downstream_type:
|
||||
# Note: Currently, we strictly enforce that the downstream and upstream block_types must exactly match.
|
||||
# It could be reasonable to relax this requirement in the future if there's product need for it.
|
||||
# For example, there's no reason that a StaticTabBlock couldn't take updates from an HtmlBlock.
|
||||
raise BadUpstream(
|
||||
_("Content type mismatch: {downstream_type} cannot be linked to {upstream_type}.").format(
|
||||
downstream_type=downstream_type, upstream_type=upstream_key.block_type
|
||||
)
|
||||
) from TypeError(
|
||||
f"downstream block '{downstream.usage_key}' is linked to "
|
||||
f"upstream block of different type '{upstream_key}'"
|
||||
)
|
||||
# We import this here b/c UpstreamSyncMixin is used by cms/envs, which loads before the djangoapps are ready.
|
||||
from openedx.core.djangoapps.content_libraries.api import (
|
||||
get_library_block # pylint: disable=wrong-import-order
|
||||
)
|
||||
try:
|
||||
lib_meta = get_library_block(upstream_key)
|
||||
except XBlockNotFoundError as exc:
|
||||
raise BadUpstream(_("Linked library item was not found in the system")) from exc
|
||||
return cls(
|
||||
upstream_ref=downstream.upstream,
|
||||
version_synced=downstream.upstream_version,
|
||||
version_available=(lib_meta.published_version_num if lib_meta else None),
|
||||
version_declined=downstream.upstream_version_declined,
|
||||
error_message=None,
|
||||
)
|
||||
|
||||
|
||||
def sync_from_upstream(downstream: XBlock, user: User) -> None:
|
||||
"""
|
||||
Update `downstream` with content+settings from the latest available version of its linked upstream content.
|
||||
|
||||
Preserves overrides to customizable fields; overwrites overrides to other fields.
|
||||
Does not save `downstream` to the store. That is left up to the caller.
|
||||
|
||||
If `downstream` lacks a valid+supported upstream link, this raises an UpstreamLinkException.
|
||||
"""
|
||||
link, upstream = _load_upstream_link_and_block(downstream, user)
|
||||
_update_customizable_fields(upstream=upstream, downstream=downstream, only_fetch=False)
|
||||
_update_non_customizable_fields(upstream=upstream, downstream=downstream)
|
||||
_update_tags(upstream=upstream, downstream=downstream)
|
||||
downstream.upstream_version = link.version_available
|
||||
|
||||
|
||||
def fetch_customizable_fields(*, downstream: XBlock, user: User, upstream: XBlock | None = None) -> None:
|
||||
"""
|
||||
Fetch upstream-defined value of customizable fields and save them on the downstream.
|
||||
|
||||
If `upstream` is provided, use that block as the upstream.
|
||||
Otherwise, load the block specified by `downstream.upstream`, which may raise an UpstreamLinkException.
|
||||
"""
|
||||
if not upstream:
|
||||
_link, upstream = _load_upstream_link_and_block(downstream, user)
|
||||
_update_customizable_fields(upstream=upstream, downstream=downstream, only_fetch=True)
|
||||
|
||||
|
||||
def _load_upstream_link_and_block(downstream: XBlock, user: User) -> tuple[UpstreamLink, XBlock]:
|
||||
"""
|
||||
Load the upstream metadata and content for a downstream block.
|
||||
|
||||
Assumes that the upstream content is an XBlock in an LC-backed content libraries. This assumption may need to be
|
||||
relaxed in the future (see module docstring).
|
||||
|
||||
If `downstream` lacks a valid+supported upstream link, this raises an UpstreamLinkException.
|
||||
"""
|
||||
link = UpstreamLink.get_for_block(downstream) # can raise UpstreamLinkException
|
||||
# We import load_block here b/c UpstreamSyncMixin is used by cms/envs, which loads before the djangoapps are ready.
|
||||
from openedx.core.djangoapps.xblock.api import load_block, CheckPerm, LatestVersion # pylint: disable=wrong-import-order
|
||||
try:
|
||||
lib_block: XBlock = load_block(
|
||||
LibraryUsageLocatorV2.from_string(downstream.upstream),
|
||||
user,
|
||||
check_permission=CheckPerm.CAN_READ_AS_AUTHOR,
|
||||
version=LatestVersion.PUBLISHED,
|
||||
)
|
||||
except (NotFound, PermissionDenied) as exc:
|
||||
raise BadUpstream(_("Linked library item could not be loaded: {}").format(downstream.upstream)) from exc
|
||||
return link, lib_block
|
||||
|
||||
|
||||
def _update_customizable_fields(*, upstream: XBlock, downstream: XBlock, only_fetch: bool) -> None:
|
||||
"""
|
||||
For each customizable field:
|
||||
* Save the upstream value to a hidden field on the downstream ("FETCH").
|
||||
* If `not only_fetch`, and if the field *isn't* customized on the downstream, then:
|
||||
* Update it the downstream field's value from the upstream field ("SYNC").
|
||||
|
||||
Concrete example: Imagine `lib_problem` is our upstream and `course_problem` is our downstream.
|
||||
|
||||
* Say that the customizable fields are [display_name, max_attempts].
|
||||
|
||||
* Set `course_problem.upstream_display_name = lib_problem.display_name` ("fetch").
|
||||
* If `not only_fetch`, and `course_problem.display_name` wasn't customized, then:
|
||||
* Set `course_problem.display_name = lib_problem.display_name` ("sync").
|
||||
|
||||
* Set `course_problem.upstream_max_attempts = lib_problem.max_attempts` ("fetch").
|
||||
* If `not only_fetch`, and `course_problem.max_attempts` wasn't customized, then:
|
||||
* Set `course_problem.max_attempts = lib_problem.max_attempts` ("sync").
|
||||
"""
|
||||
syncable_field_names = _get_synchronizable_fields(upstream, downstream)
|
||||
|
||||
for field_name, fetch_field_name in downstream.get_customizable_fields().items():
|
||||
|
||||
if field_name not in syncable_field_names:
|
||||
continue
|
||||
|
||||
# FETCH the upstream's value and save it on the downstream (ie, `downstream.upstream_$FIELD`).
|
||||
old_upstream_value = getattr(downstream, fetch_field_name)
|
||||
new_upstream_value = getattr(upstream, field_name)
|
||||
setattr(downstream, fetch_field_name, new_upstream_value)
|
||||
|
||||
if only_fetch:
|
||||
continue
|
||||
|
||||
# Okay, now for the nuanced part...
|
||||
# We need to update the downstream field *iff it has not been customized**.
|
||||
# Determining whether a field has been customized will differ in Beta vs Future release.
|
||||
# (See "PRESERVING DOWNSTREAM CUSTOMIZATIONS" comment below for details.)
|
||||
|
||||
## FUTURE BEHAVIOR: field is "customized" iff we have noticed that the user edited it.
|
||||
# if field_name in downstream.downstream_customized:
|
||||
# continue
|
||||
|
||||
## BETA BEHAVIOR: field is "customized" iff we have the prev upstream value, but field doesn't match it.
|
||||
downstream_value = getattr(downstream, field_name)
|
||||
if old_upstream_value and downstream_value != old_upstream_value:
|
||||
continue # Field has been customized. Don't touch it. Move on.
|
||||
|
||||
# Field isn't customized -- SYNC it!
|
||||
setattr(downstream, field_name, new_upstream_value)
|
||||
|
||||
|
||||
def _update_non_customizable_fields(*, upstream: XBlock, downstream: XBlock) -> None:
|
||||
"""
|
||||
For each field `downstream.blah` that isn't customizable: set it to `upstream.blah`.
|
||||
"""
|
||||
syncable_fields = _get_synchronizable_fields(upstream, downstream)
|
||||
customizable_fields = set(downstream.get_customizable_fields().keys())
|
||||
for field_name in syncable_fields - customizable_fields:
|
||||
new_upstream_value = getattr(upstream, field_name)
|
||||
setattr(downstream, field_name, new_upstream_value)
|
||||
|
||||
|
||||
def _update_tags(*, upstream: XBlock, downstream: XBlock) -> None:
|
||||
"""
|
||||
Update tags from `upstream` to `downstream`
|
||||
"""
|
||||
from openedx.core.djangoapps.content_tagging.api import copy_tags_as_read_only
|
||||
# For any block synced with an upstream, copy the tags as read_only
|
||||
# This keeps tags added locally.
|
||||
copy_tags_as_read_only(
|
||||
str(upstream.location),
|
||||
str(downstream.location),
|
||||
)
|
||||
|
||||
|
||||
def _get_synchronizable_fields(upstream: XBlock, downstream: XBlock) -> set[str]:
|
||||
"""
|
||||
The syncable fields are the ones which are content- or settings-scoped AND are defined on both (up,down)stream.
|
||||
"""
|
||||
return set.intersection(*[
|
||||
set(
|
||||
field_name
|
||||
for (field_name, field) in block.__class__.fields.items()
|
||||
if field.scope in [Scope.settings, Scope.content]
|
||||
)
|
||||
for block in [upstream, downstream]
|
||||
])
|
||||
|
||||
|
||||
def decline_sync(downstream: XBlock) -> None:
|
||||
"""
|
||||
Given an XBlock that is linked to upstream content, mark the latest available update as 'declined' so that its
|
||||
authors are not prompted (until another upstream version becomes available).
|
||||
|
||||
Does not save `downstream` to the store. That is left up to the caller.
|
||||
|
||||
If `downstream` lacks a valid+supported upstream link, this raises an UpstreamLinkException.
|
||||
"""
|
||||
upstream_link = UpstreamLink.get_for_block(downstream) # Can raise UpstreamLinkException
|
||||
downstream.upstream_version_declined = upstream_link.version_available
|
||||
|
||||
|
||||
def sever_upstream_link(downstream: XBlock) -> None:
|
||||
"""
|
||||
Given an XBlock that is linked to upstream content, disconnect the link, such that authors are never again prompted
|
||||
to sync upstream updates. Erase all `.upstream*` fields from the downtream block.
|
||||
|
||||
However, before nulling out the `.upstream` field, we copy its value over to `.copied_from_block`. This makes sense,
|
||||
because once a downstream block has been de-linked from source (e.g., a Content Library block), it is no different
|
||||
than if the block had just been copy-pasted in the first place.
|
||||
|
||||
Does not save `downstream` to the store. That is left up to the caller.
|
||||
|
||||
If `downstream` lacks a link, then this raises NoUpstream (though it is reasonable for callers to handle such
|
||||
exception and ignore it, as the end result is the same: `downstream.upstream is None`).
|
||||
"""
|
||||
if not downstream.upstream:
|
||||
raise NoUpstream()
|
||||
downstream.copied_from_block = downstream.upstream
|
||||
downstream.upstream = None
|
||||
downstream.upstream_version = None
|
||||
for _, fetched_upstream_field in downstream.get_customizable_fields().items():
|
||||
setattr(downstream, fetched_upstream_field, None) # Null out upstream_display_name, et al.
|
||||
|
||||
|
||||
def _get_library_xblock_url(usage_key: LibraryUsageLocatorV2):
|
||||
"""
|
||||
Gets authoring url for given library_key.
|
||||
"""
|
||||
library_url = None
|
||||
if mfe_base_url := settings.COURSE_AUTHORING_MICROFRONTEND_URL: # type: ignore
|
||||
library_key = usage_key.lib_key
|
||||
library_url = f'{mfe_base_url}/library/{library_key}/components?usageKey={usage_key}'
|
||||
return library_url
|
||||
|
||||
|
||||
class UpstreamSyncMixin(XBlockMixin):
|
||||
"""
|
||||
Allows an XBlock in the CMS to be associated & synced with an upstream.
|
||||
|
||||
Mixed into CMS's XBLOCK_MIXINS, but not LMS's.
|
||||
"""
|
||||
|
||||
# Upstream synchronization metadata fields
|
||||
upstream = String(
|
||||
help=(
|
||||
"The usage key of a block (generally within a content library) which serves as a source of upstream "
|
||||
"updates for this block, or None if there is no such upstream. Please note: It is valid for this "
|
||||
"field to hold a usage key for an upstream block that does not exist (or does not *yet* exist) on "
|
||||
"this instance, particularly if this downstream block was imported from a different instance."
|
||||
),
|
||||
default=None, scope=Scope.settings, hidden=True, enforce_type=True
|
||||
)
|
||||
upstream_version = Integer(
|
||||
help=(
|
||||
"Record of the upstream block's version number at the time this block was created from it. If this "
|
||||
"upstream_version is smaller than the upstream block's latest published version, then the author will be "
|
||||
"invited to sync updates into this downstream block, presuming that they have not already declined to sync "
|
||||
"said version."
|
||||
),
|
||||
default=None, scope=Scope.settings, hidden=True, enforce_type=True,
|
||||
)
|
||||
upstream_version_declined = Integer(
|
||||
help=(
|
||||
"Record of the latest upstream version for which the author declined to sync updates, or None if they have "
|
||||
"never declined an update."
|
||||
),
|
||||
default=None, scope=Scope.settings, hidden=True, enforce_type=True,
|
||||
)
|
||||
|
||||
# Store the fetched upstream values for customizable fields.
|
||||
upstream_display_name = String(
|
||||
help=("The value of display_name on the linked upstream block."),
|
||||
default=None, scope=Scope.settings, hidden=True, enforce_type=True,
|
||||
)
|
||||
upstream_max_attempts = Integer(
|
||||
help=("The value of max_attempts on the linked upstream block."),
|
||||
default=None, scope=Scope.settings, hidden=True, enforce_type=True,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def get_customizable_fields(cls) -> dict[str, str]:
|
||||
"""
|
||||
Mapping from each customizable field to the field which can be used to restore its upstream value.
|
||||
|
||||
XBlocks outside of edx-platform can override this in order to set up their own customizable fields.
|
||||
"""
|
||||
return {
|
||||
"display_name": "upstream_display_name",
|
||||
"max_attempts": "upstream_max_attempts",
|
||||
}
|
||||
|
||||
# PRESERVING DOWNSTREAM CUSTOMIZATIONS and RESTORING UPSTREAM VALUES
|
||||
#
|
||||
# For the full Content Libraries Relaunch, we would like to keep track of which customizable fields the user has
|
||||
# actually customized. The idea is: once an author has customized a customizable field....
|
||||
#
|
||||
# - future upstream syncs will NOT blow away the customization,
|
||||
# - but future upstream syncs WILL fetch the upstream values and tuck them away in a hidden field,
|
||||
# - and the author can can revert back to said fetched upstream value at any point.
|
||||
#
|
||||
# Now, whether field is "customized" (and thus "revertible") is dependent on whether they have ever edited it.
|
||||
# To instrument this, we need to keep track of which customizable fields have been edited using a new XBlock field:
|
||||
# `downstream_customized`
|
||||
#
|
||||
# Implementing `downstream_customized` has proven difficult, because there is no simple way to keep it up-to-date
|
||||
# with the many different ways XBlock fields can change. The `.save()` and `.editor_saved()` methods are promising,
|
||||
# but we need to do more due diligence to be sure that they cover all cases, including API edits, import/export,
|
||||
# copy/paste, etc. We will figure this out in time for the full Content Libraries Relaunch (related ticket:
|
||||
# https://github.com/openedx/frontend-app-authoring/issues/1317). But, for the Beta realease, we're going to
|
||||
# implement something simpler:
|
||||
#
|
||||
# - We fetch upstream values for customizable fields and tuck them away in a hidden field (same as above).
|
||||
# - If a customizable field DOES match the fetched upstream value, then future upstream syncs DO update it.
|
||||
# - If a customizable field does NOT the fetched upstream value, then future upstream syncs DO NOT update it.
|
||||
# - There is no UI option for explicitly reverting back to the fetched upstream value.
|
||||
#
|
||||
# For future reference, here is a partial implementation of what we are thinking for the full Content Libraries
|
||||
# Relaunch::
|
||||
#
|
||||
# downstream_customized = List(
|
||||
# help=(
|
||||
# "Names of the fields which have values set on the upstream block yet have been explicitly "
|
||||
# "overridden on this downstream block. Unless explicitly cleared by the user, these customizations "
|
||||
# "will persist even when updates are synced from the upstream."
|
||||
# ),
|
||||
# default=[], scope=Scope.settings, hidden=True, enforce_type=True,
|
||||
# )
|
||||
#
|
||||
# def save(self, *args, **kwargs):
|
||||
# """
|
||||
# Update `downstream_customized` when a customizable field is modified.
|
||||
#
|
||||
# NOTE: This does not work, because save() isn't actually called in all the cases that we'd want it to be.
|
||||
# """
|
||||
# super().save(*args, **kwargs)
|
||||
# customizable_fields = self.get_customizable_fields()
|
||||
#
|
||||
# # Loop through all the fields that are potentially cutomizable.
|
||||
# for field_name, restore_field_name in self.get_customizable_fields():
|
||||
#
|
||||
# # If the field is already marked as customized, then move on so that we don't
|
||||
# # unneccessarily query the block for its current value.
|
||||
# if field_name in self.downstream_customized:
|
||||
# continue
|
||||
#
|
||||
# # If this field's value doesn't match the synced upstream value, then mark the field
|
||||
# # as customized so that we don't clobber it later when syncing.
|
||||
# # NOTE: Need to consider the performance impact of all these field lookups.
|
||||
# if getattr(self, field_name) != getattr(self, restore_field_name):
|
||||
# self.downstream_customized.append(field_name)
|
||||
BIN
cms/static/images/large-itembank-icon.png
Normal file
BIN
cms/static/images/large-itembank-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 811 B |
BIN
cms/static/images/large-library_v2-icon.png
Normal file
BIN
cms/static/images/large-library_v2-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 584 B |
3
cms/static/images/pencil-icon.svg
Normal file
3
cms/static/images/pencil-icon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" role="img" focusable="false" aria-hidden="true">
|
||||
<path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25ZM21.41 6.34l-3.75-3.75-2.53 2.54 3.75 3.75 2.53-2.54Z" fill="currentColor"></path>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 292 B |
@@ -3,7 +3,7 @@ define([
|
||||
], function($, CourseDetailsModel, MainView) {
|
||||
'use strict';
|
||||
|
||||
return function(detailsUrl, showMinGradeWarning, showCertificateAvailableDate, upgradeDeadline, useV2CertDisplaySettings) {
|
||||
return function(detailsUrl, showMinGradeWarning, showCertificateAvailableDate, upgradeDeadline) {
|
||||
var model;
|
||||
// highlighting labels when fields are focused in
|
||||
$('form :input')
|
||||
@@ -23,7 +23,6 @@ define([
|
||||
model = new CourseDetailsModel();
|
||||
model.urlRoot = detailsUrl;
|
||||
model.showCertificateAvailableDate = showCertificateAvailableDate;
|
||||
model.useV2CertDisplaySettings = useV2CertDisplaySettings;
|
||||
model.set('upgrade_deadline', upgradeDeadline);
|
||||
model.fetch({
|
||||
// eslint-disable-next-line no-shadow
|
||||
@@ -33,7 +32,6 @@ define([
|
||||
model: model,
|
||||
showMinGradeWarning: showMinGradeWarning
|
||||
});
|
||||
editor.useV2CertDisplaySettings = useV2CertDisplaySettings;
|
||||
editor.render();
|
||||
},
|
||||
reset: true,
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
define([ // jshint ignore:line
|
||||
'jquery',
|
||||
'underscore',
|
||||
'gettext',
|
||||
'common/js/components/utils/view_utils',
|
||||
'edx-ui-toolkit/js/utils/string-utils',
|
||||
'edx-ui-toolkit/js/utils/html-utils'
|
||||
],
|
||||
function($, _, gettext, ViewUtils, StringUtils, HtmlUtils) {
|
||||
'use strict';
|
||||
|
||||
return function(maintenanceViewURL) {
|
||||
var showError;
|
||||
// Reset values
|
||||
$('#reset-button').click(function(e) {
|
||||
e.preventDefault();
|
||||
$('#course-id').val('');
|
||||
$('#dry-run').prop('checked', true);
|
||||
// clear out result container
|
||||
$('#result-container').html('');
|
||||
});
|
||||
|
||||
showError = function(containerElSelector, error) {
|
||||
var errorWrapperElSelector, errorHtml;
|
||||
errorWrapperElSelector = containerElSelector + ' .wrapper-error';
|
||||
errorHtml = HtmlUtils.joinHtml(
|
||||
HtmlUtils.HTML('<div class="error" aria-live="polite" id="course-id-error">'),
|
||||
error,
|
||||
HtmlUtils.HTML('</div>')
|
||||
);
|
||||
HtmlUtils.setHtml($(errorWrapperElSelector), HtmlUtils.HTML(errorHtml));
|
||||
$(errorWrapperElSelector).css('display', 'inline-block');
|
||||
$(errorWrapperElSelector).fadeOut(5000);
|
||||
};
|
||||
|
||||
$('form#force_publish').submit(function(event) {
|
||||
var attrs, forcePublishedTemplate, $submitButton, deferred, promise, data;
|
||||
event.preventDefault();
|
||||
|
||||
// clear out result container
|
||||
$('#result-container').html('');
|
||||
|
||||
$submitButton = $('#submit_force_publish');
|
||||
deferred = new $.Deferred();
|
||||
promise = deferred.promise();
|
||||
|
||||
data = $('#force_publish').serialize();
|
||||
|
||||
// disable submit button while executing.
|
||||
ViewUtils.disableElementWhileRunning($submitButton, function() { return promise; });
|
||||
|
||||
$.ajax({
|
||||
type: 'POST',
|
||||
url: maintenanceViewURL,
|
||||
dataType: 'json',
|
||||
data: data
|
||||
})
|
||||
.done(function(response) {
|
||||
if (response.error) {
|
||||
showError('#course-id-container', response.msg);
|
||||
} else {
|
||||
if (response.msg) {
|
||||
showError('#result-error', response.msg);
|
||||
} else {
|
||||
attrs = $.extend({}, response, {StringUtils: StringUtils});
|
||||
forcePublishedTemplate = HtmlUtils.template(
|
||||
$('#force-published-course-response-tpl').text()
|
||||
);
|
||||
HtmlUtils.setHtml($('#result-container'), forcePublishedTemplate(attrs));
|
||||
}
|
||||
}
|
||||
})
|
||||
.fail(function() {
|
||||
// response.responseText here because it would show some strange output, it may output Traceback
|
||||
// sometimes if unexpected issue arises. Better to show just internal error when getting 500 error.
|
||||
showError('#result-error', gettext('Internal Server Error.'));
|
||||
})
|
||||
.always(function() {
|
||||
deferred.resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
});
|
||||
@@ -11,7 +11,8 @@ define(['backbone'], function(Backbone) {
|
||||
// boilerplate_name (may be null)
|
||||
// is_common (only used for problems)
|
||||
templates: [],
|
||||
support_legend: {}
|
||||
support_legend: {},
|
||||
beta: false,
|
||||
},
|
||||
parse: function(response) {
|
||||
// Returns true only for templates that both have no boilerplate and are of
|
||||
@@ -26,6 +27,7 @@ define(['backbone'], function(Backbone) {
|
||||
this.templates = response.templates;
|
||||
this.display_name = response.display_name;
|
||||
this.support_legend = response.support_legend;
|
||||
this.beta = response.beta;
|
||||
|
||||
// Sort the templates.
|
||||
this.templates.sort(function(a, b) {
|
||||
|
||||
@@ -84,35 +84,33 @@ function(Backbone, _, gettext, ValidationHelpers, DateUtils, StringUtils) {
|
||||
);
|
||||
}
|
||||
|
||||
if (this.useV2CertDisplaySettings) {
|
||||
if (
|
||||
newattrs.certificates_display_behavior
|
||||
&& !(Object.values(CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS).includes(newattrs.certificates_display_behavior))
|
||||
) {
|
||||
errors.certificates_display_behavior = StringUtils.interpolate(
|
||||
gettext(
|
||||
'The certificate display behavior must be one of: {behavior_options}'
|
||||
),
|
||||
{
|
||||
behavior_options: Object.values(CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS).join(', ')
|
||||
}
|
||||
);
|
||||
}
|
||||
if (
|
||||
newattrs.certificates_display_behavior
|
||||
&& !(Object.values(CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS).includes(newattrs.certificates_display_behavior))
|
||||
) {
|
||||
errors.certificates_display_behavior = StringUtils.interpolate(
|
||||
gettext(
|
||||
'The certificate display behavior must be one of: {behavior_options}'
|
||||
),
|
||||
{
|
||||
behavior_options: Object.values(CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS).join(', ')
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Throw error if there's a value for certificate_available_date
|
||||
if (
|
||||
(newattrs.certificate_available_date && newattrs.certificates_display_behavior != CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE)
|
||||
|| (!newattrs.certificate_available_date && newattrs.certificates_display_behavior == CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE)
|
||||
) {
|
||||
errors.certificates_display_behavior = StringUtils.interpolate(
|
||||
gettext(
|
||||
'The certificates display behavior must be {valid_option} if certificate available date is set.'
|
||||
),
|
||||
{
|
||||
valid_option: CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE
|
||||
}
|
||||
);
|
||||
}
|
||||
// Throw error if there's a value for certificate_available_date
|
||||
if (
|
||||
(newattrs.certificate_available_date && newattrs.certificates_display_behavior != CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE)
|
||||
|| (!newattrs.certificate_available_date && newattrs.certificates_display_behavior == CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE)
|
||||
) {
|
||||
errors.certificates_display_behavior = StringUtils.interpolate(
|
||||
gettext(
|
||||
'The certificates display behavior must be {valid_option} if certificate available date is set.'
|
||||
),
|
||||
{
|
||||
valid_option: CERTIFICATES_DISPLAY_BEHAVIOR_OPTIONS.END_WITH_DATE
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (newattrs.intro_video && newattrs.intro_video !== this.get('intro_video')) {
|
||||
|
||||
@@ -92,6 +92,7 @@ installEditTemplates = function(append) {
|
||||
TemplateHelpers.installTemplate('edit-xblock-modal');
|
||||
TemplateHelpers.installTemplate('editor-mode-button');
|
||||
TemplateHelpers.installTemplate('edit-title-button');
|
||||
TemplateHelpers.installTemplate('edit-upstream-alert');
|
||||
|
||||
// Add templates needed by the settings editor
|
||||
TemplateHelpers.installTemplate('metadata-editor');
|
||||
|
||||
75
cms/static/js/views/components/add_library_content.js
Normal file
75
cms/static/js/views/components/add_library_content.js
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Provides utilities to open and close the library content picker.
|
||||
* This is for adding a single, selected, non-randomized component (XBlock)
|
||||
* from the library into the course. It achieves the same effect as copy-pasting
|
||||
* the block from a library into the course. The block will remain synced with
|
||||
* the "upstream" library version.
|
||||
*
|
||||
* Compare cms/static/js/views/modals/select_v2_library_content.js which uses
|
||||
* a multi-select modal to add component(s) to a Problem Bank (for
|
||||
* randomization).
|
||||
*/
|
||||
define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal'],
|
||||
function($, _, gettext, BaseModal) {
|
||||
'use strict';
|
||||
|
||||
var AddLibraryContent = BaseModal.extend({
|
||||
options: $.extend({}, BaseModal.prototype.options, {
|
||||
modalName: 'add-component-from-library',
|
||||
modalSize: 'lg',
|
||||
view: 'studio_view',
|
||||
viewSpecificClasses: 'modal-add-component-picker confirm',
|
||||
// Translators: "title" is the name of the current component being edited.
|
||||
titleFormat: gettext('Add library content'),
|
||||
addPrimaryActionButton: false,
|
||||
}),
|
||||
|
||||
initialize: function() {
|
||||
BaseModal.prototype.initialize.call(this);
|
||||
// Add event listen to close picker when the iframe tells us to
|
||||
const handleMessage = (event) => {
|
||||
if (event.data?.type === 'pickerComponentSelected') {
|
||||
var requestData = {
|
||||
library_content_key: event.data.usageKey,
|
||||
category: event.data.category,
|
||||
}
|
||||
this.refreshFunction(requestData);
|
||||
this.hide();
|
||||
}
|
||||
};
|
||||
this.messageListener = window.addEventListener("message", handleMessage);
|
||||
this.cleanupListener = () => { window.removeEventListener("message", handleMessage) };
|
||||
},
|
||||
|
||||
hide: function() {
|
||||
BaseModal.prototype.hide.call(this);
|
||||
this.cleanupListener();
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds the action buttons to the modal.
|
||||
*/
|
||||
addActionButtons: function() {
|
||||
this.addActionButton('cancel', gettext('Cancel'));
|
||||
},
|
||||
|
||||
/**
|
||||
* Show a component picker modal from library.
|
||||
* @param contentPickerUrl Url for component picker
|
||||
* @param refreshFunction A function to refresh the block after it has been updated
|
||||
*/
|
||||
showComponentPicker: function(contentPickerUrl, refreshFunction) {
|
||||
this.contentPickerUrl = contentPickerUrl;
|
||||
this.refreshFunction = refreshFunction;
|
||||
|
||||
this.render();
|
||||
this.show();
|
||||
},
|
||||
|
||||
getContentHtml: function() {
|
||||
return `<iframe src="${this.contentPickerUrl}" onload="this.contentWindow.focus()" frameborder="0" style="width: 100%; height: 100%;"/>`;
|
||||
},
|
||||
});
|
||||
|
||||
return AddLibraryContent;
|
||||
});
|
||||
@@ -3,8 +3,9 @@
|
||||
*/
|
||||
define(['jquery', 'underscore', 'gettext', 'js/views/baseview', 'common/js/components/utils/view_utils',
|
||||
'js/views/components/add_xblock_button', 'js/views/components/add_xblock_menu',
|
||||
'js/views/components/add_library_content',
|
||||
'edx-ui-toolkit/js/utils/html-utils'],
|
||||
function($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu, HtmlUtils) {
|
||||
function($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu, AddLibraryContent, HtmlUtils) {
|
||||
'use strict';
|
||||
|
||||
var AddXBlockComponent = BaseView.extend({
|
||||
@@ -67,14 +68,32 @@ function($, _, gettext, BaseView, ViewUtils, AddXBlockButton, AddXBlockMenu, Htm
|
||||
oldOffset = ViewUtils.getScrollOffset(this.$el);
|
||||
event.preventDefault();
|
||||
this.closeNewComponent(event);
|
||||
ViewUtils.runOperationShowingMessage(
|
||||
gettext('Adding'),
|
||||
_.bind(this.options.createComponent, this, saveData, $element)
|
||||
).always(function() {
|
||||
// Restore the scroll position of the buttons so that the new
|
||||
// component appears above them.
|
||||
ViewUtils.setScrollOffset(self.$el, oldOffset);
|
||||
});
|
||||
|
||||
if (saveData.type === 'library_v2') {
|
||||
var modal = new AddLibraryContent();
|
||||
modal.showComponentPicker(
|
||||
this.options.libraryContentPickerUrl,
|
||||
function(data) {
|
||||
ViewUtils.runOperationShowingMessage(
|
||||
gettext('Adding'),
|
||||
_.bind(this.options.createComponent, this, data, $element),
|
||||
).always(function() {
|
||||
// Restore the scroll position of the buttons so that the new
|
||||
// component appears above them.
|
||||
ViewUtils.setScrollOffset(self.$el, oldOffset);
|
||||
});
|
||||
}.bind(this)
|
||||
);
|
||||
} else {
|
||||
ViewUtils.runOperationShowingMessage(
|
||||
gettext('Adding'),
|
||||
_.bind(this.options.createComponent, this, saveData, $element),
|
||||
).always(function() {
|
||||
// Restore the scroll position of the buttons so that the new
|
||||
// component appears above them.
|
||||
ViewUtils.setScrollOffset(self.$el, oldOffset);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ define(['js/views/baseview', 'edx-ui-toolkit/js/utils/html-utils'],
|
||||
var attributes = {
|
||||
type: this.model.type,
|
||||
templates: this.model.templates,
|
||||
display_name: this.model.display_name
|
||||
display_name: this.model.display_name,
|
||||
beta: this.model.beta,
|
||||
};
|
||||
BaseView.prototype.initialize.call(this);
|
||||
this.template = this.loadTemplate('add-xblock-component-button');
|
||||
|
||||
@@ -75,6 +75,27 @@ function($, _, Backbone, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockE
|
||||
this.$('.modal-window-title').html(this.loadTemplate('edit-title-button')({title: title}));
|
||||
},
|
||||
|
||||
createWarningToast: function(upstreamLink) {
|
||||
// xss-lint: disable=javascript-jquery-insertion
|
||||
this.$('.modal-header').before(this.loadTemplate('edit-upstream-alert')({
|
||||
upstreamLink: upstreamLink,
|
||||
}));
|
||||
},
|
||||
|
||||
getXBlockUpstreamLink: function() {
|
||||
const usageKey = this.xblockElement.data('locator');
|
||||
$.ajax({
|
||||
url: '/api/contentstore/v2/downstreams/' + usageKey,
|
||||
type: 'GET',
|
||||
success: function(data) {
|
||||
if (data?.upstream_link) {
|
||||
this.createWarningToast(data.upstream_link);
|
||||
}
|
||||
}.bind(this),
|
||||
notifyOnError: false,
|
||||
})
|
||||
},
|
||||
|
||||
onDisplayXBlock: function() {
|
||||
var editorView = this.editorView,
|
||||
title = this.getTitle(),
|
||||
@@ -101,6 +122,7 @@ function($, _, Backbone, gettext, BaseModal, ViewUtils, XBlockViewUtils, XBlockE
|
||||
} else {
|
||||
this.$('.modal-window-title').text(title);
|
||||
}
|
||||
this.getXBlockUpstreamLink();
|
||||
|
||||
// If the xblock is not using custom buttons then choose which buttons to show
|
||||
if (!editorView.hasCustomButtons()) {
|
||||
|
||||
117
cms/static/js/views/modals/preview_v2_library_changes.js
Normal file
117
cms/static/js/views/modals/preview_v2_library_changes.js
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* The PreviewLibraryChangesModal is a Backbone view that shows an iframe in a
|
||||
* modal window. The iframe embeds a view from the Authoring MFE that allows
|
||||
* authors to preview the new version of a library-sourced XBlock, and decide
|
||||
* whether to accept ("sync") or reject ("ignore") the changes.
|
||||
*/
|
||||
define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal', 'common/js/components/utils/view_utils'],
|
||||
function($, _, gettext, BaseModal, ViewUtils) {
|
||||
'use strict';
|
||||
|
||||
var PreviewLibraryChangesModal = BaseModal.extend({
|
||||
events: _.extend({}, BaseModal.prototype.events, {
|
||||
'click .action-accept': 'acceptChanges',
|
||||
'click .action-ignore': 'ignoreChanges',
|
||||
}),
|
||||
|
||||
options: $.extend({}, BaseModal.prototype.options, {
|
||||
modalName: 'preview-lib-changes',
|
||||
modalSize: 'lg',
|
||||
view: 'studio_view',
|
||||
viewSpecificClasses: 'modal-lib-preview confirm',
|
||||
// Translators: "title" is the name of the current component being edited.
|
||||
titleFormat: gettext('Preview changes to: {title}'),
|
||||
addPrimaryActionButton: false,
|
||||
}),
|
||||
|
||||
initialize: function() {
|
||||
BaseModal.prototype.initialize.call(this);
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds the action buttons to the modal.
|
||||
*/
|
||||
addActionButtons: function() {
|
||||
this.addActionButton('accept', gettext('Accept changes'), true);
|
||||
this.addActionButton('ignore', gettext('Ignore changes'));
|
||||
this.addActionButton('cancel', gettext('Cancel'));
|
||||
},
|
||||
|
||||
/**
|
||||
* Show an edit modal for the specified xblock
|
||||
* @param xblockInfo The XBlockInfo model that describes the xblock.
|
||||
* @param courseAuthoringMfeUrl The course authoring mfe url.
|
||||
* @param upstreamBlockId The library block id.
|
||||
* @param upstreamBlockVersionSynced The library block current version.
|
||||
* @param refreshFunction A function to refresh the block after it has been updated
|
||||
*/
|
||||
showPreviewFor: function(
|
||||
xblockInfo,
|
||||
courseAuthoringMfeUrl,
|
||||
upstreamBlockId,
|
||||
upstreamBlockVersionSynced,
|
||||
refreshFunction
|
||||
) {
|
||||
this.xblockInfo = xblockInfo;
|
||||
this.courseAuthoringMfeUrl = courseAuthoringMfeUrl;
|
||||
this.downstreamBlockId = this.xblockInfo.get('id');
|
||||
this.upstreamBlockId = upstreamBlockId;
|
||||
this.upstreamBlockVersionSynced = upstreamBlockVersionSynced;
|
||||
this.refreshFunction = refreshFunction;
|
||||
|
||||
this.render();
|
||||
this.show();
|
||||
},
|
||||
|
||||
getContentHtml: function() {
|
||||
return `
|
||||
<iframe src="${this.courseAuthoringMfeUrl}/legacy/preview-changes/${this.upstreamBlockId}?old=${this.upstreamBlockVersionSynced}">
|
||||
`;
|
||||
},
|
||||
|
||||
getTitle: function() {
|
||||
var displayName = this.xblockInfo.get('display_name');
|
||||
if (!displayName) {
|
||||
if (this.xblockInfo.isVertical()) {
|
||||
displayName = gettext('Unit');
|
||||
} else {
|
||||
displayName = gettext('Component');
|
||||
}
|
||||
}
|
||||
return edx.StringUtils.interpolate(
|
||||
this.options.titleFormat, {
|
||||
title: displayName
|
||||
}
|
||||
);
|
||||
},
|
||||
|
||||
acceptChanges: function(event) {
|
||||
event.preventDefault();
|
||||
$.post(`/api/contentstore/v2/downstreams/${this.downstreamBlockId}/sync`).done(() => {
|
||||
this.hide();
|
||||
this.refreshFunction();
|
||||
}); // Note: if this POST request fails, Studio will display an error toast automatically.
|
||||
},
|
||||
|
||||
ignoreChanges: function(event) {
|
||||
event.preventDefault();
|
||||
ViewUtils.confirmThenRunOperation(
|
||||
gettext('Ignore these changes?'),
|
||||
gettext('Would you like to permanently ignore this updated version? If so, you won\'t be able to update this until a newer version is published (in the library).'),
|
||||
gettext('Ignore'),
|
||||
() => {
|
||||
$.ajax({
|
||||
type: 'DELETE',
|
||||
url: `/api/contentstore/v2/downstreams/${this.downstreamBlockId}/sync`,
|
||||
data: {},
|
||||
}).done(() => {
|
||||
this.hide();
|
||||
this.refreshFunction();
|
||||
}); // Note: if this DELETE request fails, Studio will display an error toast automatically.
|
||||
}
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
return PreviewLibraryChangesModal;
|
||||
});
|
||||
102
cms/static/js/views/modals/select_v2_library_content.js
Normal file
102
cms/static/js/views/modals/select_v2_library_content.js
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* Provides utilities to open and close the library content picker.
|
||||
* This is for adding multiple components to a Problem Bank (for randomization).
|
||||
*
|
||||
* Compare cms/static/js/views/components/add_library_content.js which uses
|
||||
* a single-select modal to add one component to a course (non-randomized).
|
||||
*/
|
||||
define(['jquery', 'underscore', 'gettext', 'js/views/modals/base_modal'],
|
||||
function($, _, gettext, BaseModal) {
|
||||
'use strict';
|
||||
|
||||
var SelectV2LibraryContent = BaseModal.extend({
|
||||
options: $.extend({}, BaseModal.prototype.options, {
|
||||
modalName: 'add-components-from-library',
|
||||
modalSize: 'lg',
|
||||
view: 'studio_view',
|
||||
viewSpecificClasses: 'modal-add-component-picker confirm',
|
||||
titleFormat: gettext('Add library content'),
|
||||
addPrimaryActionButton: false,
|
||||
}),
|
||||
|
||||
events: {
|
||||
'click .action-add': 'addSelectedComponents',
|
||||
'click .action-cancel': 'cancel',
|
||||
},
|
||||
|
||||
initialize: function() {
|
||||
BaseModal.prototype.initialize.call(this);
|
||||
this.selections = [];
|
||||
// Add event listen to close picker when the iframe tells us to
|
||||
const handleMessage = (event) => {
|
||||
if (event.data?.type === 'pickerSelectionChanged') {
|
||||
this.selections = event.data.selections;
|
||||
if (this.selections.length > 0) {
|
||||
this.enableActionButton('add');
|
||||
} else {
|
||||
this.disableActionButton('add');
|
||||
}
|
||||
}
|
||||
if (event.data?.type === 'addSelectedComponentsToBank') {
|
||||
this.selections = event.data.payload.selectedComponents;
|
||||
this.callback(this.selections);
|
||||
}
|
||||
};
|
||||
this.messageListener = window.addEventListener("message", handleMessage);
|
||||
this.cleanupListener = () => { window.removeEventListener("message", handleMessage) };
|
||||
},
|
||||
|
||||
hide: function() {
|
||||
BaseModal.prototype.hide.call(this);
|
||||
this.cleanupListener();
|
||||
},
|
||||
|
||||
/**
|
||||
* Adds the action buttons to the modal.
|
||||
*/
|
||||
addActionButtons: function() {
|
||||
this.addActionButton('add', gettext('Add selected components'), true);
|
||||
this.addActionButton('cancel', gettext('Cancel'));
|
||||
this.disableActionButton('add');
|
||||
},
|
||||
|
||||
/** Handler when the user clicks the "Add Selected Components" primary button */
|
||||
addSelectedComponents: function(event) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation(); // Make sure parent modals don't see the click
|
||||
}
|
||||
this.hide();
|
||||
this.callback(this.selections);
|
||||
},
|
||||
|
||||
/**
|
||||
* Show a component picker modal from library.
|
||||
* @param contentPickerUrl Url for component picker
|
||||
* @param callback A function to call with the selected block(s)
|
||||
* @param isIframeEmbed Boolean indicating if the unit is displayed inside an iframe
|
||||
*/
|
||||
showComponentPicker: function(contentPickerUrl, callback, isIframeEmbed) {
|
||||
this.contentPickerUrl = contentPickerUrl;
|
||||
this.callback = callback;
|
||||
if (isIframeEmbed) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'showMultipleComponentPicker',
|
||||
payload: {}
|
||||
}, document.referrer
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
this.render();
|
||||
this.show();
|
||||
},
|
||||
|
||||
getContentHtml: function() {
|
||||
return `<iframe src="${this.contentPickerUrl}" onload="this.contentWindow.focus()" frameborder="0" style="width: 100%; height: 100%;"/>`;
|
||||
},
|
||||
});
|
||||
|
||||
return SelectV2LibraryContent;
|
||||
});
|
||||
@@ -8,14 +8,16 @@ define(['jquery', 'underscore', 'backbone', 'gettext', 'js/views/pages/base_page
|
||||
'js/models/xblock_info', 'js/views/xblock_string_field_editor', 'js/views/xblock_access_editor',
|
||||
'js/views/pages/container_subviews', 'js/views/unit_outline', 'js/views/utils/xblock_utils',
|
||||
'common/js/components/views/feedback_notification', 'common/js/components/views/feedback_prompt',
|
||||
'js/views/utils/tagging_drawer_utils', 'js/utils/module',
|
||||
'js/views/utils/tagging_drawer_utils', 'js/utils/module', 'js/views/modals/preview_v2_library_changes',
|
||||
'js/views/modals/select_v2_library_content'
|
||||
],
|
||||
function($, _, Backbone, gettext, BasePage,
|
||||
ViewUtils, ContainerView, XBlockView,
|
||||
AddXBlockComponent, EditXBlockModal, MoveXBlockModal,
|
||||
XBlockInfo, XBlockStringFieldEditor, XBlockAccessEditor,
|
||||
ContainerSubviews, UnitOutlineView, XBlockUtils,
|
||||
NotificationView, PromptView, TaggingDrawerUtils, ModuleUtils) {
|
||||
NotificationView, PromptView, TaggingDrawerUtils, ModuleUtils,
|
||||
PreviewLibraryChangesModal, SelectV2LibraryContent) {
|
||||
'use strict';
|
||||
|
||||
var XBlockContainerPage = BasePage.extend({
|
||||
@@ -28,6 +30,8 @@ function($, _, Backbone, gettext, BasePage,
|
||||
'click .copy-button': 'copyXBlock',
|
||||
'click .move-button': 'showMoveXBlockModal',
|
||||
'click .delete-button': 'deleteXBlock',
|
||||
'click .library-sync-button': 'showXBlockLibraryChangesPreview',
|
||||
'click .problem-bank-v2-add-button': 'showSelectV2LibraryContent',
|
||||
'click .show-actions-menu-button': 'showXBlockActionsMenu',
|
||||
'click .new-component-button': 'scrollToNewComponentButtons',
|
||||
'click .save-button': 'saveSelectedLibraryComponents',
|
||||
@@ -124,6 +128,14 @@ function($, _, Backbone, gettext, BasePage,
|
||||
|
||||
}
|
||||
|
||||
if (this.options.isIframeEmbed) {
|
||||
window.addEventListener('message', (event) => {
|
||||
if (event.data && event.data.type === 'refreshXBlock') {
|
||||
this.render();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.listenTo(Backbone, 'move:onXBlockMoved', this.onXBlockMoved);
|
||||
},
|
||||
|
||||
@@ -207,12 +219,13 @@ function($, _, Backbone, gettext, BasePage,
|
||||
|
||||
renderAddXBlockComponents: function() {
|
||||
var self = this;
|
||||
if (self.options.canEdit) {
|
||||
if (self.options.canEdit && !self.options.isIframeEmbed) {
|
||||
this.$('.add-xblock-component').each(function(index, element) {
|
||||
var component = new AddXBlockComponent({
|
||||
el: element,
|
||||
createComponent: _.bind(self.createComponent, self),
|
||||
collection: self.options.templates
|
||||
collection: self.options.templates,
|
||||
libraryContentPickerUrl: self.options.libraryContentPickerUrl,
|
||||
});
|
||||
component.render();
|
||||
});
|
||||
@@ -222,7 +235,7 @@ function($, _, Backbone, gettext, BasePage,
|
||||
},
|
||||
|
||||
initializePasteButton() {
|
||||
if (this.options.canEdit) {
|
||||
if (this.options.canEdit && !this.options.isIframeEmbed) {
|
||||
// We should have the user's clipboard status.
|
||||
const data = this.options.clipboardData;
|
||||
this.refreshPasteButton(data);
|
||||
@@ -239,7 +252,7 @@ function($, _, Backbone, gettext, BasePage,
|
||||
refreshPasteButton(data) {
|
||||
// Do not perform any changes on paste button since they are not
|
||||
// rendered on Library or LibraryContent pages
|
||||
if (!this.isLibraryPage && !this.isLibraryContentPage) {
|
||||
if (!this.isLibraryPage && !this.isLibraryContentPage && !this.options.isIframeEmbed) {
|
||||
// 'data' is the same data returned by the "get clipboard status" API endpoint
|
||||
// i.e. /api/content-staging/v1/clipboard/
|
||||
if (this.options.canEdit && data.content) {
|
||||
@@ -252,6 +265,7 @@ function($, _, Backbone, gettext, BasePage,
|
||||
} else {
|
||||
// The thing in the clipboard can be pasted into this unit:
|
||||
const detailsPopupEl = this.$(".clipboard-details-popup")[0];
|
||||
if (!detailsPopupEl) return; // This happens on the Problem Bank container page - no paste button is there anyways
|
||||
detailsPopupEl.querySelector(".detail-block-name").innerText = data.content.display_name;
|
||||
detailsPopupEl.querySelector(".detail-block-type").innerText = data.content.block_type_display;
|
||||
detailsPopupEl.querySelector(".detail-course-name").innerText = data.source_context_title;
|
||||
@@ -273,6 +287,18 @@ function($, _, Backbone, gettext, BasePage,
|
||||
/** The user has clicked on the "Paste Component button" */
|
||||
pasteComponent(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (this.options.isIframeEmbed) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'pasteComponent',
|
||||
payload: {}
|
||||
}, document.referrer
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
// Get the ID of the container (usually a unit/vertical) that we're pasting into:
|
||||
const parentElement = this.findXBlockElement(event.target);
|
||||
const parentLocator = parentElement.data('locator');
|
||||
@@ -365,6 +391,18 @@ function($, _, Backbone, gettext, BasePage,
|
||||
|
||||
editXBlock: function(event, options) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (this.options.isIframeEmbed) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'editXBlock',
|
||||
payload: {}
|
||||
}, document.referrer
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
if (!options || options.view !== 'visibility_view') {
|
||||
const primaryHeader = $(event.target).closest('.xblock-header-primary, .nav-actions');
|
||||
@@ -378,7 +416,13 @@ function($, _, Backbone, gettext, BasePage,
|
||||
|| (useNewVideoEditor === 'True' && blockType === 'video')
|
||||
|| (useNewProblemEditor === 'True' && blockType === 'problem')
|
||||
) {
|
||||
var destinationUrl = primaryHeader.attr('authoring_MFE_base_url') + '/' + blockType + '/' + encodeURI(primaryHeader.attr('data-usage-id'));
|
||||
var destinationUrl = primaryHeader.attr('authoring_MFE_base_url')
|
||||
+ '/' + blockType
|
||||
+ '/' + encodeURI(primaryHeader.attr('data-usage-id'));
|
||||
var upstreamRef = primaryHeader.attr('data-upstream-ref');
|
||||
if(upstreamRef) {
|
||||
destinationUrl += '?upstreamLibRef=' + upstreamRef;
|
||||
}
|
||||
window.location.href = destinationUrl;
|
||||
return;
|
||||
}
|
||||
@@ -396,6 +440,93 @@ function($, _, Backbone, gettext, BasePage,
|
||||
});
|
||||
},
|
||||
|
||||
/** Show the modal for previewing changes before syncing a library-sourced XBlock. */
|
||||
showXBlockLibraryChangesPreview: function(event, options) {
|
||||
const xblockElement = this.findXBlockElement(event.target);
|
||||
const self = this;
|
||||
const xblockInfo = XBlockUtils.findXBlockInfo(xblockElement, this.model);
|
||||
const courseAuthoringMfeUrl = this.model.attributes.course_authoring_url;
|
||||
const headerElement = xblockElement.find('.xblock-header-primary');
|
||||
const upstreamBlockId = headerElement.data('upstream-ref');
|
||||
const upstreamBlockVersionSynced = headerElement.data('version-synced');
|
||||
|
||||
try {
|
||||
if (this.options.isIframeEmbed) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'showXBlockLibraryChangesPreview',
|
||||
payload: {
|
||||
downstreamBlockId: xblockInfo.get('id'),
|
||||
displayName: xblockInfo.get('display_name'),
|
||||
isVertical: xblockInfo.isVertical(),
|
||||
upstreamBlockId,
|
||||
upstreamBlockVersionSynced,
|
||||
}
|
||||
}, document.referrer
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
var modal = new PreviewLibraryChangesModal(options);
|
||||
modal.showPreviewFor(
|
||||
xblockInfo,
|
||||
courseAuthoringMfeUrl,
|
||||
upstreamBlockId,
|
||||
upstreamBlockVersionSynced,
|
||||
function() { self.refreshXBlock(xblockElement, false); }
|
||||
);
|
||||
},
|
||||
|
||||
/** Show the multi-select library content picker, for adding to a Problem Bank (itembank) Component */
|
||||
showSelectV2LibraryContent: function(event, options) {
|
||||
event.preventDefault();
|
||||
|
||||
const xblockElement = this.findXBlockElement(event.target);
|
||||
const modal = new SelectV2LibraryContent(options);
|
||||
const courseAuthoringMfeUrl = this.model.attributes.course_authoring_url;
|
||||
const itemBankBlockId = xblockElement.data("locator");
|
||||
const pickerUrl = courseAuthoringMfeUrl + '/component-picker/multiple?variant=published';
|
||||
|
||||
modal.showComponentPicker(pickerUrl, (selectedBlocks) => {
|
||||
// selectedBlocks has type: {usageKey: string, blockType: string}[]
|
||||
let doneAddingAllBlocks = () => { this.refreshXBlock(xblockElement, false); };
|
||||
let doneAddingBlock = () => {};
|
||||
if (this.model.id === itemBankBlockId) {
|
||||
// We're on the detailed view, showing all the components inside the problem bank.
|
||||
// Create a placeholder that will become the new block(s)
|
||||
const $insertSpot = xblockElement.find('.insert-new-lib-blocks-here');
|
||||
doneAddingBlock = (addResult) => {
|
||||
const $placeholderEl = $(this.createPlaceholderElement());
|
||||
const placeholderElement = $placeholderEl.insertBefore($insertSpot);
|
||||
placeholderElement.data('locator', addResult.locator);
|
||||
return this.refreshXBlock(placeholderElement, true);
|
||||
};
|
||||
doneAddingAllBlocks = () => {};
|
||||
}
|
||||
// Note: adding all the XBlocks in parallel will cause a race condition 😢 so we have to add
|
||||
// them one at a time:
|
||||
let lastAdded = $.when();
|
||||
for (const { usageKey, blockType } of selectedBlocks) {
|
||||
const addData = {
|
||||
library_content_key: usageKey,
|
||||
category: blockType,
|
||||
parent_locator: itemBankBlockId,
|
||||
};
|
||||
lastAdded = lastAdded.then(() => (
|
||||
$.postJSON(this.getURLRoot() + '/', addData, doneAddingBlock)
|
||||
));
|
||||
}
|
||||
// Now we actually add the block:
|
||||
ViewUtils.runOperationShowingMessage(gettext('Adding'), () => {
|
||||
return lastAdded.done(() => { doneAddingAllBlocks() });
|
||||
});
|
||||
}, this.options.isIframeEmbed);
|
||||
},
|
||||
|
||||
/**
|
||||
* If the new "Actions" menu is enabled, most XBlock actions like
|
||||
* Duplicate, Move, Delete, Manage Access, etc. are moved into this
|
||||
@@ -432,66 +563,43 @@ function($, _, Backbone, gettext, BasePage,
|
||||
});
|
||||
},
|
||||
|
||||
duplicateXBlock: function(event) {
|
||||
event.preventDefault();
|
||||
this.duplicateComponent(this.findXBlockElement(event.target));
|
||||
},
|
||||
|
||||
openManageTags: function(event) {
|
||||
try {
|
||||
if (this.options.isIframeEmbed) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'openManageTags',
|
||||
payload: {}
|
||||
}, document.referrer
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
const taxonomyTagsWidgetUrl = this.model.get('taxonomy_tags_widget_url');
|
||||
const contentId = this.findXBlockElement(event.target).data('locator');
|
||||
|
||||
TaggingDrawerUtils.openDrawer(taxonomyTagsWidgetUrl, contentId);
|
||||
},
|
||||
|
||||
showMoveXBlockModal: function(event) {
|
||||
var xblockElement = this.findXBlockElement(event.target),
|
||||
parentXBlockElement = xblockElement.parents('.studio-xblock-wrapper'),
|
||||
modal = new MoveXBlockModal({
|
||||
sourceXBlockInfo: XBlockUtils.findXBlockInfo(xblockElement, this.model),
|
||||
sourceParentXBlockInfo: XBlockUtils.findXBlockInfo(parentXBlockElement, this.model),
|
||||
XBlockURLRoot: this.getURLRoot(),
|
||||
outlineURL: this.options.outlineURL
|
||||
});
|
||||
|
||||
event.preventDefault();
|
||||
modal.show();
|
||||
},
|
||||
|
||||
deleteXBlock: function(event) {
|
||||
event.preventDefault();
|
||||
this.deleteComponent(this.findXBlockElement(event.target));
|
||||
},
|
||||
|
||||
createPlaceholderElement: function() {
|
||||
return $('<div/>', {class: 'studio-xblock-wrapper'});
|
||||
},
|
||||
|
||||
createComponent: function(template, target) {
|
||||
// A placeholder element is created in the correct location for the new xblock
|
||||
// and then onNewXBlock will replace it with a rendering of the xblock. Note that
|
||||
// for xblocks that can't be replaced inline, the entire parent will be refreshed.
|
||||
var parentElement = this.findXBlockElement(target),
|
||||
parentLocator = parentElement.data('locator'),
|
||||
buttonPanel = target.closest('.add-xblock-component'),
|
||||
listPanel = buttonPanel.prev(),
|
||||
scrollOffset = ViewUtils.getScrollOffset(buttonPanel),
|
||||
$placeholderEl = $(this.createPlaceholderElement()),
|
||||
requestData = _.extend(template, {
|
||||
parent_locator: parentLocator
|
||||
}),
|
||||
placeholderElement;
|
||||
placeholderElement = $placeholderEl.appendTo(listPanel);
|
||||
return $.postJSON(this.getURLRoot() + '/', requestData,
|
||||
_.bind(this.onNewXBlock, this, placeholderElement, scrollOffset, false))
|
||||
.fail(function() {
|
||||
// Remove the placeholder if the update failed
|
||||
placeholderElement.remove();
|
||||
});
|
||||
},
|
||||
|
||||
copyXBlock: function(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (this.options.isIframeEmbed) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'copyXBlock',
|
||||
payload: {}
|
||||
}, document.referrer
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
const clipboardEndpoint = "/api/content-staging/v1/clipboard/";
|
||||
const element = this.findXBlockElement(event.target);
|
||||
const usageKeyToCopy = element.data('locator');
|
||||
@@ -535,41 +643,58 @@ function($, _, Backbone, gettext, BasePage,
|
||||
});
|
||||
},
|
||||
|
||||
duplicateComponent: function(xblockElement) {
|
||||
// A placeholder element is created in the correct location for the duplicate xblock
|
||||
// and then onNewXBlock will replace it with a rendering of the xblock. Note that
|
||||
// for xblocks that can't be replaced inline, the entire parent will be refreshed.
|
||||
var self = this,
|
||||
parentElement = self.findXBlockElement(xblockElement.parent()),
|
||||
scrollOffset = ViewUtils.getScrollOffset(xblockElement),
|
||||
$placeholderEl = $(self.createPlaceholderElement()),
|
||||
placeholderElement;
|
||||
|
||||
placeholderElement = $placeholderEl.insertAfter(xblockElement);
|
||||
XBlockUtils.duplicateXBlock(xblockElement, parentElement)
|
||||
.done(function(data) {
|
||||
self.onNewXBlock(placeholderElement, scrollOffset, true, data);
|
||||
})
|
||||
.fail(function() {
|
||||
// Remove the placeholder if the update failed
|
||||
placeholderElement.remove();
|
||||
});
|
||||
},
|
||||
|
||||
duplicateXBlock: function(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (this.options.isIframeEmbed) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'duplicateXBlock',
|
||||
payload: {}
|
||||
}, document.referrer
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
this.duplicateComponent(this.findXBlockElement(event.target));
|
||||
},
|
||||
|
||||
showMoveXBlockModal: function(event) {
|
||||
var xblockElement = this.findXBlockElement(event.target),
|
||||
parentXBlockElement = xblockElement.parents('.studio-xblock-wrapper'),
|
||||
sourceXBlockInfo = XBlockUtils.findXBlockInfo(xblockElement, this.model),
|
||||
sourceParentXBlockInfo = XBlockUtils.findXBlockInfo(parentXBlockElement, this.model),
|
||||
modal = new MoveXBlockModal({
|
||||
sourceXBlockInfo: XBlockUtils.findXBlockInfo(xblockElement, this.model),
|
||||
sourceParentXBlockInfo: XBlockUtils.findXBlockInfo(parentXBlockElement, this.model),
|
||||
XBlockURLRoot: this.getURLRoot(),
|
||||
outlineURL: this.options.outlineURL
|
||||
});
|
||||
sourceXBlockInfo: sourceXBlockInfo,
|
||||
sourceParentXBlockInfo: sourceParentXBlockInfo,
|
||||
XBlockURLRoot: this.getURLRoot(),
|
||||
outlineURL: this.options.outlineURL
|
||||
});
|
||||
|
||||
try {
|
||||
if (this.options.isIframeEmbed) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'showMoveXBlockModal',
|
||||
payload: {
|
||||
sourceXBlockInfo: {
|
||||
id: sourceXBlockInfo.attributes.id,
|
||||
displayName: sourceXBlockInfo.attributes.display_name,
|
||||
},
|
||||
sourceParentXBlockInfo: {
|
||||
id: sourceParentXBlockInfo.attributes.id,
|
||||
category: sourceParentXBlockInfo.attributes.category,
|
||||
hasChildren: sourceParentXBlockInfo.attributes.has_children,
|
||||
},
|
||||
},
|
||||
}, document.referrer
|
||||
);
|
||||
return true;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
modal.show();
|
||||
@@ -577,6 +702,18 @@ function($, _, Backbone, gettext, BasePage,
|
||||
|
||||
deleteXBlock: function(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (this.options.isIframeEmbed) {
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'deleteXBlock',
|
||||
payload: {}
|
||||
}, document.referrer
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
this.deleteComponent(this.findXBlockElement(event.target));
|
||||
},
|
||||
|
||||
@@ -725,9 +862,10 @@ function($, _, Backbone, gettext, BasePage,
|
||||
var matchBlockTypeFromLocator = /\@(.*?)\+/;
|
||||
var blockType = data.locator.match(matchBlockTypeFromLocator);
|
||||
}
|
||||
if((useNewTextEditor === 'True' && blockType.includes('html'))
|
||||
// open mfe editors for new blocks only and not for content imported from libraries
|
||||
if(!data.hasOwnProperty('upstreamRef') && ((useNewTextEditor === 'True' && blockType.includes('html'))
|
||||
|| (useNewVideoEditor === 'True' && blockType.includes('video'))
|
||||
|| (useNewProblemEditor === 'True' && blockType.includes('problem'))
|
||||
|| (useNewProblemEditor === 'True' && blockType.includes('problem')))
|
||||
){
|
||||
var destinationUrl;
|
||||
if (useVideoGalleryFlow === "True" && blockType.includes("video")) {
|
||||
|
||||
@@ -506,9 +506,13 @@ function($, _, gettext, BaseView, ViewUtils, XBlockViewUtils, MoveXBlockUtils, H
|
||||
},
|
||||
|
||||
renderTagElements: function(tags, depth, parentId) {
|
||||
/* This function displays the tags in the sidebar of the legacy Unit Outline Page.
|
||||
* It is not used when the Authoring MFE iframes a component in the Unit Outline. */
|
||||
const parentElement = document.querySelector(`.content-tags-${parentId}`);
|
||||
if (!parentElement) return;
|
||||
|
||||
const tagListElement = this;
|
||||
tags.forEach(function(tag) {
|
||||
const parentElement = document.querySelector(`.content-tags-${parentId}`);
|
||||
var tagContentElement = document.createElement('div'),
|
||||
tagValueElement = document.createElement('span');
|
||||
|
||||
|
||||
@@ -388,9 +388,6 @@ function(ValidatingView, CodeMirror, _, $, ui, DateUtils, FileUploadModel,
|
||||
Hides and clears the certificate available date field if a display behavior that doesn't use it is
|
||||
chosen. Because we are clearing it, toggling back to "end_with_date" will require re-entering the date
|
||||
*/
|
||||
if (!this.useV2CertDisplaySettings) {
|
||||
return;
|
||||
}
|
||||
// eslint-disable-next-line prefer-const
|
||||
let showDatepicker = this.model.get('certificates_display_behavior') == 'end_with_date';
|
||||
// eslint-disable-next-line prefer-const
|
||||
|
||||
@@ -77,7 +77,6 @@
|
||||
@import 'views/group-configuration';
|
||||
@import 'views/video-upload';
|
||||
@import 'views/certificates';
|
||||
@import 'views/maintenance';
|
||||
|
||||
// +Base - Contexts
|
||||
// ====================
|
||||
|
||||
@@ -66,3 +66,17 @@
|
||||
height: ($baseline*3);
|
||||
background: url('#{$static-path}/images/large-library-icon.png') center no-repeat;
|
||||
}
|
||||
|
||||
.large-library_v2-icon {
|
||||
display: inline-block;
|
||||
width: ($baseline*3);
|
||||
height: ($baseline*3);
|
||||
background: url('#{$static-path}/images/large-library_v2-icon.png') center no-repeat;
|
||||
}
|
||||
|
||||
.large-itembank-icon {
|
||||
display: inline-block;
|
||||
width: ($baseline*3);
|
||||
height: ($baseline*3);
|
||||
background: url('#{$static-path}/images/large-itembank-icon.png') center no-repeat;
|
||||
}
|
||||
|
||||
658
cms/static/sass/course-unit-mfe-iframe-bundle.scss
Normal file
658
cms/static/sass/course-unit-mfe-iframe-bundle.scss
Normal file
@@ -0,0 +1,658 @@
|
||||
@import 'cms/theme/variables-v1';
|
||||
@import 'elements/course-unit-mfe-iframe';
|
||||
|
||||
.wrapper {
|
||||
.wrapper-xblock {
|
||||
background-color: $transparent;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
border-color: none;
|
||||
}
|
||||
|
||||
.xblock-header-primary {
|
||||
padding: ($baseline * 1.2) ($baseline * 1.2) ($baseline / 1.67);
|
||||
border-bottom: none;
|
||||
|
||||
.header-details .xblock-display-name {
|
||||
font-size: 22px;
|
||||
line-height: 28px;
|
||||
font-weight: 700;
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
|
||||
&.level-element {
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, .15), 0 2px 8px rgba(0, 0, 0, .15);
|
||||
margin: 0 0 ($baseline * 1.4) 0;
|
||||
}
|
||||
|
||||
&.level-element .xblock-header-primary {
|
||||
background-color: $white;
|
||||
}
|
||||
|
||||
&.level-element .xblock-render {
|
||||
background: $white;
|
||||
margin: 0;
|
||||
padding: $baseline;
|
||||
border-bottom-left-radius: 6px;
|
||||
border-bottom-right-radius: 6px;
|
||||
}
|
||||
|
||||
.wrapper-xblock .header-actions .actions-list .action-item .action-button {
|
||||
@extend %button-styles;
|
||||
|
||||
color: $primary;
|
||||
|
||||
.fa-ellipsis-v {
|
||||
font-size: $base-font-size;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $primary;
|
||||
color: $white;
|
||||
border-color: $transparent;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px $transparent;
|
||||
background-color: $transparent;
|
||||
box-shadow: inset 0 0 0 2px $primary;
|
||||
color: $primary;
|
||||
border-color: $transparent;
|
||||
}
|
||||
}
|
||||
|
||||
.xblock-header-primary .header-actions .nav-dd .nav-sub {
|
||||
border: 1px solid rgba(0, 0, 0, .15);
|
||||
border-radius: 6px;
|
||||
padding: ($baseline / 2) 0;
|
||||
min-width: 288px;
|
||||
right: 90px;
|
||||
|
||||
.nav-item {
|
||||
border-bottom: none;
|
||||
color: $dark;
|
||||
font-size: $base-font-size;
|
||||
|
||||
a {
|
||||
padding: ($baseline / 2) ($baseline / 1.25);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: #f2f0ef;
|
||||
color: $dark;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: $dark;
|
||||
}
|
||||
|
||||
a:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
background-color: $primary;
|
||||
|
||||
a {
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.wrapper-modal-window .modal-window .modal-actions a {
|
||||
color: $text-color;
|
||||
background-color: $transparent;
|
||||
border-color: $transparent;
|
||||
box-shadow: none;
|
||||
font-weight: 500;
|
||||
border: 1px solid $transparent;
|
||||
padding: ($baseline / 2) ($baseline / 1.25);
|
||||
font-size: $base-font-size;
|
||||
line-height: 20px;
|
||||
border-radius: $btn-border-radius;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
background-image: none;
|
||||
display: block;
|
||||
|
||||
&:hover {
|
||||
background-color: $light-background-color;
|
||||
border-color: $transparent;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: $transparent;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-window.modal-editor {
|
||||
background-color: $white;
|
||||
border-radius: 6px;
|
||||
|
||||
a {
|
||||
color: #00688d;
|
||||
}
|
||||
|
||||
select {
|
||||
&:focus {
|
||||
background-color: $white;
|
||||
color: $text-color;
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 1px $primary;
|
||||
}
|
||||
}
|
||||
|
||||
input:not([type="radio"], [type="checkbox"], [type="submit"]) {
|
||||
@extend %input-styles;
|
||||
|
||||
line-height: 20px;
|
||||
height: 44px;
|
||||
|
||||
&:hover {
|
||||
border: solid 1px #002121;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: $text-color;
|
||||
background-color: $white;
|
||||
border-color: $primary;
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 1px $primary;
|
||||
}
|
||||
}
|
||||
|
||||
#student_training_settings_editor .openassessment_training_example .openassessment_training_example_body
|
||||
.openassessment_training_example_essay_wrapper textarea {
|
||||
@extend %input-styles;
|
||||
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
textarea {
|
||||
@extend %input-styles;
|
||||
|
||||
box-shadow: none;
|
||||
|
||||
&#poll-question-editor,
|
||||
&#poll-feedback-editor {
|
||||
box-shadow: none;
|
||||
|
||||
&:focus {
|
||||
color: $text-color;
|
||||
background-color: $white;
|
||||
border-color: $primary;
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 1px $primary;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
border: solid 1px #002121;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
color: $text-color;
|
||||
background-color: $white;
|
||||
border-color: $primary;
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 1px $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.tip.setting-help {
|
||||
color: $border-color;
|
||||
font-size: 14px;
|
||||
line-height: $base-font-size;
|
||||
}
|
||||
|
||||
label,
|
||||
.label.setting-label {
|
||||
font-size: $base-font-size;
|
||||
line-height: 28px;
|
||||
color: $text-color;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.title.modal-window-title {
|
||||
color: $black;
|
||||
font-weight: 700;
|
||||
font-size: 22px;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.xblock-actions {
|
||||
background-color: $white;
|
||||
|
||||
.action-button {
|
||||
background-color: $transparent;
|
||||
border: 1px solid $transparent;
|
||||
padding: ($baseline / 2.22) ($baseline / 1.25);
|
||||
border-radius: $input-border-radius;
|
||||
font-weight: 400;
|
||||
color: #00688d;
|
||||
font-size: $base-font-size;
|
||||
line-height: 20px;
|
||||
cursor: pointer;
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.openassessment_save_button,
|
||||
.save-button,
|
||||
.continue-button {
|
||||
color: $white;
|
||||
background-color: $primary;
|
||||
border-color: $primary;
|
||||
box-shadow: none;
|
||||
font-weight: 500;
|
||||
border: 1px solid $transparent;
|
||||
padding: ($baseline / 2) ($baseline / 1.25);
|
||||
font-size: $base-font-size;
|
||||
line-height: 20px;
|
||||
border-radius: $btn-border-radius;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
background-image: none;
|
||||
height: auto;
|
||||
display: block;
|
||||
|
||||
&:hover {
|
||||
background: darken($primary, 5%);
|
||||
border-color: $transparent;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: $primary;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
.openassessment_cancel_button,
|
||||
.cancel-button {
|
||||
color: $text-color;
|
||||
background-color: $transparent;
|
||||
border-color: $transparent;
|
||||
box-shadow: none;
|
||||
font-weight: 500;
|
||||
border: 1px solid $transparent;
|
||||
padding: ($baseline / 2) ($baseline / 1.25);
|
||||
font-size: $base-font-size;
|
||||
line-height: 20px;
|
||||
border-radius: $btn-border-radius;
|
||||
text-align: center;
|
||||
vertical-align: middle;
|
||||
user-select: none;
|
||||
background-image: none;
|
||||
display: block;
|
||||
|
||||
&:hover {
|
||||
background-color: $light-background-color;
|
||||
border-color: $transparent;
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: $transparent;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-lg.modal-window.confirm.openassessment_modal_window {
|
||||
height: 635px;
|
||||
}
|
||||
}
|
||||
|
||||
.view-container .content-primary {
|
||||
background-color: $transparent;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.wrapper-content.wrapper {
|
||||
padding: $baseline / 4;
|
||||
}
|
||||
|
||||
.btn-default.action-edit.title-edit-button {
|
||||
@extend %button-styles;
|
||||
|
||||
position: relative;
|
||||
top: 7px;
|
||||
|
||||
.fa-pencil {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
@extend %icon-position;
|
||||
|
||||
content: '';
|
||||
position: absolute;
|
||||
background-color: $black;
|
||||
mask: url('#{$static-path}/images/pencil-icon.svg') center no-repeat;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $primary;
|
||||
border-color: $transparent;
|
||||
|
||||
&::before {
|
||||
background-color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px $transparent;
|
||||
background-color: $transparent;
|
||||
box-shadow: inset 0 0 0 2px $primary;
|
||||
border-color: $transparent;
|
||||
|
||||
&:hover {
|
||||
color: $white;
|
||||
border-color: $transparent;
|
||||
|
||||
&::before {
|
||||
background-color: $primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.library-sync-button {
|
||||
.action-button-text {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.action-edit {
|
||||
.action-button-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.edit-button.action-button {
|
||||
@extend %button-styles;
|
||||
|
||||
position: relative;
|
||||
|
||||
.fa-pencil {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&::before {
|
||||
@extend %icon-position;
|
||||
|
||||
content: '';
|
||||
position: absolute;
|
||||
background-color: $black;
|
||||
mask: url('#{$static-path}/images/pencil-icon.svg') center no-repeat;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background-color: $primary;
|
||||
border-color: $transparent;
|
||||
|
||||
&::before {
|
||||
background-color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px $transparent;
|
||||
background-color: $transparent;
|
||||
box-shadow: inset 0 0 0 2px $primary;
|
||||
border-color: $transparent;
|
||||
|
||||
&:hover {
|
||||
color: $white;
|
||||
border-color: $transparent;
|
||||
|
||||
&::before {
|
||||
background-color: $primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.nav-dd.ui-right .nav-sub {
|
||||
&::before,
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[class*="view-"] .modal-lg.modal-editor .modal-header .editor-modes .action-item {
|
||||
.editor-button,
|
||||
.settings-button {
|
||||
@extend %light-button;
|
||||
}
|
||||
}
|
||||
|
||||
[class*="view-"] .wrapper.wrapper-modal-window .modal-window .modal-actions .action-primary {
|
||||
@extend %primary-button;
|
||||
}
|
||||
|
||||
.wrapper-comp-settings {
|
||||
.list-input.settings-list {
|
||||
.metadata-list-enum .create-setting {
|
||||
@extend %modal-actions-button;
|
||||
|
||||
background-color: $primary;
|
||||
color: $white;
|
||||
border: 1px solid $primary;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: darken($primary, 5%);
|
||||
border-color: #2d494e;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: $primary;
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-input.settings-list {
|
||||
.field.comp-setting-entry.is-set .setting-input {
|
||||
color: $text-color;
|
||||
}
|
||||
|
||||
select {
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $input-border-radius;
|
||||
color: $text-color;
|
||||
height: 44px;
|
||||
|
||||
&:focus {
|
||||
background-color: $white;
|
||||
color: $text-color;
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 1px $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: $base-font-size;
|
||||
line-height: 28px;
|
||||
color: $text-color;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
input[type="number"] {
|
||||
width: 45%;
|
||||
}
|
||||
|
||||
.action.setting-clear {
|
||||
@extend %button-styles;
|
||||
|
||||
background-color: $transparent;
|
||||
color: $primary;
|
||||
border: none;
|
||||
|
||||
&:hover {
|
||||
background-color: $primary;
|
||||
color: $white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 2px $transparent;
|
||||
background-color: $transparent;
|
||||
box-shadow: inset 0 0 0 2px $primary;
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.list-input.settings-list .metadata-list-enum .remove-setting .fa-times-circle {
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
select {
|
||||
border: 1px solid $border-color;
|
||||
border-radius: $input-border-radius;
|
||||
color: $text-color;
|
||||
height: 44px;
|
||||
padding: ($baseline / 2) ($baseline / 1.25);
|
||||
|
||||
&:focus {
|
||||
background-color: $white;
|
||||
color: $text-color;
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 1px $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.xblock {
|
||||
.xblock--drag-and-drop--editor .tab {
|
||||
background-color: $white;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
#openassessment-editor #oa_rubric_editor_wrapper {
|
||||
.openassessment_criterion .openassessment_criterion_basic_editor .comp-setting-entry .wrapper-comp-settings label input,
|
||||
.openassessment_criterion_option .openassessment_criterion_option_name_wrapper label,
|
||||
.openassessment_criterion_option .openassessment_criterion_option_explanation_wrapper label,
|
||||
.openassessment_criterion .openassessment_criterion_feedback_wrapper label,
|
||||
#openassessment_rubric_feedback_wrapper label,
|
||||
.openassessment_criterion_option .openassessment_criterion_option_point_wrapper label,
|
||||
.openassessment_criterion_option .openassessment_criterion_option_name_wrapper label input,
|
||||
.openassessment_criterion .openassessment_criterion_basic_editor .comp-setting-entry .wrapper-comp-settings .openassessment_criterion_prompt,
|
||||
#openassessment_rubric_feedback_wrapper textarea,
|
||||
.openassessment_criterion_option
|
||||
.openassessment_criterion_option_explanation_wrapper label textarea,
|
||||
input[type=number] {
|
||||
font-size: $base-font-size;
|
||||
background-color: $white;
|
||||
background-image: none;
|
||||
}
|
||||
}
|
||||
|
||||
#openassessment-editor {
|
||||
#openassessment_editor_header .editor_tabs .oa_editor_tab {
|
||||
@extend %light-button;
|
||||
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
#openassessment_editor_header,
|
||||
.openassessment_tab_instructions {
|
||||
background-color: $white;
|
||||
}
|
||||
|
||||
#oa_schedule_editor_wrapper #dates_config_new_badge {
|
||||
background-color: $primary;
|
||||
}
|
||||
|
||||
.openassessment_description {
|
||||
font-size: 14px;
|
||||
line-height: $base-font-size;
|
||||
}
|
||||
|
||||
#oa_rubric_editor_wrapper .openassessment_criterion
|
||||
.openassessment_criterion_basic_editor .comp-setting-entry
|
||||
.wrapper-comp-settings label {
|
||||
font-size: $base-font-size;
|
||||
line-height: 28px;
|
||||
color: $text-color;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#oa_rubric_editor_wrapper .openassessment_criterion_option
|
||||
.openassessment_criterion_option_point_wrapper label input {
|
||||
min-width: 70px;
|
||||
font-size: 18px;
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.openassessment_assessment_module_settings_editor:hover {
|
||||
border-color: $primary;
|
||||
|
||||
.drag-handle {
|
||||
background-color: $primary;
|
||||
border-color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.setting-help,
|
||||
.ti.wrapper-comp-settings .list-input.settings-list .setting-help {
|
||||
color: $border-color;
|
||||
font-size: 14px;
|
||||
line-height: $base-font-size;
|
||||
}
|
||||
}
|
||||
|
||||
.xblock--drag-and-drop--editor .btn,
|
||||
#openassessment-editor .openassessment_container_add_button,
|
||||
#openassessment-editor #oa_rubric_editor_wrapper .openassessment_criterion .openassessment_criterion_add_option,
|
||||
#student_training_settings_editor .openassessment_add_training_example {
|
||||
@extend %primary-button;
|
||||
}
|
||||
}
|
||||
|
||||
.xblock-header-primary {
|
||||
position: relative;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 83px;
|
||||
left: 25px;
|
||||
width: 95%;
|
||||
height: 1px;
|
||||
background-color: #eae6e5;
|
||||
}
|
||||
}
|
||||
|
||||
.ui-loading {
|
||||
background-color: #f8f7f6;
|
||||
box-shadow: none;
|
||||
|
||||
.spin,
|
||||
.copy {
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper-comp-setting.metadata-list-enum .action.setting-clear.active {
|
||||
margin-top: 0;
|
||||
}
|
||||
65
cms/static/sass/elements/_course-unit-mfe-iframe.scss
Normal file
65
cms/static/sass/elements/_course-unit-mfe-iframe.scss
Normal file
@@ -0,0 +1,65 @@
|
||||
%button-styles {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
%input-styles {
|
||||
font-size: $base-font-size;
|
||||
color: $text-color !important;
|
||||
background-color: $white;
|
||||
border: 1px solid $border-color !important;
|
||||
border-radius: $input-border-radius !important;
|
||||
padding: 10px 16px !important;
|
||||
background-image: none;
|
||||
transition: border-color .15s ease-in-out, box-shadow .15s ease-in-out;
|
||||
}
|
||||
|
||||
%modal-actions-button {
|
||||
font-size: $base-font-size;
|
||||
line-height: 20px;
|
||||
padding: 10px 16px;
|
||||
border-radius: $btn-border-radius;
|
||||
}
|
||||
|
||||
%primary-button {
|
||||
@extend %modal-actions-button;
|
||||
|
||||
background-color: $primary;
|
||||
color: $white;
|
||||
border: 1px solid $primary;
|
||||
cursor: pointer;
|
||||
background-image: none;
|
||||
display: block;
|
||||
|
||||
&:hover {
|
||||
background: darken($primary, 5%);
|
||||
border-color: #2d494e;
|
||||
box-shadow: none;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background: $primary;
|
||||
outline: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
%light-button {
|
||||
@extend %modal-actions-button;
|
||||
|
||||
color: $black;
|
||||
background-color: $light-background-color;
|
||||
border-color: $light-background-color;
|
||||
box-shadow: none;
|
||||
border: 1px solid $transparent;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
%icon-position {
|
||||
top: 11px;
|
||||
left: 11px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
// studio - elements - side drawers
|
||||
// ====================
|
||||
|
||||
.drawer-cover {
|
||||
.drawer-cover,
|
||||
.picker-cover {
|
||||
@extend %ui-depth3;
|
||||
|
||||
display: none;
|
||||
@@ -13,7 +14,8 @@
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.drawer-cover.gray-cover {
|
||||
.drawer-cover.gray-cover,
|
||||
.picker-cover.gray-cover {
|
||||
background: rgba(112, 112, 112, 0.8);
|
||||
}
|
||||
|
||||
@@ -29,6 +31,20 @@
|
||||
background-color: $gray-l4;
|
||||
}
|
||||
|
||||
body.drawer-open {
|
||||
body.drawer-open,
|
||||
body.picker-open {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.picker {
|
||||
@extend %ui-depth4;
|
||||
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 1em;
|
||||
left: 1em;
|
||||
right: 1em;
|
||||
bottom: 1em;
|
||||
background-color: $gray-l4;
|
||||
}
|
||||
|
||||
|
||||
@@ -140,12 +140,13 @@
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: ($baseline*6.25);
|
||||
height: ($baseline*5);
|
||||
height: ($baseline*7);
|
||||
margin-bottom: ($baseline/2);
|
||||
box-shadow: 0 1px 1px $shadow, 0 1px 0 rgba(255, 255, 255, 0.4) inset;
|
||||
border-radius: ($baseline/4);
|
||||
padding: 0;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
|
||||
@extend %btn-primary-green;
|
||||
|
||||
@@ -154,6 +155,21 @@
|
||||
display: block;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
.beta {
|
||||
margin-top: 3px;
|
||||
margin-bottom: -20px;
|
||||
padding: 4px;
|
||||
display: inline-block;
|
||||
|
||||
color: $uxpl-green-base;
|
||||
background-color: theme-color("inverse");
|
||||
border-color: theme-color("inverse");
|
||||
border-radius: 3px;
|
||||
|
||||
font-size: 90%;
|
||||
line-height: 90%;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,3 +304,12 @@ $state-danger-bg: #f2dede !default;
|
||||
$state-danger-border: darken($state-danger-bg, 5%) !default;
|
||||
|
||||
$text-dark-black-blue: #2c3e50;
|
||||
|
||||
$primary: #0a3055 !default;
|
||||
$btn-border-radius: 6px !default;
|
||||
$input-border-radius: 6px !default;
|
||||
$text-color: #454545 !default;
|
||||
$light-background-color: #e1dddb !default;
|
||||
$border-color: #707070 !default;
|
||||
$base-font-size: 18px !default;
|
||||
$dark: #212529;
|
||||
|
||||
@@ -562,7 +562,71 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Modal for previewing changes to a library-sourced block
|
||||
// cms/static/js/views/modals/preview_v2_library_changes.js
|
||||
.modal-lib-preview {
|
||||
.modal-content {
|
||||
padding: 0 !important;
|
||||
|
||||
& > iframe {
|
||||
width: 100%;
|
||||
min-height: 450px;
|
||||
background: url('#{$static-path}/images/spinner.gif') center center no-repeat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ltiLaunchFrame{
|
||||
width:100%;
|
||||
height:100%
|
||||
}
|
||||
|
||||
// Modal for adding components from libary using component picker
|
||||
// cms/static/js/views/components/add_library_content_with_picker.js
|
||||
.modal-add-component-picker {
|
||||
top: 10%;
|
||||
.modal-content {
|
||||
padding: 0 !important;
|
||||
|
||||
& > iframe {
|
||||
width: 100%;
|
||||
min-height: 80vh;
|
||||
background: url('#{$static-path}/images/spinner.gif') center center no-repeat;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.modal-alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: $baseline;
|
||||
margin-bottom: $baseline;
|
||||
|
||||
&.warning {
|
||||
background-color: $state-warning-bg;
|
||||
|
||||
.fa-warning {
|
||||
color: $orange;
|
||||
margin-right: $baseline;
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
text-wrap: nowrap;
|
||||
background-color: $black;
|
||||
color: $white;
|
||||
margin-left: auto;
|
||||
padding: ($baseline/2);
|
||||
|
||||
.fa {
|
||||
margin-left: ($baseline/4);
|
||||
color: $white
|
||||
}
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&:active {
|
||||
background-color: lighten($black, 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
.maintenance-header {
|
||||
text-align: center;
|
||||
margin-top: 50px;
|
||||
|
||||
h2 {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.maintenance-content {
|
||||
padding: 3rem 0;
|
||||
|
||||
.maintenance-list {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
|
||||
.view-list-container {
|
||||
padding: 10px 15px;
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ddd;
|
||||
|
||||
&:hover {
|
||||
background-color: #fafafa;
|
||||
}
|
||||
|
||||
.view-name {
|
||||
display: inline-block;
|
||||
width: 20%;
|
||||
float: left;
|
||||
}
|
||||
|
||||
.view-desc {
|
||||
display: inline-block;
|
||||
width: 80%;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.maintenance-form {
|
||||
width: 60%;
|
||||
margin: auto;
|
||||
|
||||
.result-list {
|
||||
height: calc(100vh - 200px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.result {
|
||||
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||
margin-top: 15px;
|
||||
padding: 15px 30px;
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
li {
|
||||
font-size: 13px;
|
||||
line-height: 9px;
|
||||
}
|
||||
|
||||
.actions {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.field-radio div {
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
div.error {
|
||||
color: #f00;
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
div.head-output {
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
div.main-output {
|
||||
color: #0a0;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.announcement-container {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
|
||||
.announcement-item {
|
||||
display: inline-block;
|
||||
max-width: 300px;
|
||||
min-width: 300px;
|
||||
margin: 15px;
|
||||
|
||||
.announcement-content {
|
||||
background-color: $body-bg;
|
||||
text-align: center;
|
||||
padding: 22px 33px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,7 @@ from openedx.core.djangolib.markup import HTML, Text
|
||||
isUnitPage: ${is_unit_page | n, dump_js_escaped_json},
|
||||
canEdit: true,
|
||||
outlineURL: "${outline_url | n, js_escaped_string}",
|
||||
libraryContentPickerUrl: "${library_content_picker_url | n, js_escaped_string}",
|
||||
clipboardData: ${user_clipboard | n, dump_js_escaped_json},
|
||||
}
|
||||
);
|
||||
|
||||
278
cms/templates/container_chromeless.html
Normal file
278
cms/templates/container_chromeless.html
Normal file
@@ -0,0 +1,278 @@
|
||||
## coding=utf-8
|
||||
## mako
|
||||
##
|
||||
## Studio view template for rendering the whole Unit in an iframe with
|
||||
## XBlocks controls specifically for Authoring MFE. This template renders
|
||||
## a chromeless version of a unit container without headers, footers,
|
||||
## and a navigation bar.
|
||||
|
||||
<%! main_css = "style-main-v1" %>
|
||||
|
||||
<%! course_unit_mfe_iframe_css = "course-unit-mfe-iframe-bundle" %>
|
||||
|
||||
<%namespace name='static' file='static_content.html'/>
|
||||
<%!
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cms.djangoapps.contentstore.config.waffle import CUSTOM_RELATIVE_DATES
|
||||
from cms.djangoapps.contentstore.helpers import xblock_type_display_name
|
||||
from lms.djangoapps.branding import api as branding_api
|
||||
from openedx.core.djangoapps.util.user_messages import PageLevelMessages
|
||||
from openedx.core.djangolib.js_utils import (
|
||||
dump_js_escaped_json, js_escaped_string
|
||||
)
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
from openedx.core.release import RELEASE_LINE
|
||||
%>
|
||||
|
||||
<%page expression_filter="h"/>
|
||||
<!doctype html>
|
||||
<!--[if lte IE 9]><html class="ie9 lte9" lang="${LANGUAGE_CODE}"><![endif]-->
|
||||
<!--[if !IE]><<!--><html lang="${LANGUAGE_CODE}"><!--<![endif]-->
|
||||
<head dir="${static.dir_rtl()}">
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1">
|
||||
<meta name="openedx-release-line" content="${RELEASE_LINE}" />
|
||||
<title>
|
||||
${xblock.display_name_with_default} ${xblock_type_display_name(xblock)} |
|
||||
% if context_course:
|
||||
<% ctx_loc = context_course.location %>
|
||||
${context_course.display_name_with_default} |
|
||||
% elif context_library:
|
||||
${context_library.display_name_with_default} |
|
||||
% endif
|
||||
${settings.STUDIO_NAME}
|
||||
</title>
|
||||
|
||||
<%
|
||||
jsi18n_path = "js/i18n/{language}/djangojs.js".format(language=LANGUAGE_CODE)
|
||||
%>
|
||||
|
||||
% if getattr(settings, 'CAPTURE_CONSOLE_LOG', False):
|
||||
<script type="text/javascript">
|
||||
var oldOnError = window.onerror;
|
||||
window.localStorage.setItem('console_log_capture', JSON.stringify([]));
|
||||
|
||||
window.onerror = function (message, url, lineno, colno, error) {
|
||||
if (oldOnError) {
|
||||
oldOnError.apply(this, arguments);
|
||||
}
|
||||
|
||||
var messages = JSON.parse(window.localStorage.getItem('console_log_capture'));
|
||||
messages.push([message, url, lineno, colno, (error || {}).stack]);
|
||||
window.localStorage.setItem('console_log_capture', JSON.stringify(messages));
|
||||
}
|
||||
</script>
|
||||
% endif
|
||||
|
||||
<script type="text/javascript" src="${static.url(jsi18n_path)}"></script>
|
||||
% if settings.DEBUG:
|
||||
## Provides a fallback for gettext functions in development environment
|
||||
<script type="text/javascript" src="${static.url('js/src/gettext_fallback.js')}"></script>
|
||||
% endif
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<meta name="path_prefix" content="${EDX_ROOT_URL}">
|
||||
<% favicon_url = branding_api.get_favicon_url() %>
|
||||
<link rel="icon" type="image/x-icon" href="${favicon_url}"/>
|
||||
<%static:css group='style-vendor'/>
|
||||
<%static:css group='style-vendor-tinymce-content'/>
|
||||
<%static:css group='style-vendor-tinymce-skin'/>
|
||||
|
||||
% if uses_bootstrap:
|
||||
<link rel="stylesheet" href="${static.url(self.attr.main_css)}" type="text/css" media="all" />
|
||||
% else:
|
||||
<%static:css group='${self.attr.main_css}'/>
|
||||
% endif
|
||||
|
||||
<%static:css group='${self.attr.course_unit_mfe_iframe_css}'/>
|
||||
|
||||
<%include file="widgets/segment-io.html" />
|
||||
|
||||
% for template_name in templates:
|
||||
<script type="text/template" id="${template_name}-tpl">
|
||||
<%static:include path="js/${template_name}.underscore" />
|
||||
</script>
|
||||
% endfor
|
||||
<script type="text/template" id="image-modal-tpl">
|
||||
<%static:include path="common/templates/image-modal.underscore" />
|
||||
</script>
|
||||
<link rel="stylesheet" type="text/css" href="${static.url('js/vendor/timepicker/jquery.timepicker.css')}" />
|
||||
## 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:
|
||||
<link rel="stylesheet" type="text/css" href="${static.url('common/css/vendor/common.min.css')}" />
|
||||
<link rel="stylesheet" type="text/css" href="${static.url('common/css/vendor/editImageModal.min.css')}" />
|
||||
% endif
|
||||
</head>
|
||||
|
||||
<body class="${static.dir_rtl()} is-signedin course container view-container lang_${LANGUAGE_CODE}">
|
||||
|
||||
<a class="nav-skip" href="#main">${_("Skip to main content")}</a>
|
||||
|
||||
<%static:js group='base_vendor'/>
|
||||
|
||||
<%static:webpack entry="commons"/>
|
||||
|
||||
<script type="text/javascript">
|
||||
window.baseUrl = "${settings.STATIC_URL | n, js_escaped_string}";
|
||||
require.config({
|
||||
baseUrl: window.baseUrl
|
||||
});
|
||||
</script>
|
||||
|
||||
<script type="text/javascript" src="${static.url("cms/js/require-config.js")}"></script>
|
||||
|
||||
<!-- view -->
|
||||
<div class="wrapper wrapper-view" dir="${static.dir_rtl()}">
|
||||
<%
|
||||
banner_messages = list(PageLevelMessages.user_messages(request))
|
||||
%>
|
||||
|
||||
% if banner_messages:
|
||||
<div class="page-banner">
|
||||
<div class="user-messages">
|
||||
% for message in banner_messages:
|
||||
<div class="alert ${message.css_class}" role="alert">
|
||||
<span class="icon icon-alert fa ${message.icon_class}" aria-hidden="true"></span>
|
||||
${HTML(message.message_html)}
|
||||
</div>
|
||||
% endfor
|
||||
</div>
|
||||
</div>
|
||||
% endif
|
||||
|
||||
<main id="main" aria-label="Content" tabindex="-1">
|
||||
<div id="content">
|
||||
|
||||
<div class="wrapper-content wrapper">
|
||||
<div class="inner-wrapper">
|
||||
<section class="content-area">
|
||||
<article class="content-primary ${'content-primary-fullwidth' if is_fullwidth_content else ''}">
|
||||
<%
|
||||
assets_url = reverse('assets_handler', kwargs={'course_key_string': str(xblock_locator.course_key)})
|
||||
%>
|
||||
<section class="wrapper-xblock level-page is-hidden studio-xblock-wrapper" data-locator="${xblock_locator}" data-course-key="${xblock_locator.course_key}" data-course-assets="${assets_url}">
|
||||
</section>
|
||||
<div class="ui-loading">
|
||||
<p><span class="spin"><span class="icon fa fa-refresh" aria-hidden="true"></span></span> <span class="copy">${_("Loading")}</span></p>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="page-prompt"></div>
|
||||
|
||||
% if context_course:
|
||||
<%static:webpack entry="js/factories/context_course"/>
|
||||
<script type="text/javascript">
|
||||
window.course = new ContextCourse({
|
||||
id: "${context_course.id | n, js_escaped_string}",
|
||||
name: "${context_course.display_name_with_default | n, js_escaped_string}",
|
||||
url_name: "${context_course.location.block_id | n, js_escaped_string}",
|
||||
org: "${context_course.location.org | n, js_escaped_string}",
|
||||
num: "${context_course.location.course | n, js_escaped_string}",
|
||||
display_course_number: "${context_course.display_coursenumber | n, js_escaped_string}",
|
||||
revision: "${context_course.location.branch | n, js_escaped_string}",
|
||||
self_paced: ${ context_course.self_paced | n, dump_js_escaped_json },
|
||||
is_custom_relative_dates_active: ${CUSTOM_RELATIVE_DATES.is_enabled(context_course.id) | n, dump_js_escaped_json},
|
||||
start: ${context_course.start | n, dump_js_escaped_json},
|
||||
discussions_settings: ${context_course.discussions_settings | n, dump_js_escaped_json}
|
||||
});
|
||||
</script>
|
||||
% endif
|
||||
<script type="text/javascript">
|
||||
require(['js/factories/base'], function () {});
|
||||
</script>
|
||||
|
||||
<%static:webpack entry="js/factories/container">
|
||||
ContainerFactory(
|
||||
${component_templates | n, dump_js_escaped_json},
|
||||
${xblock_info | n, dump_js_escaped_json},
|
||||
"${action | n, js_escaped_string}",
|
||||
{
|
||||
isUnitPage: ${is_unit_page | n, dump_js_escaped_json},
|
||||
canEdit: true,
|
||||
outlineURL: "${outline_url | n, js_escaped_string}",
|
||||
clipboardData: ${user_clipboard | n, dump_js_escaped_json},
|
||||
isIframeEmbed: true,
|
||||
}
|
||||
);
|
||||
</%static:webpack>
|
||||
</body>
|
||||
|
||||
## Initialize MutationObserver and ResizeObserver to update the iframe size.
|
||||
## These are used to provide resize events for the Authoring MFE.
|
||||
<script type="text/javascript">
|
||||
(function() {
|
||||
// If this view is rendered in an iframe within the authoring microfrontend app
|
||||
// it will report the height of its contents to the parent window when the
|
||||
// document loads, window resizes, or DOM mutates.
|
||||
if (window !== window.parent) {
|
||||
var lastHeight = window.parent[0].offsetHeight;
|
||||
var lastWidth = window.parent[0].offsetWidth;
|
||||
var contentElement = document.getElementById('content');
|
||||
|
||||
function dispatchResizeMessage(event) {
|
||||
// Note: event is actually an Array of MutationRecord objects when fired from the MutationObserver
|
||||
var isLoadEvent = event.type === 'load';
|
||||
var newHeight = contentElement.offsetHeight;
|
||||
var newWidth = contentElement.offsetWidth;
|
||||
|
||||
// Monitor for messages and checks if the message contains an id. If
|
||||
// there is an id, then the location of the selected focus element
|
||||
// is sent through its offset attribute. The offset will allow the
|
||||
// page to scroll to the location of the focus element so that it is
|
||||
// at the top of the page. Unique ids and names are required for
|
||||
// proper scrolling.
|
||||
window.addEventListener('message', function (event) {
|
||||
if (event.data.hashName) {
|
||||
var targetId = event.data.hashName;
|
||||
var targetName = event.data.hashName.slice(1);
|
||||
// Checks if the target uses an id or name to focus and gets offset.
|
||||
var targetOffset = $(targetId).offset() || $(document.getElementsByName(targetName)[0]).offset();
|
||||
window.parent.postMessage({ 'offset': targetOffset.top }, document.referrer);
|
||||
}
|
||||
})
|
||||
|
||||
window.parent.postMessage(
|
||||
{
|
||||
type: 'plugin.resize',
|
||||
payload: {
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
}
|
||||
}, document.referrer
|
||||
);
|
||||
|
||||
lastHeight = newHeight;
|
||||
lastWidth = newWidth;
|
||||
|
||||
// Within the authoring microfrontend the iframe resizes to match the
|
||||
// height of this document and it should never scroll. It does scroll
|
||||
// ocassionally when javascript is used to focus elements on the page
|
||||
// before the parent iframe has been resized to match the content
|
||||
// height. This window.scrollTo is an attempt to keep the content at the
|
||||
// top of the page.
|
||||
window.scrollTo(0, 0);
|
||||
}
|
||||
|
||||
// Create an observer instance linked to the callback function
|
||||
const observer = new MutationObserver(dispatchResizeMessage);
|
||||
|
||||
// Start observing the target node for configured mutations
|
||||
observer.observe(document.body, { attributes: true, childList: true, subtree: true });
|
||||
|
||||
window.addEventListener('load', dispatchResizeMessage);
|
||||
|
||||
const resizeObserver = new ResizeObserver(dispatchResizeMessage);
|
||||
resizeObserver.observe(document.body);
|
||||
}
|
||||
}());
|
||||
</script>
|
||||
</html>
|
||||
@@ -6,4 +6,7 @@
|
||||
<span class="large-template-icon large-<%- type %>-icon"></span>
|
||||
<span class="sr"> <%- gettext("Add Component:") %></span>
|
||||
<span class="name"><%- display_name %></span>
|
||||
<% if (beta) { %>
|
||||
<span class="beta"><%- gettext("Beta") %></span>
|
||||
<% } %>
|
||||
</button>
|
||||
|
||||
18
cms/templates/js/edit-upstream-alert.underscore
Normal file
18
cms/templates/js/edit-upstream-alert.underscore
Normal file
@@ -0,0 +1,18 @@
|
||||
<div class="modal-alert warning" role="alert">
|
||||
<span class="fa fa-lg fa-warning" aria-hidden="true"/><span class="sr"><%- gettext("Warning") %></span>
|
||||
<div class="message-content">
|
||||
<strong><%- gettext("You are editing content from a Library.") %></strong>
|
||||
<p>
|
||||
<%- gettext("Edits made here will only be reflected in this course. These edits may be overridden later if updates are accepted.") %>
|
||||
</p>
|
||||
</div>
|
||||
<a
|
||||
href="<%- upstreamLink %>"
|
||||
target="_blank"
|
||||
class="btn action-btn"
|
||||
>
|
||||
<%- gettext('View in Library') %>
|
||||
<span class="fa fa-external-link" aria-hidden="true" />
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<%page expression_filter="h"/>
|
||||
<%inherit file="base.html" />
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
<%!
|
||||
from django.utils.translation import gettext as _
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
%>
|
||||
<%block name="title">${_('Delete Announcement')}</%block>
|
||||
<%block name="viewtitle">
|
||||
<h3 class="info-course">
|
||||
<span>${_('Delete Announcement')}</span>
|
||||
</h3>
|
||||
</%block>
|
||||
|
||||
<%block name="viewcontent">
|
||||
<section class="container maintenance-content">
|
||||
<div id="delete-announcement-form" class="studio-view maintenance-form">
|
||||
<div class="container-message wrapper-message">
|
||||
<div class="message has-warnings" aria-hidden="true">
|
||||
<p class="warning">
|
||||
<span class="icon fa fa-warning"></span>
|
||||
${_('Are you sure you want to delete this Announcement?')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<form action="" method="post" class="form-create announcement-container">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}"/>
|
||||
<div class="announcement-item">
|
||||
<div class="announcement-content">
|
||||
## xss-lint: disable=mako-invalid-html-filter
|
||||
${object.content | n}
|
||||
</div>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="action action-primary" value="Confirm">${_('Confirm')}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</%block>
|
||||
@@ -1,50 +0,0 @@
|
||||
<%page expression_filter="h"/>
|
||||
<%inherit file="base.html" />
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
<%!
|
||||
from django.utils.translation import gettext as _
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
%>
|
||||
<%block name="title">${_('Edit Announcement')}</%block>
|
||||
<%block name="viewtitle">
|
||||
<h3 class="info-course">
|
||||
<span>${_('Edit Announcement')}</span>
|
||||
</h3>
|
||||
</%block>
|
||||
|
||||
<%block name="viewcontent">
|
||||
<section class="container maintenance-content">
|
||||
<div class="container studio-view maintenance-form">
|
||||
<form class="form-create" method="post" action="${action_url}">
|
||||
<div class="wrapper-form announcement-container">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}"/>
|
||||
<div class="announcement-item">
|
||||
## xss-lint: disable=mako-invalid-html-filter
|
||||
${form.as_p() | n}
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" class="action action-primary">${_('Save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</%block>
|
||||
|
||||
<%block name="header_extras">
|
||||
<script src="${STATIC_URL}js/vendor/tinymce/js/tinymce/tinymce.full.min.js"></script>
|
||||
<script type="text/javascript">
|
||||
tinymce.init({
|
||||
selector: "#id_content",
|
||||
height: 450,
|
||||
skin: "studio-tmce5",
|
||||
branding: false,
|
||||
plugins: [
|
||||
"advlist autolink lists link image charmap print anchor",
|
||||
"searchreplace visualblocks code",
|
||||
"insertdatetime media table contextmenu paste"
|
||||
],
|
||||
toolbar: "undo redo styleselect bold italic alignleft aligncenter alignright alignjustify bullist numlist outdent indent link image"
|
||||
});
|
||||
</script>
|
||||
</%block>
|
||||
@@ -1,59 +0,0 @@
|
||||
<%page expression_filter="h"/>
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
<%!
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
|
||||
%>
|
||||
<div class="studio-view maintenance-form">
|
||||
<div class="form-create">
|
||||
<div class="announcement-container">
|
||||
% for announcement in announcement_list:
|
||||
<div class="announcement-item">
|
||||
<div class="announcement-content">
|
||||
## xss-lint: disable=mako-invalid-html-filter
|
||||
${announcement.content | n}
|
||||
</div>
|
||||
|
||||
<div class="actions-list">
|
||||
<span>
|
||||
% if announcement.active:
|
||||
Active <span class="icon fa fa-check-square-o" aria-hidden="true" />
|
||||
% else:
|
||||
Inactive <span class="icon fa fa-square-o" aria-hidden="true" />
|
||||
% endif
|
||||
</span>
|
||||
<a class="action-item announcement-edit"
|
||||
href="${ reverse('maintenance:announcement_edit', kwargs={'pk': announcement.pk}) }">
|
||||
<button class="btn-default">Edit</button>
|
||||
</a>
|
||||
<a class="action-item announcement-delete"
|
||||
href="${ reverse('maintenance:announcement_delete', kwargs={'pk': announcement.pk}) }">
|
||||
<button class="btn-default">Delete</button>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
% endfor
|
||||
</div>
|
||||
<div class="actions">
|
||||
<a href="${ reverse('maintenance:announcement_create') }">
|
||||
<button class="action action-primary">${_('Create New')}</button>
|
||||
</a>
|
||||
% if is_paginated:
|
||||
% if page_obj.has_previous():
|
||||
<a href="${ reverse('maintenance:announcement_index', kwargs={'page': page_obj.previous_page_number()}) }">
|
||||
<button class="action action-secondary">${_('previous')}</button>
|
||||
</a>
|
||||
% endif
|
||||
|
||||
% if page_obj.has_next():
|
||||
<a href="${ reverse('maintenance:announcement_index', kwargs={'page': page_obj.next_page_number()}) }">
|
||||
<button class="action action-secondary">${_('next')}</button>
|
||||
</a>
|
||||
% endif
|
||||
% endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,33 +0,0 @@
|
||||
<%page expression_filter="h"/>
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
<%!
|
||||
from django.utils.translation import gettext as _
|
||||
from openedx.core.djangolib.markup import HTML, Text
|
||||
%>
|
||||
<div id="force-published-form" class="wrap-instructor-info studio-view maintenance-form">
|
||||
<form id="force_publish" class="form-create" method="post">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}"/>
|
||||
<div class="wrapper-form">
|
||||
<fieldset>
|
||||
<legend class="sr">${_("Required data to force publish course.")}</legend>
|
||||
<div class="list-input">
|
||||
<div id="course-id-container" class="field text required">
|
||||
<label for="course-id">${_('Course ID')}</label>
|
||||
<input id="course-id" type="text" name="course-id" aria-describedby="course-id-desc" required />
|
||||
<div id="course-id-desc" class="tip tip-stacked">${_('course-v1:edX+DemoX+Demo_Course')}</div>
|
||||
<div class="wrapper-error"></div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit" id="submit_force_publish" class="action action-primary">${_('Force Publish Course')}
|
||||
</button>
|
||||
<button id="reset-button" class="action action-secondary action-cancel"
|
||||
aria-describedby="reset-values-desc">${_('Reset')}</button>
|
||||
<span id="reset-values-desc" class="is-hidden">${_('Reset values')}</span>
|
||||
</div>
|
||||
</form>
|
||||
<div id="result-error"><div class="wrapper-error"></div></div>
|
||||
<div id="result-container" class="result-container"></div>
|
||||
</div>
|
||||
@@ -1,21 +0,0 @@
|
||||
<%page expression_filter="h"/>
|
||||
<%inherit file="../base.html" />
|
||||
<%def name='online_help_token()'><% return 'maintenance' %></%def>
|
||||
<%namespace name='static' file='../static_content.html'/>
|
||||
<%!
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
%>
|
||||
<%block name="content">
|
||||
<div class="wrapper-content wrapper">
|
||||
<div class="maintenance-header">
|
||||
<h2>
|
||||
<a href="${reverse('maintenance:maintenance_index')}">
|
||||
<span>${_('Maintenance Dashboard')}</span>
|
||||
</a>
|
||||
</h2>
|
||||
<%block name="viewtitle">
|
||||
</%block>
|
||||
</div>
|
||||
<%block name="viewcontent"></%block>
|
||||
</%block>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user