diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 3f9abcc671f..fc452f7acde 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -15,12 +15,15 @@ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_task/ lms/djangoapps/mobile_api/ +openedx/core/djangoapps/commerce/ @openedx/2u-infinity openedx/core/djangoapps/credentials @openedx/2U-aperture openedx/core/djangoapps/credit @openedx/2U-aperture +openedx/core/djangoapps/enrollments/ @openedx/2U-aperture 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/verified_track_content/ @openedx/2u-infinity openedx/features/course_experience/ xmodule/ @@ -35,15 +38,18 @@ common/djangoapps/track/ lms/djangoapps/certificates/ @openedx/2U-aperture # Discovery -common/djangoapps/course_modes/ +common/djangoapps/course_modes/ @openedx/2U-aperture common/djangoapps/enrollment/ -lms/djangoapps/commerce/ -lms/djangoapps/experiments/ -lms/djangoapps/learner_dashboard/ @openedx/2U-aperture -lms/djangoapps/learner_home/ @openedx/2U-aperture -openedx/features/content_type_gating/ +common/djangoapps/entitlements/ @openedx/2U-aperture +lms/djangoapps/branding/ @openedx/2U-aperture +lms/djangoapps/commerce/ @openedx/2u-infinity +lms/djangoapps/experiments/ @openedx/2u-infinity +lms/djangoapps/gating/ @openedx/2u-infinity +lms/djangoapps/learner_dashboard/ @openedx/2U-aperture +lms/djangoapps/learner_home/ @openedx/2U-aperture +openedx/features/content_type_gating/ @openedx/2u-infinity openedx/features/course_duration_limits/ -openedx/features/discounts/ +openedx/features/discounts/ @openedx/2u-infinity # Ping Axim On-call if someone uses the QuickStart # https://docs.openedx.org/en/latest/developers/quickstarts/first_openedx_pr.html diff --git a/.github/workflows/ci-static-analysis.yml b/.github/workflows/ci-static-analysis.yml index 7e768a45646..458e00fc6b1 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-20.04"] + os: ["ubuntu-22.04"] steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/compile-python-requirements.yml b/.github/workflows/compile-python-requirements.yml index 0ff99b9c685..21cb80083f1 100644 --- a/.github/workflows/compile-python-requirements.yml +++ b/.github/workflows/compile-python-requirements.yml @@ -15,7 +15,7 @@ defaults: jobs: recompile-python-dependencies: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Check out target branch diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml index 597f7e0bb4c..eb8fb3ddc56 100644 --- a/.github/workflows/js-tests.yml +++ b/.github/workflows/js-tests.yml @@ -12,7 +12,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-20.04] + os: [ubuntu-latest] node-version: [18, 20] python-version: - "3.11" diff --git a/.github/workflows/lint-imports.yml b/.github/workflows/lint-imports.yml index 8ead8396bf3..e3c59ec0930 100644 --- a/.github/workflows/lint-imports.yml +++ b/.github/workflows/lint-imports.yml @@ -9,7 +9,7 @@ on: jobs: lint-imports: name: Lint Python Imports - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Check out branch diff --git a/.github/workflows/migrations-check.yml b/.github/workflows/migrations-check.yml index 183b90effa2..624caddd530 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-20.04] + os: [ubuntu-22.04] python-version: - "3.11" # 'pinned' is used to install the latest patch version of Django @@ -52,7 +52,7 @@ jobs: steps: - name: Setup mongodb user run: | - mongosh edxapp --eval ' + docker exec ${{ job.services.mongo.id }} mongosh edxapp --eval ' db.createUser( { user: "edxapp", @@ -67,7 +67,7 @@ jobs: - name: Verify mongo and mysql db credentials run: | mysql -h 127.0.0.1 -uedxapp001 -ppassword -e "select 1;" edxapp - mongosh --host 127.0.0.1 --username edxapp --password password --eval 'use edxapp; db.adminCommand("ping");' edxapp + docker exec ${{ job.services.mongo.id }} mongosh --host 127.0.0.1 --username edxapp --password password --eval 'use edxapp; db.adminCommand("ping");' edxapp - name: Checkout repo uses: actions/checkout@v4 @@ -126,7 +126,7 @@ jobs: if: always() needs: - check_migrations - runs-on: ubuntu-latest + runs-on: ubuntu-22.04 steps: - name: Decide whether the needed jobs succeeded or failed # uses: re-actors/alls-green@v1.2.1 diff --git a/.github/workflows/publish-ci-docker-image.yml b/.github/workflows/publish-ci-docker-image.yml index 0a9f50f6daf..6a0f3768b7e 100644 --- a/.github/workflows/publish-ci-docker-image.yml +++ b/.github/workflows/publish-ci-docker-image.yml @@ -7,7 +7,7 @@ on: jobs: push: - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml index eeb53c24ed9..8860aced7f9 100644 --- a/.github/workflows/pylint-checks.yml +++ b/.github/workflows/pylint-checks.yml @@ -8,25 +8,25 @@ on: jobs: run-pylint: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 strategy: fail-fast: false matrix: include: - module-name: lms-1 - path: "--django-settings-module=lms.envs.test lms/djangoapps/badges/ lms/djangoapps/branding/ lms/djangoapps/bulk_email/ lms/djangoapps/bulk_enroll/ lms/djangoapps/bulk_user_retirement/ lms/djangoapps/ccx/ lms/djangoapps/certificates/ lms/djangoapps/commerce/ lms/djangoapps/course_api/ lms/djangoapps/course_blocks/ lms/djangoapps/course_home_api/ lms/djangoapps/course_wiki/ lms/djangoapps/coursewarehistoryextended/ lms/djangoapps/debug/ lms/djangoapps/courseware/ lms/djangoapps/course_goals/ lms/djangoapps/rss_proxy/" + path: "lms/djangoapps/badges/ lms/djangoapps/branding/ lms/djangoapps/bulk_email/ lms/djangoapps/bulk_enroll/ lms/djangoapps/bulk_user_retirement/ lms/djangoapps/ccx/ lms/djangoapps/certificates/ lms/djangoapps/commerce/ lms/djangoapps/course_api/ lms/djangoapps/course_blocks/ lms/djangoapps/course_home_api/ lms/djangoapps/course_wiki/ lms/djangoapps/coursewarehistoryextended/ lms/djangoapps/debug/ lms/djangoapps/courseware/ lms/djangoapps/course_goals/ lms/djangoapps/rss_proxy/" - module-name: lms-2 - path: "--django-settings-module=lms.envs.test lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/learner_home/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/djangoapps/mfe_config_api/ lms/envs/ lms/lib/ lms/tests.py" + path: "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/learner_home/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/djangoapps/mfe_config_api/ lms/envs/ lms/lib/ lms/tests.py" - module-name: openedx-1 - path: "--django-settings-module=lms.envs.test openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/content_staging/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/ openedx/core/djangoapps/course_live/" + path: "openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/content_staging/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/ openedx/core/djangoapps/course_live/" - module-name: openedx-2 - path: "--django-settings-module=lms.envs.test openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/service_status/ openedx/core/djangoapps/session_inactivity_timeout/ openedx/core/djangoapps/signals/ openedx/core/djangoapps/site_configuration/ openedx/core/djangoapps/system_wide_roles/ openedx/core/djangoapps/theming/ openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/util/ openedx/core/djangoapps/verified_track_content/ openedx/core/djangoapps/video_config/ openedx/core/djangoapps/video_pipeline/ openedx/core/djangoapps/waffle_utils/ openedx/core/djangoapps/xblock/ openedx/core/djangoapps/xmodule_django/ openedx/core/tests/ openedx/features/ openedx/testing/ openedx/tests/ openedx/core/djangoapps/learner_pathway/ openedx/core/djangoapps/notifications/ openedx/core/djangoapps/staticfiles/ openedx/core/djangoapps/content_tagging/" + path: "openedx/core/djangoapps/geoinfo/ openedx/core/djangoapps/header_control/ openedx/core/djangoapps/heartbeat/ openedx/core/djangoapps/lang_pref/ openedx/core/djangoapps/models/ openedx/core/djangoapps/monkey_patch/ openedx/core/djangoapps/oauth_dispatch/ openedx/core/djangoapps/olx_rest_api/ openedx/core/djangoapps/password_policy/ openedx/core/djangoapps/plugin_api/ openedx/core/djangoapps/plugins/ openedx/core/djangoapps/profile_images/ openedx/core/djangoapps/programs/ openedx/core/djangoapps/safe_sessions/ openedx/core/djangoapps/schedules/ openedx/core/djangoapps/service_status/ openedx/core/djangoapps/session_inactivity_timeout/ openedx/core/djangoapps/signals/ openedx/core/djangoapps/site_configuration/ openedx/core/djangoapps/system_wide_roles/ openedx/core/djangoapps/theming/ openedx/core/djangoapps/user_api/ openedx/core/djangoapps/user_authn/ openedx/core/djangoapps/util/ openedx/core/djangoapps/verified_track_content/ openedx/core/djangoapps/video_config/ openedx/core/djangoapps/video_pipeline/ openedx/core/djangoapps/waffle_utils/ openedx/core/djangoapps/xblock/ openedx/core/djangoapps/xmodule_django/ openedx/core/tests/ openedx/features/ openedx/testing/ openedx/tests/ openedx/core/djangoapps/notifications/ openedx/core/djangoapps/staticfiles/ openedx/core/djangoapps/content_tagging/" - module-name: common - path: "--django-settings-module=lms.envs.test common pavelib" + path: "common pavelib" - module-name: cms - path: "--django-settings-module=cms.envs.test cms" + path: "cms" - module-name: xmodule - path: "--django-settings-module=lms.envs.test xmodule" + path: "xmodule" name: pylint ${{ matrix.module-name }} steps: @@ -75,7 +75,7 @@ jobs: if: always() needs: - run-pylint - runs-on: ubuntu-latest + runs-on: ubuntu-22.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 28c3841acc5..510059a9d62 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-20.04] + os: [ubuntu-22.04] python-version: - "3.11" node-version: [20] diff --git a/.github/workflows/semgrep.yml b/.github/workflows/semgrep.yml index 7f2b4925af8..d880d735176 100644 --- a/.github/workflows/semgrep.yml +++ b/.github/workflows/semgrep.yml @@ -17,7 +17,7 @@ jobs: runs-on: "${{ matrix.os }}" strategy: matrix: - os: ["ubuntu-20.04"] + os: ["ubuntu-latest"] python-version: - "3.11" diff --git a/.github/workflows/static-assets-check.yml b/.github/workflows/static-assets-check.yml index 7bbfd3369b6..4fe66e2a777 100644 --- a/.github/workflows/static-assets-check.yml +++ b/.github/workflows/static-assets-check.yml @@ -12,7 +12,7 @@ jobs: runs-on: ${{ matrix.os }} strategy: matrix: - os: [ubuntu-20.04] + os: [ubuntu-22.04] python-version: - "3.11" node-version: [18, 20] @@ -72,9 +72,6 @@ jobs: run: | pip install -r requirements/edx/assets.txt - - name: Initiate Mongo DB Service - run: sudo systemctl start mongod - - name: Add node_modules bin to $Path run: echo $GITHUB_WORKSPACE/node_modules/.bin >> $GITHUB_PATH diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index 3e442b75d4e..854677b93cf 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-20.04 + runs-on: ubuntu-22.04 strategy: matrix: python-version: @@ -66,7 +66,29 @@ jobs: - name: install system requirements run: | - sudo apt-get update && sudo apt-get install libmysqlclient-dev libxmlsec1-dev lynx + sudo apt-get update && sudo apt-get install libmysqlclient-dev libxmlsec1-dev lynx openssl + + # This is needed until the ENABLE_BLAKE2B_HASHING can be removed and we + # can stop using MD4 by default. + - name: enable md4 hashing in libssl + run: | + cat < --unusable-password + ./manage.py lms create_dot_application studio-sso-id studio_worker \ + --grant-type authorization-code \ + --skip-authorization \ + --redirect-uris 'http://localhost:18010/complete/edx-oauth2/' \ + --scopes user_id + +* Log into Django admin (eg. http://localhost:18000/admin/oauth2_provider/application/), + click into the application you created above (``studio-sso-id``), and copy its "Client secret". +* In your private LMS_CFG yaml file or your private Django settings module: + + * Set ``SOCIAL_AUTH_EDX_OAUTH2_KEY`` to the client ID (``studio-sso-id``). + * Set ``SOCIAL_AUTH_EDX_OAUTH2_SECRET`` to the client secret (which you copied). Run the Platform ---------------- @@ -131,11 +167,11 @@ First, ensure MySQL, Mongo, and Memcached are running. Start the LMS:: - ./manage.py lms runserver + ./manage.py lms runserver 18000 Start the CMS:: - ./manage.py cms runserver + ./manage.py cms runserver 18010 This will give you a mostly-headless Open edX platform. Most frontends have been migrated to "Micro-Frontends (MFEs)" which need to be installed and run diff --git a/catalog-info.yaml b/catalog-info.yaml index 885b879833d..c6b87b08db8 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 281258e03a8..97fcb312574 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.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/config/waffle.py b/cms/djangoapps/contentstore/config/waffle.py index f84290ba83a..ae0e6ea467b 100644 --- a/cms/djangoapps/contentstore/config/waffle.py +++ b/cms/djangoapps/contentstore/config/waffle.py @@ -4,7 +4,7 @@ """ -from edx_toggles.toggles import WaffleFlag, WaffleSwitch +from edx_toggles.toggles import WaffleSwitch from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag @@ -26,20 +26,6 @@ f'{WAFFLE_NAMESPACE}.show_review_rules', __name__, LOG_PREFIX ) -# Waffle flag to redirect to the library authoring MFE. -# .. toggle_name: contentstore.library_authoring_mfe -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Toggles the new micro-frontend-based implementation of the library authoring experience. -# .. toggle_use_cases: temporary, open_edx -# .. toggle_creation_date: 2020-08-03 -# .. toggle_target_removal_date: 2020-12-31 -# .. toggle_warning: Also set settings.LIBRARY_AUTHORING_MICROFRONTEND_URL and ENABLE_LIBRARY_AUTHORING_MICROFRONTEND. -# .. toggle_tickets: https://openedx.atlassian.net/wiki/spaces/OEPM/pages/4106944527/Libraries+Relaunch+Proposal+For+Product+Review -REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND = WaffleFlag( - f'{WAFFLE_NAMESPACE}.library_authoring_mfe', __name__, LOG_PREFIX -) - # .. toggle_name: studio.custom_relative_dates # .. toggle_implementation: CourseWaffleFlag diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index a4ece6c85d5..e67e337e55f 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -3,14 +3,16 @@ """ from __future__ import annotations import logging +import pathlib import urllib from lxml import etree from mimetypes import guess_type 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 +24,7 @@ from xmodule.xml_block import XmlMixin from cms.djangoapps.models.settings.course_grading import CourseGradingModel +from cms.lib.xblock.upstream_sync import UpstreamLink, BadUpstream, BadDownstream, 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 +33,10 @@ 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 +191,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 +272,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 +281,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,17 +291,34 @@ 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 @@ -302,12 +328,15 @@ def _import_xml_node_to_parent( 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 +344,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 @@ -373,12 +404,32 @@ 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 + # 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 (BadDownstream, BadUpstream): + # 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). + 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) + # 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'): @@ -394,13 +445,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 +473,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 +495,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 +521,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 +567,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/rest_api/v1/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py index e8d3039b0b2..6fe829ce0e3 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/__init__.py @@ -8,7 +8,7 @@ from .course_team import CourseTeamSerializer 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, diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py index 1afc51ed77a..a81d391b3f6 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py @@ -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() @@ -51,17 +51,18 @@ class CourseHomeSerializer(serializers.Serializer): allow_empty=True ) archived_courses = CourseCommonSerializer(required=False, many=True) + can_access_advanced_settings = serializers.BooleanField() can_create_organizations = serializers.BooleanField() course_creator_status = serializers.CharField() courses = CourseCommonSerializer(required=False, many=True) 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() - library_authoring_mfe_url = serializers.CharField() taxonomy_list_mfe_url = serializers.CharField() optimization_enabled = serializers.BooleanField() - redirect_to_library_authoring_mfe = serializers.BooleanField() request_course_creator_url = serializers.CharField() rerun_creator_status = serializers.BooleanField() show_new_library_button = serializers.BooleanField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/settings.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/settings.py index 1c9f9f60847..b1a02fff1f3 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/settings.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/settings.py @@ -31,4 +31,3 @@ class CourseSettingsSerializer(serializers.Serializer): show_min_grade_warning = serializers.BooleanField() sidebar_html_enabled = serializers.BooleanField() upgrade_deadline = serializers.DateTimeField(allow_null=True) - use_v2_cert_display_settings = serializers.BooleanField() diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py index d72707ed783..dffa23349d9 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/vertical_block.py @@ -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 @@ def get_assets_url(self, obj): 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() diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/home.py b/cms/djangoapps/contentstore/rest_api/v1/views/home.py index d41ceb2647c..06433d9f42d 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/home.py @@ -8,7 +8,7 @@ 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.", }, ) @@ -52,15 +52,17 @@ def get(self, request: Request): "allow_unicode_course_id": false, "allowed_organizations": [], "archived_courses": [], + "can_access_advanced_settings": true, "can_create_organizations": true, "course_creator_status": "granted", "courses": [], "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, - "redirect_to_library_authoring_mfe": false, "request_course_creator_url": "/request_course_creator", "rerun_creator_status": true, "show_new_library_button": true, @@ -85,7 +87,7 @@ def get(self, request: Request): 'platform_name': settings.PLATFORM_NAME, 'user_is_active': request.user.is_active, }) - serializer = CourseHomeSerializer(home_context) + serializer = StudioHomeSerializer(home_context) return Response(serializer.data) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/settings.py b/cms/djangoapps/contentstore/rest_api/v1/views/settings.py index e921ac60398..fbb05cba4de 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/settings.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/settings.py @@ -95,7 +95,6 @@ def get(self, request: Request, course_id: str): "show_min_grade_warning": false, "sidebar_html_enabled": true, "upgrade_deadline": null, - "use_v2_cert_display_settings": false } ``` """ @@ -112,7 +111,6 @@ def get(self, request: Request, course_id: str): 'course_display_name_with_default': course_block.display_name_with_default, 'platform_name': settings.PLATFORM_NAME, 'licensing_enabled': settings.FEATURES.get("LICENSING", False), - 'use_v2_cert_display_settings': settings.FEATURES.get("ENABLE_V2_CERT_DISPLAY_SETTINGS", False), }) serializer = CourseSettingsSerializer(settings_context) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py index eafc2b37aa0..189f2496a42 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_course_index.py @@ -1,6 +1,7 @@ """ Unit tests for course index outline. """ +from django.conf import settings from django.test import RequestFactory from django.urls import reverse from rest_framework import status @@ -62,7 +63,7 @@ def test_course_index_response(self): "advance_settings_url": f"/settings/advanced/{self.course.id}" }, "discussions_incontext_feedback_url": "", - "discussions_incontext_learnmore_url": "", + "discussions_incontext_learnmore_url": settings.DISCUSSIONS_INCONTEXT_LEARNMORE_URL, "is_custom_relative_dates_active": True, "initial_state": None, "initial_user_clipboard": { @@ -103,7 +104,7 @@ def test_course_index_response_with_show_locators(self): "advance_settings_url": f"/settings/advanced/{self.course.id}" }, "discussions_incontext_feedback_url": "", - "discussions_incontext_learnmore_url": "", + "discussions_incontext_learnmore_url": settings.DISCUSSIONS_INCONTEXT_LEARNMORE_URL, "is_custom_relative_dates_active": False, "initial_state": { "expanded_locators": [ diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py index 1b8bfaa8472..69eee524373 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_home.py @@ -33,28 +33,24 @@ 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, "allowed_organizations": [], "archived_courses": [], + "can_access_advanced_settings": True, "can_create_organizations": True, "course_creator_status": "granted", "courses": [], "in_process_course_actions": [], "libraries": [], "libraries_enabled": True, + "libraries_v1_enabled": True, + "libraries_v2_enabled": False, "taxonomies_enabled": True, - "library_authoring_mfe_url": settings.LIBRARY_AUTHORING_MICROFRONTEND_URL, "taxonomy_list_mfe_url": 'http://course-authoring-mfe/taxonomies', "optimization_enabled": False, - "redirect_to_library_authoring_mfe": False, "request_course_creator_url": "/request_course_creator", "rerun_creator_status": True, "show_new_library_button": True, @@ -67,6 +63,21 @@ def test_home_page_courses_response(self): "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) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py index 66b5f46128c..8e220a334c0 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_proctoring.py @@ -277,18 +277,14 @@ def test_update_exam_settings_invalid_value(self): # response is correct assert response.status_code == status.HTTP_400_BAD_REQUEST - self.assertDictEqual( - response.data, + self.assertIn( { - "detail": [ - { - "proctoring_provider": ( - "The selected proctoring provider, notvalidprovider, is not a valid provider. " - "Please select from one of ['test_proctoring_provider']." - ) - } - ] + "proctoring_provider": ( + "The selected proctoring provider, notvalidprovider, is not a valid provider. " + "Please select from one of ['test_proctoring_provider']." + ) }, + response.data['detail'], ) # course settings have been updated @@ -408,18 +404,14 @@ def test_400_for_disabled_lti(self): # response is correct assert response.status_code == status.HTTP_400_BAD_REQUEST - self.assertDictEqual( - response.data, + self.assertIn( { - "detail": [ - { - "proctoring_provider": ( - "The selected proctoring provider, lti_external, is not a valid provider. " - "Please select from one of ['null']." - ) - } - ] + "proctoring_provider": ( + "The selected proctoring provider, lti_external, is not a valid provider. " + "Please select from one of ['null']." + ) }, + response.data['detail'], ) # course settings have been updated diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_settings.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_settings.py index 5365443c47d..15b0992fdf1 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_settings.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_settings.py @@ -55,7 +55,6 @@ def test_course_settings_response(self): "show_min_grade_warning": False, "upgrade_deadline": None, "licensing_enabled": False, - "use_v2_cert_display_settings": False, } self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py index d3fc3719821..7cac074a433 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/tests/test_vertical_block.py @@ -70,6 +70,8 @@ def setup_xblock(self): parent=self.vertical.location, category="html", display_name="Html Content 2", + upstream="lb:FakeOrg:FakeLib:html:FakeBlock", + upstream_version=5, ) def create_block(self, parent, category, display_name, **kwargs): @@ -193,6 +195,7 @@ def test_children_content(self): "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 @@ def test_children_content(self): "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): diff --git a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py index 670b94afbbe..0798c341cc1 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py +++ b/cms/djangoapps/contentstore/rest_api/v1/views/vertical_block.py @@ -20,6 +20,7 @@ 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 @@ def get(self, request: Request, usage_key_string: str): "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 @@ def get(self, request: Request, usage_key_string: str): "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 @@ def get(self, request: Request, usage_key_string: str): "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 @@ def get(self, request: Request, usage_key_string: str): 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 @@ def get(self, request: Request, usage_key_string: str): "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, }) diff --git a/cms/djangoapps/contentstore/rest_api/v2/urls.py b/cms/djangoapps/contentstore/rest_api/v2/urls.py index ad61cc93701..3e653d07fbc 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v2/urls.py @@ -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" + ), ] diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v2/views/__init__.py index 73ddde98440..e69de29bb2d 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/__init__.py @@ -1,3 +0,0 @@ -"""Module for v2 views.""" - -from cms.djangoapps.contentstore.rest_api.v2.views.home import HomePageCoursesViewV2 diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py new file mode 100644 index 00000000000..8c0d651910a --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py @@ -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 diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py new file mode 100644 index 00000000000..92da28bde98 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py @@ -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 diff --git a/cms/djangoapps/contentstore/tests/test_filters.py b/cms/djangoapps/contentstore/tests/test_filters.py new file mode 100644 index 00000000000..13bdfa07473 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_filters.py @@ -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)) diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index 450040c8037..2c5be351fc5 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -9,6 +9,7 @@ from django.conf import settings from django.test import TestCase from django.test.utils import override_settings +from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import CourseLocator, LibraryLocator from path import Path as path @@ -19,7 +20,11 @@ from cms.djangoapps.contentstore import utils from cms.djangoapps.contentstore.tasks import ALL_ALLOWED_XBLOCKS, validate_course_olx from cms.djangoapps.contentstore.tests.utils import TEST_DATA_DIR, CourseTestCase +from cms.djangoapps.contentstore.utils import send_course_update_notification +from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import GlobalStaffFactory, InstructorFactory, UserFactory +from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS +from openedx.core.djangoapps.notifications.models import CourseNotificationPreference, Notification from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration_context 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 @@ -927,3 +932,45 @@ def test_update_course_details_instructor_paced(self, mock_update): utils.update_course_details(mock_request, self.course.id, payload, None) mock_update.assert_called_once_with(self.course.id, payload, mock_request.user) + + +@override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) +class CourseUpdateNotificationTests(ModuleStoreTestCase): + """ + Unit tests for the course_update notification. + """ + + def setUp(self): + """ + Setup the test environment. + """ + super().setUp() + self.user = UserFactory() + self.course = CourseFactory.create(org='testorg', number='testcourse', run='testrun') + CourseNotificationPreference.objects.create(user_id=self.user.id, course_id=self.course.id) + + def test_course_update_notification_sent(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 = "

