diff --git a/.annotation_safe_list.yml b/.annotation_safe_list.yml
index a5e997bf2f..c57eeeb250 100644
--- a/.annotation_safe_list.yml
+++ b/.annotation_safe_list.yml
@@ -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:
diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index fc452f7acd..c41fe40b4d 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -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/
diff --git a/.github/renovate.json5 b/.github/renovate.json5
index 3f771c2c72..ca314891b2 100644
--- a/.github/renovate.json5
+++ b/.github/renovate.json5
@@ -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"]
diff --git a/.github/workflows/check-consistent-dependencies.yml b/.github/workflows/check-consistent-dependencies.yml
index 048cbe6b00..82298af70a 100644
--- a/.github/workflows/check-consistent-dependencies.yml
+++ b/.github/workflows/check-consistent-dependencies.yml
@@ -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/**
diff --git a/.github/workflows/ci-static-analysis.yml b/.github/workflows/ci-static-analysis.yml
index 458e00fc6b..d989ff9db2 100644
--- a/.github/workflows/ci-static-analysis.yml
+++ b/.github/workflows/ci-static-analysis.yml
@@ -10,7 +10,7 @@ jobs:
matrix:
python-version:
- "3.11"
- os: ["ubuntu-22.04"]
+ os: ["ubuntu-24.04"]
steps:
- uses: actions/checkout@v4
diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml
index c9d2d7ab11..4496a4b61c 100644
--- a/.github/workflows/js-tests.yml
+++ b/.github/workflows/js-tests.yml
@@ -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: |
diff --git a/.github/workflows/migrations-check.yml b/.github/workflows/migrations-check.yml
index 624caddd53..84e334d688 100644
--- a/.github/workflows/migrations-check.yml
+++ b/.github/workflows/migrations-check.yml
@@ -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
diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml
index 8860aced7f..9a654e09e7 100644
--- a/.github/workflows/pylint-checks.yml
+++ b/.github/workflows/pylint-checks.yml
@@ -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
diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml
index 8461012349..310f9f83bf 100644
--- a/.github/workflows/quality-checks.yml
+++ b/.github/workflows/quality-checks.yml
@@ -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]
diff --git a/.github/workflows/static-assets-check.yml b/.github/workflows/static-assets-check.yml
index 4fe66e2a77..502dddce08 100644
--- a/.github/workflows/static-assets-check.yml
+++ b/.github/workflows/static-assets-check.yml
@@ -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"
diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json
index 4ab126cb47..1af8af8142 100644
--- a/.github/workflows/unit-test-shards.json
+++ b/.github/workflows/unit-test-shards.json
@@ -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/",
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index 854677b93c..e691e16e47 100644
--- a/.github/workflows/unit-tests.yml
+++ b/.github/workflows/unit-tests.yml
@@ -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:
diff --git a/.github/workflows/verify-dunder-init.yml b/.github/workflows/verify-dunder-init.yml
index 9d920238eb..c398c50673 100644
--- a/.github/workflows/verify-dunder-init.yml
+++ b/.github/workflows/verify-dunder-init.yml
@@ -1,4 +1,4 @@
-name: CI
+name: Verify Dunder __init__.py Files
on:
pull_request:
diff --git a/.nvmrc b/.nvmrc
index 3c032078a4..209e3ef4b6 100644
--- a/.nvmrc
+++ b/.nvmrc
@@ -1 +1 @@
-18
+20
diff --git a/.readthedocs.yaml b/.readthedocs.yaml
index 548ea72b3e..c0db64e5e1 100644
--- a/.readthedocs.yaml
+++ b/.readthedocs.yaml
@@ -3,7 +3,7 @@ version: 2
build:
os: "ubuntu-22.04"
tools:
- python: "3.12"
+ python: "3.11"
sphinx:
configuration: docs/conf.py
diff --git a/Dockerfile b/Dockerfile
index 75a716177f..a67c46738b 100644
--- a/Dockerfile
+++ b/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/*
diff --git a/README.rst b/README.rst
index 64186fca3a..54b91363a8 100644
--- a/README.rst
+++ b/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
-----------
diff --git a/catalog-info.yaml b/catalog-info.yaml
index 885b879833..c6b87b08db 100644
--- a/catalog-info.yaml
+++ b/catalog-info.yaml
@@ -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'
diff --git a/cms/djangoapps/contentstore/asset_storage_handlers.py b/cms/djangoapps/contentstore/asset_storage_handlers.py
index 281258e03a..97fcb31257 100644
--- a/cms/djangoapps/contentstore/asset_storage_handlers.py
+++ b/cms/djangoapps/contentstore/asset_storage_handlers.py
@@ -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 {
diff --git a/cms/djangoapps/contentstore/courseware_index.py b/cms/djangoapps/contentstore/courseware_index.py
index 48647bf47b..d3b6f811d5 100644
--- a/cms/djangoapps/contentstore/courseware_index.py
+++ b/cms/djangoapps/contentstore/courseware_index.py
@@ -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
diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py
index a4ece6c85d..e40eddb6c9 100644
--- a/cms/djangoapps/contentstore/helpers.py
+++ b/cms/djangoapps/contentstore/helpers.py
@@ -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):
diff --git a/cms/djangoapps/contentstore/management/commands/import.py b/cms/djangoapps/contentstore/management/commands/import.py
index 4ff9e7ce8d..346f10d933 100644
--- a/cms/djangoapps/contentstore/management/commands/import.py
+++ b/cms/djangoapps/contentstore/management/commands/import.py
@@ -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)
diff --git a/cms/djangoapps/contentstore/management/commands/reindex_course.py b/cms/djangoapps/contentstore/management/commands/reindex_course.py
index accbc077c4..275eff5062 100644
--- a/cms/djangoapps/contentstore/management/commands/reindex_course.py
+++ b/cms/djangoapps/contentstore/management/commands/reindex_course.py
@@ -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 content content content Sub contentheading
content Sub content heading
" + + 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>
"\
+ "<p>abcd</p>
"\
+ "<p> </p>
abcd
" diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index 7c3a369fed..39b793f479 100644 --- a/cms/djangoapps/contentstore/toggles.py +++ b/cms/djangoapps/contentstore/toggles.py @@ -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) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 035e44d5ce..7023bcaefa 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -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): diff --git a/cms/djangoapps/contentstore/video_storage_handlers.py b/cms/djangoapps/contentstore/video_storage_handlers.py index 6827b295b0..4cc5c738b5 100644 --- a/cms/djangoapps/contentstore/video_storage_handlers.py +++ b/cms/djangoapps/contentstore/video_storage_handlers.py @@ -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) diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index 4d6c17838e..e6b41dc261 100644 --- a/cms/djangoapps/contentstore/views/block.py +++ b/cms/djangoapps/contentstore/views/block.py @@ -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) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index ba767df78d..914c078846 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -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] diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 9f6cfb7c43..244804c306 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -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: diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py index 340cadb4e2..92e4329c2f 100644 --- a/cms/djangoapps/contentstore/views/library.py +++ b/cms/djangoapps/contentstore/views/library.py @@ -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 diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index acc5fc95df..aa7421bb87 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -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, diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py index f3e20b45b2..1190eb2395 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -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) diff --git a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py index 7979a422a3..9244ffa989 100644 --- a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py +++ b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py @@ -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, """ +Including this totally real image: 