content

" + send_course_update_notification(self.course.id, content, self.user) + assert Notification.objects.all().count() == 1 + notification = Notification.objects.first() + assert notification.content == "

content

" + + 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 = "

content

Sub content

heading

" + send_course_update_notification(self.course.id, content, self.user) + assert Notification.objects.all().count() == 1 + notification = Notification.objects.first() + assert notification.content == "

content Sub content heading

" diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index 7c3a369fed6..79c722e24d5 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,76 @@ 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() + ) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index b268bd6fcb5..964f0c57e75 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -11,16 +11,19 @@ from urllib.parse import quote_plus from uuid import uuid4 +from bs4 import BeautifulSoup from django.conf import settings from django.core.exceptions import ValidationError from django.urls import reverse from django.utils import translation +from django.utils.text import Truncator from django.utils.translation import gettext as _ from eventtracking import tracker from help_tokens.core import HelpUrlExpert from lti_consumer.models import CourseAllowPIISharingInLTIFlag from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import LibraryLocator + from openedx.core.lib.teams_config import CONTENT_GROUPS_FOR_TEAMS, TEAM_SCHEME from openedx_events.content_authoring.data import DuplicatedXBlockData from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED @@ -31,7 +34,32 @@ 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 @@ -72,30 +100,7 @@ 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 LibraryToolsService +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 from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order @@ -424,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. @@ -1262,7 +1279,7 @@ def load_services_for_studio(runtime, user): "settings": SettingsService(), "lti-configuration": ConfigurationService(CourseAllowPIISharingInLTIFlag), "teams_configuration": TeamsConfigurationService(), - "library_tools": LibraryToolsService(modulestore(), user.id) + "library_tools": LegacyLibraryToolsService(modulestore(), user.id) } runtime._services.update(services) # lint-amnesty, pylint: disable=protected-access @@ -1533,10 +1550,10 @@ 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, ) - 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], } @@ -1546,8 +1563,8 @@ def get_library_context(request, request_is_json=False): **data, 'in_process_course_actions': [], 'courses': [], - 'libraries_enabled': LIBRARIES_ENABLED, - 'show_new_library_button': LIBRARIES_ENABLED and request.user.is_active, + 'libraries_enabled': libraries_v1_enabled(), + 'show_new_library_button': user_can_view_create_library_button(request.user) and request.user.is_active, 'user': request.user, 'request_course_creator_url': reverse('request_course_creator'), 'course_creator_status': _get_course_creator_status(request.user), @@ -1667,9 +1684,6 @@ def get_home_context(request, no_course=False): ENABLE_GLOBAL_STAFF_OPTIMIZATION, ) from cms.djangoapps.contentstore.views.library import ( - LIBRARY_AUTHORING_MICROFRONTEND_URL, - LIBRARIES_ENABLED, - should_redirect_to_library_authoring_mfe, user_can_view_create_library_button, ) @@ -1685,7 +1699,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 = { @@ -1693,14 +1707,13 @@ 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(), - 'redirect_to_library_authoring_mfe': should_redirect_to_library_authoring_mfe(), - 'library_authoring_mfe_url': LIBRARY_AUTHORING_MICROFRONTEND_URL, 'taxonomy_list_mfe_url': get_taxonomy_list_url(), 'libraries': libraries, - 'show_new_library_button': user_can_view_create_library_button(user) - and not should_redirect_to_library_authoring_mfe(), + 'show_new_library_button': user_can_view_create_library_button(user), 'user': user, 'request_course_creator_url': reverse('request_course_creator'), 'course_creator_status': _get_course_creator_status(user), @@ -1712,6 +1725,7 @@ def get_home_context(request, no_course=False): 'allowed_organizations': get_allowed_organizations(user), 'allowed_organizations_for_libraries': get_allowed_organizations_for_libraries(user), 'can_create_organizations': user_can_create_organizations(user), + 'can_access_advanced_settings': auth.has_studio_advanced_settings_access(user), } return home_context @@ -2041,6 +2055,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 @@ -2197,7 +2212,7 @@ class StudioPermissionsService: Deprecated. To be replaced by a more general authorization service. - Only used by LibraryContentBlock (and library_tools.py). + Only used by LegacyLibraryContentBlock (and library_tools.py). """ def __init__(self, user): @@ -2239,11 +2254,21 @@ def track_course_update_event(course_key, user, course_update_content=None): tracker.emit(event_name, event_data) +def clean_html_body(html_body): + """ + Get html body, remove tags and limit to 500 characters + """ + html_body = BeautifulSoup(Truncator(html_body).chars(500, html=True), 'html.parser') + 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): """ Send course update notification """ - text_content = re.sub(r"(\s| |//)+", " ", html_to_text(content)) + text_content = re.sub(r"(\s| |//)+", " ", clean_html_body(content)) course = modulestore().get_course(course_key) extra_context = { 'author_id': user.id, @@ -2252,10 +2277,10 @@ def send_course_update_notification(course_key, content, user): notification_data = CourseNotificationData( course_key=course_key, content_context={ - "course_update_content": text_content if len(text_content.strip()) < 10 else "Click here to view", + "course_update_content": text_content, **extra_context, }, - notification_type="course_update", + notification_type="course_updates", content_url=f"{settings.LMS_ROOT_URL}/courses/{str(course_key)}/course/updates", app_name="updates", audience_filters={}, diff --git a/cms/djangoapps/contentstore/views/block.py b/cms/djangoapps/contentstore/views/block.py index 4d6c17838e5..e6b41dc261d 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 ba767df78dc..46f2dd322ef 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -11,6 +11,7 @@ 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,7 @@ 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_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 +36,24 @@ __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 = [ + 'discussion', + 'library', + 'library_v2', # Not an XBlock + 'itembank', + 'html', + 'openassessment', + 'problem', + 'video', + 'drag-and-drop-v2', +] ADVANCED_COMPONENT_TYPES = sorted({name for name, class_ in XBlock.load_classes()} - set(COMPONENT_TYPES)) @@ -56,7 +68,7 @@ "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 +107,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 +157,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 +261,9 @@ def create_support_legend_dict(): '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"), } @@ -245,7 +293,7 @@ def create_support_legend_dict(): 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) @@ -440,6 +488,9 @@ 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_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/library.py b/cms/djangoapps/contentstore/views/library.py index 870c192653d..92e4329c2f9 100644 --- a/cms/djangoapps/contentstore/views/library.py +++ b/cms/djangoapps/contentstore/views/library.py @@ -41,8 +41,8 @@ ) from common.djangoapps.util.json_request import JsonResponse, JsonResponseBadRequest, expect_json -from ..config.waffle import REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND 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 @@ -51,52 +51,11 @@ log = logging.getLogger(__name__) -LIBRARIES_ENABLED = settings.FEATURES.get('ENABLE_CONTENT_LIBRARIES', False) -ENABLE_LIBRARY_AUTHORING_MICROFRONTEND = settings.FEATURES.get('ENABLE_LIBRARY_AUTHORING_MICROFRONTEND', False) -LIBRARY_AUTHORING_MICROFRONTEND_URL = settings.LIBRARY_AUTHORING_MICROFRONTEND_URL - -def should_redirect_to_library_authoring_mfe(): - """ - Boolean helper method, returns whether or not to redirect to the Library - Authoring MFE based on settings and flags. - """ - - return ( - ENABLE_LIBRARY_AUTHORING_MICROFRONTEND and - LIBRARY_AUTHORING_MICROFRONTEND_URL and - REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND.is_enabled() - ) - - -def user_can_view_create_library_button(user): - """ - Helper method for displaying the visibilty of the create_library_button. - """ - if not LIBRARIES_ENABLED: - return False - elif user.is_staff: - return True - elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False): - is_course_creator = get_course_creator_status(user) == 'granted' - has_org_staff_role = OrgStaffRole().get_orgs_for_user(user).exists() - has_course_staff_role = UserBasedRole(user=user, role=CourseStaffRole.ROLE).courses_with_role().exists() - has_course_admin_role = UserBasedRole(user=user, role=CourseInstructorRole.ROLE).courses_with_role().exists() - return is_course_creator or has_org_staff_role or has_course_staff_role or has_course_admin_role - else: - # EDUCATOR-1924: DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION, if present. - disable_library_creation = settings.FEATURES.get('DISABLE_LIBRARY_CREATION', None) - disable_course_creation = settings.FEATURES.get('DISABLE_COURSE_CREATION', False) - if disable_library_creation is not None: - return not disable_library_creation - else: - return not disable_course_creation - - -def user_can_create_library(user, org): +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. @@ -109,29 +68,29 @@ def user_can_create_library(user, org): 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 org is None: - return False - if not LIBRARIES_ENABLED: + if not libraries_v1_enabled(): return False elif user.is_staff: return True - if settings.FEATURES.get('ENABLE_CREATOR_GROUP', False): + elif settings.FEATURES.get('ENABLE_CREATOR_GROUP', False): + org_filter_params = {} + if org: + org_filter_params['org'] = org is_course_creator = get_course_creator_status(user) == 'granted' - has_org_staff_role = org in OrgStaffRole().get_orgs_for_user(user) + has_org_staff_role = OrgStaffRole().get_orgs_for_user(user).filter(**org_filter_params).exists() has_course_staff_role = ( UserBasedRole(user=user, role=CourseStaffRole.ROLE) .courses_with_role() - .filter(org=org) + .filter(**org_filter_params) .exists() ) has_course_admin_role = ( UserBasedRole(user=user, role=CourseInstructorRole.ROLE) .courses_with_role() - .filter(org=org) + .filter(**org_filter_params) .exists() ) return is_course_creator or has_org_staff_role or has_course_staff_role or has_course_admin_role - else: # EDUCATOR-1924: DISABLE_LIBRARY_CREATION overrides DISABLE_COURSE_CREATION, if present. disable_library_creation = settings.FEATURES.get('DISABLE_LIBRARY_CREATION', None) @@ -142,6 +101,22 @@ def user_can_create_library(user, org): return not disable_course_creation +def user_can_view_create_library_button(user): + """ + Helper method for displaying the visibilty of the create_library_button. + """ + return _user_can_create_library_for_org(user) + + +def user_can_create_library(user, org): + """ + Helper method for to check if user can create library for given org. + """ + if org is None: + return False + return _user_can_create_library_for_org(user, org) + + @login_required @ensure_csrf_cookie @require_http_methods(('GET', 'POST')) @@ -149,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 acc5fc95dfe..aa7421bb87b 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 fc119c7edd5..f3e20b45b2e 100644 --- a/cms/djangoapps/contentstore/views/tests/test_block.py +++ b/cms/djangoapps/contentstore/views/tests/test_block.py @@ -982,7 +982,7 @@ def test_shallow_duplicate(self): def test_duplicate_library_content_block(self): # pylint: disable=too-many-statements """ - Test the LibraryContentBlock's special duplication process. + Test the LegacyLibraryContentBlock's special duplication process. """ store = modulestore() @@ -3674,14 +3674,15 @@ def test_special_exam_xblock_info( @patch_does_backend_support_onboarding @patch_get_exam_by_content_id_success @ddt.data( - ("lti_external", False), - ("other_proctoring_backend", True), + ("lti_external", False, None), + ("other_proctoring_backend", True, "test_url"), ) @ddt.unpack - def test_support_onboarding_is_correct_depending_on_lti_external( + def test_proctoring_values_correct_depending_on_lti_external( self, external_id, - expected_value, + expected_supports_onboarding_value, + expected_proctoring_link, mock_get_exam_by_content_id, mock_does_backend_support_onboarding, _mock_get_exam_configuration_dashboard_url, @@ -3691,8 +3692,9 @@ def test_support_onboarding_is_correct_depending_on_lti_external( category="sequential", display_name="Test Lesson 1", user_id=self.user.id, - is_proctored_enabled=False, - is_time_limited=False, + is_proctored_enabled=True, + is_time_limited=True, + default_time_limit_minutes=100, is_onboarding_exam=False, ) @@ -3709,7 +3711,8 @@ def test_support_onboarding_is_correct_depending_on_lti_external( include_children_predicate=ALWAYS, course=self.course, ) - assert xblock_info["supports_onboarding"] is expected_value + assert xblock_info["supports_onboarding"] is expected_supports_onboarding_value + assert xblock_info["proctoring_exam_configuration_link"] == expected_proctoring_link @patch_get_exam_configuration_dashboard_url @patch_does_backend_support_onboarding @@ -3773,6 +3776,42 @@ def test_xblock_was_never_proctortrack_proctored_exam( assert xblock_info["was_exam_ever_linked_with_external"] is False assert mock_get_exam_by_content_id.call_count == 1 + @patch_get_exam_configuration_dashboard_url + @patch_does_backend_support_onboarding + @patch_get_exam_by_content_id_success + def test_special_exam_xblock_info_get_dashboard_error( + self, + mock_get_exam_by_content_id, + _mock_does_backend_support_onboarding, + mock_get_exam_configuration_dashboard_url, + ): + sequential = BlockFactory.create( + parent_location=self.chapter.location, + category="sequential", + display_name="Test Lesson 1", + user_id=self.user.id, + is_proctored_enabled=True, + is_time_limited=True, + default_time_limit_minutes=100, + is_onboarding_exam=False, + ) + sequential = modulestore().get_item(sequential.location) + mock_get_exam_configuration_dashboard_url.side_effect = Exception("proctoring error") + xblock_info = create_xblock_info( + sequential, + include_child_info=True, + include_children_predicate=ALWAYS, + ) + + # no errors should be raised and proctoring_exam_configuration_link is None + assert xblock_info["is_proctored_exam"] is True + assert xblock_info["was_exam_ever_linked_with_external"] is True + assert xblock_info["is_time_limited"] is True + assert xblock_info["default_time_limit_minutes"] == 100 + assert xblock_info["proctoring_exam_configuration_link"] is None + assert xblock_info["supports_onboarding"] is True + assert xblock_info["is_onboarding_exam"] is False + class TestLibraryXBlockInfo(ModuleStoreTestCase): """ diff --git a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py index 76dba57f1d6..a818da81d10 100644 --- a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py +++ b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py @@ -10,7 +10,7 @@ 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 +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 @@ -165,12 +165,12 @@ def _setup_tagged_content(self, course_key) -> dict: publish_item=True, ).location - library = ClipboardLibraryContentPasteTestCase.setup_library() + library = ClipboardPasteFromV1LibraryTestCase.setup_library() with self.store.bulk_operations(course_key): library_content_block_key = BlockFactory.create( parent=self.store.get_item(unit_key), category="library_content", - source_library_id=str(library.key), + source_library_id=str(library.context_key), display_name="LC Block", publish_item=True, ).location @@ -184,7 +184,7 @@ def _setup_tagged_content(self, course_key) -> dict: 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, @@ -393,9 +393,9 @@ def test_paste_with_assets(self): assert source_pic2_hash != dest_pic2_hash # Because there was a conflict, this file was unchanged. -class ClipboardLibraryContentPasteTestCase(ModuleStoreTestCase): +class ClipboardPasteFromV2LibraryTestCase(ModuleStoreTestCase): """ - Test Clipboard Paste functionality with library content + Test Clipboard Paste functionality with a "new" (as of Sumac) library """ def setUp(self): @@ -406,14 +406,178 @@ def setUp(self): self.client = APIClient() self.client.login(username=self.user.username, password=self.user_password) self.store = modulestore() - library = self.setup_library() + + self.library = library_api.create_library( + library_type=library_api.COMPLEX, + 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, """ + + + + + Wrong + Right + + + + """) # v==2 + library_api.publish_changes(self.library.key) + library_api.set_library_block_olx(self.lib_block_key, """ + + + + + Wrong + Right + + + + """) # 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_creates_link(self): + """ + When we copy a v2 lib block into a course, the dest block should be linked up to the lib block. + """ + 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) + 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 + + 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, """ + +

Including this totally real image:

+ + + + Wrong + Right + + +
+ """) # 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) + + +class ClipboardPasteFromV1LibraryTestCase(ModuleStoreTestCase): + """ + Test Clipboard Paste functionality with legacy (v1) library content + """ + + def setUp(self): + """ + Set up a v1 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 = self.setup_library() # Create a library content block (lc), point it out our library, and sync it. self.course = CourseFactory.create(display_name='Course') self.orig_lc_block = BlockFactory.create( parent=self.course, category="library_content", - source_library_id=str(library.key), + source_library_id=str(self.library.context_key), display_name="LC Block", publish_item=False, ) @@ -426,18 +590,15 @@ def setUp(self): @classmethod def setup_library(cls): """ - Creates and returns a content library. + Creates and returns a legacy content library with 1 problem """ - library = library_api.create_library( - library_type=library_api.COMPLEX, - org=Organization.objects.create(name="Test Org", short_name="CL-TEST"), - slug="lib", - title="Library", - ) - # Populate it with a problem: - problem_key = library_api.create_library_block(library.key, "problem", "p1").usage_key - library_api.set_library_block_olx(problem_key, """ - + library = LibraryFactory.create(display_name='Library') + lib_block = BlockFactory.create( + parent_location=library.usage_key, + category="problem", + display_name="MCQ", + max_attempts=1, + data=""" @@ -445,9 +606,9 @@ def setup_library(cls): Right - - """) - library_api.publish_changes(library.key) + """, + publish_item=False, + ) return library def test_paste_library_content_block(self): diff --git a/cms/djangoapps/contentstore/views/tests/test_container_page.py b/cms/djangoapps/contentstore/views/tests/test_container_page.py index 1d5b5290535..426477e2340 100644 --- a/cms/djangoapps/contentstore/views/tests/test_container_page.py +++ b/cms/djangoapps/contentstore/views/tests/test_container_page.py @@ -242,3 +242,61 @@ def test_container_page_with_valid_and_invalid_usage_key_string(self): 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=( + '