diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index a05b78e883..fc452f7acd 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -15,6 +15,7 @@ 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
@@ -22,6 +23,7 @@ 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/
@@ -36,16 +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/branding/ @openedx/2U-aperture
-lms/djangoapps/commerce/
-lms/djangoapps/experiments/ @openedx/2U-aperture
-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 a3b0527aad..458e00fc6b 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-latest"]
+ os: ["ubuntu-22.04"]
steps:
- uses: actions/checkout@v4
diff --git a/.github/workflows/migrations-check.yml b/.github/workflows/migrations-check.yml
index f253d48e4f..624caddd53 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-latest]
+ os: [ubuntu-22.04]
python-version:
- "3.11"
# 'pinned' is used to install the latest patch version of Django
@@ -126,7 +126,7 @@ jobs:
if: always()
needs:
- check_migrations
- runs-on: ubuntu-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/pylint-checks.yml b/.github/workflows/pylint-checks.yml
index 144cc77a3d..8860aced7f 100644
--- a/.github/workflows/pylint-checks.yml
+++ b/.github/workflows/pylint-checks.yml
@@ -8,25 +8,25 @@ on:
jobs:
run-pylint:
- runs-on: ubuntu-latest
+ 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/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 5445d70e3b..8461012349 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-latest]
+ os: [ubuntu-22.04]
python-version:
- "3.11"
node-version: [20]
diff --git a/.github/workflows/static-assets-check.yml b/.github/workflows/static-assets-check.yml
index 0a417f9b1c..4fe66e2a77 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-latest]
+ os: [ubuntu-22.04]
python-version:
- "3.11"
node-version: [18, 20]
diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml
index 5fef1c8352..854677b93c 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-latest
+ runs-on: ubuntu-22.04
strategy:
matrix:
python-version:
@@ -164,7 +164,7 @@ jobs:
overwrite: true
collect-and-verify:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v4
- name: Setup Python
@@ -229,7 +229,7 @@ jobs:
# https://github.com/orgs/community/discussions/33579
success:
name: Unit tests successful
- runs-on: ubuntu-latest
+ runs-on: ubuntu-22.04
if: always()
needs: [run-tests]
steps:
@@ -240,7 +240,7 @@ jobs:
jobs: ${{ toJSON(needs) }}
compile-warnings-report:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-22.04
needs: [run-tests]
steps:
- uses: actions/checkout@v4
@@ -268,7 +268,7 @@ jobs:
overwrite: true
merge-artifacts:
- runs-on: ubuntu-latest
+ runs-on: ubuntu-22.04
needs: [compile-warnings-report]
steps:
- name: Merge Pytest Warnings JSON Artifacts
@@ -288,7 +288,7 @@ jobs:
# Combine and upload coverage reports.
coverage:
if: (github.repository == 'edx/edx-platform-private') || (github.repository == 'openedx/edx-platform' && (startsWith(github.base_ref, 'open-release') == false))
- runs-on: ubuntu-latest
+ runs-on: ubuntu-22.04
needs: [run-tests]
strategy:
matrix:
diff --git a/cms/djangoapps/contentstore/config/waffle.py b/cms/djangoapps/contentstore/config/waffle.py
index f84290ba83..ae0e6ea467 100644
--- a/cms/djangoapps/contentstore/config/waffle.py
+++ b/cms/djangoapps/contentstore/config/waffle.py
@@ -4,7 +4,7 @@ waffle switches for the contentstore app.
"""
-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 @@ SHOW_REVIEW_RULES_FLAG = CourseWaffleFlag( # lint-amnesty, pylint: disable=togg
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/rest_api/v1/serializers/home.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py
index 0aa06d8b8d..4c3e2a4321 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py
@@ -59,10 +59,8 @@ class CourseHomeSerializer(serializers.Serializer):
libraries = LibraryViewSerializer(many=True, required=False, allow_null=True)
libraries_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/views/home.py b/cms/djangoapps/contentstore/rest_api/v1/views/home.py
index d72042cff6..ff476090ee 100644
--- a/cms/djangoapps/contentstore/rest_api/v1/views/home.py
+++ b/cms/djangoapps/contentstore/rest_api/v1/views/home.py
@@ -59,9 +59,7 @@ class HomePageView(APIView):
"in_process_course_actions": [],
"libraries": [],
"libraries_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,
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 a8b4cf5e39..c3c9652e5d 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
@@ -52,10 +52,8 @@ class HomePageViewTest(CourseTestCase):
"libraries": [],
"libraries_enabled": True,
"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,
diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py
index 214193918e..035e44d5ce 100644
--- a/cms/djangoapps/contentstore/utils.py
+++ b/cms/djangoapps/contentstore/utils.py
@@ -98,7 +98,7 @@ from cms.djangoapps.contentstore.toggles import (
)
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
@@ -1265,7 +1265,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
@@ -1671,9 +1671,7 @@ 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,
)
@@ -1699,12 +1697,9 @@ def get_home_context(request, no_course=False):
'in_process_course_actions': in_process_course_actions,
'libraries_enabled': LIBRARIES_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),
@@ -2202,7 +2197,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):
diff --git a/cms/djangoapps/contentstore/views/library.py b/cms/djangoapps/contentstore/views/library.py
index 8c314caa66..340cadb4e2 100644
--- a/cms/djangoapps/contentstore/views/library.py
+++ b/cms/djangoapps/contentstore/views/library.py
@@ -41,7 +41,6 @@ from common.djangoapps.student.roles import (
)
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 .component import CONTAINER_TEMPLATES, get_component_templates
from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import create_xblock_info
@@ -52,21 +51,6 @@ __all__ = ['library_handler', 'manage_library_users']
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_create_library_for_org(user, org=None):
diff --git a/cms/djangoapps/contentstore/views/tests/test_block.py b/cms/djangoapps/contentstore/views/tests/test_block.py
index 80a2535598..f3e20b45b2 100644
--- a/cms/djangoapps/contentstore/views/tests/test_block.py
+++ b/cms/djangoapps/contentstore/views/tests/test_block.py
@@ -982,7 +982,7 @@ class TestDuplicateItem(ItemTest, DuplicateHelper, OpenEdxEventsTestMixin):
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()
diff --git a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py
index 76dba57f1d..7979a422a3 100644
--- a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py
+++ b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py
@@ -7,13 +7,11 @@ import ddt
from opaque_keys.edx.keys import UsageKey
from rest_framework.test import APIClient
from openedx_tagging.core.tagging.models import Tag
-from organizations.models import Organization
from xmodule.modulestore.django import contentstore, modulestore
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, upload_file_to_course
-from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory
+from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory, ToyCourseFactory, LibraryFactory
from cms.djangoapps.contentstore.utils import reverse_usage_url
-from openedx.core.djangoapps.content_libraries import api as library_api
from openedx.core.djangoapps.content_tagging import api as tagging_api
CLIPBOARD_ENDPOINT = "/api/content-staging/v1/clipboard/"
@@ -165,12 +163,12 @@ class ClipboardPasteTestCase(ModuleStoreTestCase):
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
@@ -393,27 +391,27 @@ class ClipboardPasteTestCase(ModuleStoreTestCase):
assert source_pic2_hash != dest_pic2_hash # Because there was a conflict, this file was unchanged.
-class ClipboardLibraryContentPasteTestCase(ModuleStoreTestCase):
+class ClipboardPasteFromV1LibraryTestCase(ModuleStoreTestCase):
"""
- Test Clipboard Paste functionality with library content
+ Test Clipboard Paste functionality with legacy (v1) library content
"""
def setUp(self):
"""
- Set up a v2 Content Library and a library content block
+ 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()
- library = self.setup_library()
+ 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 +424,15 @@ class ClipboardLibraryContentPasteTestCase(ModuleStoreTestCase):
@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="""
Q
@@ -445,9 +440,9 @@ class ClipboardLibraryContentPasteTestCase(ModuleStoreTestCase):
Right
-
- """)
- library_api.publish_changes(library.key)
+ """,
+ publish_item=False,
+ )
return library
def test_paste_library_content_block(self):
diff --git a/cms/envs/common.py b/cms/envs/common.py
index 7521cc21fa..63221ee0b0 100644
--- a/cms/envs/common.py
+++ b/cms/envs/common.py
@@ -434,18 +434,6 @@ FEATURES = {
# .. toggle_tickets: https://openedx.atlassian.net/browse/DEPR-58
'DEPRECATE_OLD_COURSE_KEYS_IN_STUDIO': True,
- # .. toggle_name: FEATURES['ENABLE_LIBRARY_AUTHORING_MICROFRONTEND']
- # .. toggle_implementation: DjangoSetting
- # .. toggle_default: False
- # .. toggle_description: Set to True to enable the Library Authoring MFE
- # .. toggle_use_cases: temporary
- # .. toggle_creation_date: 2020-06-20
- # .. toggle_target_removal_date: 2020-12-31
- # .. toggle_tickets: https://openedx.atlassian.net/wiki/spaces/OEPM/pages/4106944527/Libraries+Relaunch+Proposal+For+Product+Review
- # .. toggle_warning: Also set settings.LIBRARY_AUTHORING_MICROFRONTEND_URL and see
- # REDIRECT_TO_LIBRARY_AUTHORING_MICROFRONTEND for rollout.
- 'ENABLE_LIBRARY_AUTHORING_MICROFRONTEND': False,
-
# .. toggle_name: FEATURES['DISABLE_COURSE_CREATION']
# .. toggle_implementation: DjangoSetting
# .. toggle_default: False
@@ -601,7 +589,6 @@ IDA_LOGOUT_URI_LIST = []
COURSE_AUTHORING_MICROFRONTEND_URL = None
DISCUSSIONS_MICROFRONTEND_URL = None
DISCUSSIONS_MFE_FEEDBACK_URL = None
-LIBRARY_AUTHORING_MICROFRONTEND_URL = None
# .. toggle_name: ENABLE_AUTHN_RESET_PASSWORD_HIBP_POLICY
# .. toggle_implementation: DjangoSetting
# .. toggle_default: False
@@ -2779,6 +2766,7 @@ WIKI_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-runni
CUSTOM_PAGES_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/pages.html#adding-custom-pages"
COURSE_LIVE_HELP_URL = "https://edx.readthedocs.io/projects/edx-partner-course-staff/en/latest/course_assets/course_live.html"
ORA_SETTINGS_HELP_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/course_assets/pages.html#configuring-course-level-open-response-assessment-settings"
+# pylint: enable=line-too-long
# keys for big blue button live provider
COURSE_LIVE_GLOBAL_CREDENTIALS = {}
@@ -2810,6 +2798,7 @@ DISCUSSIONS_INCONTEXT_FEEDBACK_URL = ''
# Learn More link in upgraded discussion notification alert
# pylint: disable=line-too-long
DISCUSSIONS_INCONTEXT_LEARNMORE_URL = "https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/manage_discussions/discussions.html"
+# pylint: enable=line-too-long
#### django-simple-history##
# disable indexing on date field its coming django-simple-history.
diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py
index 1d3a510cdc..1200a61b06 100644
--- a/cms/envs/devstack.py
+++ b/cms/envs/devstack.py
@@ -174,9 +174,6 @@ FEATURES['ENABLE_ORGANIZATION_STAFF_ACCESS_FOR_CONTENT_LIBRARIES'] = True
################### FRONTEND APPLICATION PUBLISHER URL ###################
FEATURES['FRONTEND_APP_PUBLISHER_URL'] = 'http://localhost:18400'
-################### FRONTEND APPLICATION LIBRARY AUTHORING ###################
-LIBRARY_AUTHORING_MICROFRONTEND_URL = 'http://localhost:3001'
-
################### FRONTEND APPLICATION COURSE AUTHORING ###################
COURSE_AUTHORING_MICROFRONTEND_URL = 'http://localhost:2001'
diff --git a/cms/envs/test.py b/cms/envs/test.py
index 38b7c78171..49db506088 100644
--- a/cms/envs/test.py
+++ b/cms/envs/test.py
@@ -333,3 +333,13 @@ COURSE_LIVE_GLOBAL_CREDENTIALS["BIG_BLUE_BUTTON"] = {
"SECRET": "***",
"URL": "***",
}
+
+############## openedx-learning (Learning Core) config ##############
+OPENEDX_LEARNING = {
+ 'MEDIA': {
+ 'BACKEND': 'django.core.files.storage.InMemoryStorage',
+ 'OPTIONS': {
+ 'location': MEDIA_ROOT + "_private"
+ }
+ }
+}
diff --git a/cms/templates/course_outline.html b/cms/templates/course_outline.html
index f44fdcfc80..61f524123b 100644
--- a/cms/templates/course_outline.html
+++ b/cms/templates/course_outline.html
@@ -76,14 +76,18 @@ from django.urls import reverse
${_("This course run is using an upgraded version of edx discussion forum. In order to display the discussions sidebar, discussions xBlocks will no longer be visible to learners.")}
diff --git a/cms/templates/index.html b/cms/templates/index.html
index 766d68da78..e558597307 100644
--- a/cms/templates/index.html
+++ b/cms/templates/index.html
@@ -348,9 +348,6 @@ from openedx.core.djangolib.js_utils import (
% endif
% if libraries_enabled:
- % if redirect_to_library_authoring_mfe:
- ${_("Libraries")}
- %else:
% if split_studio_home:
${_("Libraries")}
@@ -358,7 +355,6 @@ from openedx.core.djangolib.js_utils import (
${_("Libraries")}
% endif
- % endif
% endif
% if taxonomies_enabled:
${_("Taxonomies")}
diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py
index f49d1bc23b..d7b0698653 100644
--- a/common/djangoapps/course_modes/tests/test_views.py
+++ b/common/djangoapps/course_modes/tests/test_views.py
@@ -149,50 +149,6 @@ class CourseModeViewTest(CatalogIntegrationMixin, UrlResetMixin, ModuleStoreTest
self.assertRedirects(response, '/test_basket/add/?sku=TEST', fetch_redirect_response=False)
ecomm_test_utils.update_commerce_config(enabled=False)
- def test_verified_mode_response_contains_course_run_key(self):
- # Create only the verified mode and enroll the user
- CourseModeFactory.create(
- mode_slug='verified',
- course_id=self.course_that_started.id,
- min_price=149,
- sku="dummy"
- )
- CourseEnrollmentFactory(
- is_active=True,
- course_id=self.course_that_started.id,
- user=self.user
- )
-
- # Value Prop TODO (REV-2378): remove waffle flag from tests once the new Track Selection template is rolled out.
- with override_waffle_flag(VALUE_PROP_TRACK_SELECTION_FLAG, active=True):
- with patch(GATING_METHOD_NAME, return_value=True):
- with patch(CDL_METHOD_NAME, return_value=True):
- with patch("common.djangoapps.course_modes.views.EcommerceService.is_enabled", return_value=True):
- url = reverse('course_modes_choose', args=[str(self.course_that_started.id)])
- response = self.client.get(url)
- self.assertContains(response, "&course_run_key=")
- self.assertContains(response, self.course_that_started.id)
-
- def test_response_without_verified_sku_does_not_contain_course_run_key(self):
- CourseModeFactory.create(
- mode_slug='verified',
- course_id=self.course_that_started.id,
- )
- CourseEnrollmentFactory(
- is_active=True,
- course_id=self.course_that_started.id,
- user=self.user
- )
-
- # Value Prop TODO (REV-2378): remove waffle flag from tests once the new Track Selection template is rolled out.
- with override_waffle_flag(VALUE_PROP_TRACK_SELECTION_FLAG, active=True):
- with patch(GATING_METHOD_NAME, return_value=True):
- with patch(CDL_METHOD_NAME, return_value=True):
- with patch("common.djangoapps.course_modes.views.EcommerceService.is_enabled", return_value=True):
- url = reverse('course_modes_choose', args=[str(self.course_that_started.id)])
- response = self.client.get(url)
- self.assertNotContains(response, "&course_run_key=")
-
@httpretty.activate
@ddt.data(
'',
diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py
index 821c59dc05..759073a135 100644
--- a/common/djangoapps/course_modes/views.py
+++ b/common/djangoapps/course_modes/views.py
@@ -241,7 +241,7 @@ class ChooseModeView(View):
if verified_mode.sku:
context["use_ecommerce_payment_flow"] = ecommerce_service.is_enabled(request.user)
- context["ecommerce_payment_page"] = ecommerce_service.get_add_to_basket_url()
+ context["ecommerce_payment_page"] = ecommerce_service.payment_page_url()
context["sku"] = verified_mode.sku
context["bulk_sku"] = verified_mode.bulk_sku
diff --git a/cms/templates/content_libraries/xblock_iframe.html b/common/templates/xblock_v2/xblock_iframe.html
similarity index 99%
rename from cms/templates/content_libraries/xblock_iframe.html
rename to common/templates/xblock_v2/xblock_iframe.html
index b6e455f785..8b733373bd 100644
--- a/cms/templates/content_libraries/xblock_iframe.html
+++ b/common/templates/xblock_v2/xblock_iframe.html
@@ -156,7 +156,7 @@
-
+
{{ fragment.body_html | safe }}
diff --git a/docs/docs_settings.py b/docs/docs_settings.py
index 5bc9b15946..f12848876e 100644
--- a/docs/docs_settings.py
+++ b/docs/docs_settings.py
@@ -14,7 +14,6 @@ from cms.envs.common import ( # lint-amnesty, pylint: disable=unused-import
ADVANCED_PROBLEM_TYPES,
COURSE_IMPORT_EXPORT_STORAGE,
GIT_EXPORT_DEFAULT_IDENT,
- LIBRARY_AUTHORING_MICROFRONTEND_URL,
SCRAPE_YOUTUBE_THUMBNAILS_JOB_QUEUE,
VIDEO_TRANSCRIPT_MIGRATIONS_JOB_QUEUE,
UPDATE_SEARCH_INDEX_JOB_QUEUE,
diff --git a/lms/djangoapps/branding/tests/test_page.py b/lms/djangoapps/branding/tests/test_page.py
index 9904f79f64..46f10bd692 100644
--- a/lms/djangoapps/branding/tests/test_page.py
+++ b/lms/djangoapps/branding/tests/test_page.py
@@ -22,6 +22,7 @@ from lms.djangoapps.courseware.tests.helpers import LoginEnrollmentTestCase
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order
+from xmodule.course_block import CATALOG_VISIBILITY_ABOUT, CATALOG_VISIBILITY_NONE
FEATURES_WITH_STARTDATE = settings.FEATURES.copy()
FEATURES_WITH_STARTDATE['DISABLE_START_DATES'] = False
@@ -201,6 +202,20 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase):
display_name='Tech Beta Course',
emit_signals=True,
)
+ self.course_with_none_visibility = CourseFactory.create(
+ org='MITx',
+ number='1003',
+ catalog_visibility=CATALOG_VISIBILITY_NONE,
+ display_name='Course with "none" catalog visibility',
+ emit_signals=True,
+ )
+ self.course_with_about_visibility = CourseFactory.create(
+ org='MITx',
+ number='1003',
+ catalog_visibility=CATALOG_VISIBILITY_ABOUT,
+ display_name='Course with "about" catalog visibility',
+ emit_signals=True,
+ )
self.factory = RequestFactory()
@patch('common.djangoapps.student.views.management.render_to_response', RENDER_MOCK)
@@ -300,6 +315,15 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase):
assert context['courses'][1].id == self.starting_earlier.id
assert context['courses'][2].id == self.course_with_default_start_date.id
+ @patch('lms.djangoapps.courseware.views.views.render_to_response', RENDER_MOCK)
+ def test_invisible_courses_are_not_displayed(self):
+ response = self.client.get(reverse('courses'))
+ ((_template, context), _) = RENDER_MOCK.call_args # pylint: disable=unpacking-non-sequence
+
+ rendered_ids = [course.id for course in context["courses"]]
+ assert self.course_with_none_visibility.id not in rendered_ids
+ assert self.course_with_about_visibility.id not in rendered_ids
+
class IndexPageProgramsTests(SiteMixin, ModuleStoreTestCase):
"""
diff --git a/lms/djangoapps/commerce/tests/test_utils.py b/lms/djangoapps/commerce/tests/test_utils.py
index 6c793433df..d5d0cf1f1c 100644
--- a/lms/djangoapps/commerce/tests/test_utils.py
+++ b/lms/djangoapps/commerce/tests/test_utils.py
@@ -11,7 +11,6 @@ from django.conf import settings
from django.test import TestCase
from django.test.client import RequestFactory
from django.test.utils import override_settings
-from edx_toggles.toggles.testutils import override_waffle_flag
from opaque_keys.edx.locator import CourseLocator
from waffle.testutils import override_switch
@@ -21,7 +20,6 @@ from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.student.tests.factories import TEST_PASSWORD, UserFactory
from lms.djangoapps.commerce.models import CommerceConfiguration
from lms.djangoapps.commerce.utils import EcommerceService, refund_entitlement, refund_seat
-from lms.djangoapps.commerce.waffle import ENABLE_TRANSITION_TO_COORDINATOR_CHECKOUT
from openedx.core.djangolib.testing.utils import skip_unless_lms
from openedx.core.lib.log_utils import audit_log
from xmodule.modulestore.tests.django_utils import \
@@ -186,27 +184,6 @@ class EcommerceServiceTests(TestCase):
assert url == expected_url
- @override_settings(COMMERCE_COORDINATOR_URL_ROOT='http://coordinator_url')
- @override_settings(ECOMMERCE_PUBLIC_URL_ROOT='http://ecommerce_url')
- @ddt.data(
- {'coordinator_flag_active': True},
- {'coordinator_flag_active': False}
- )
- @ddt.unpack
- def test_get_add_to_basket_url(self, coordinator_flag_active):
- with override_waffle_flag(ENABLE_TRANSITION_TO_COORDINATOR_CHECKOUT, active=coordinator_flag_active):
-
- ecommerce_service = EcommerceService()
- result = ecommerce_service.get_add_to_basket_url()
-
- if coordinator_flag_active:
- expected_url = 'http://coordinator_url/lms/payment_page_redirect/'
- else:
- expected_url = 'http://ecommerce_url/test_basket/add/'
-
- self.assertIsNotNone(result)
- self.assertEqual(expected_url, result)
-
@ddt.ddt
@skip_unless_lms
diff --git a/lms/djangoapps/commerce/utils.py b/lms/djangoapps/commerce/utils.py
index 617852b4f6..9abcabd4a9 100644
--- a/lms/djangoapps/commerce/utils.py
+++ b/lms/djangoapps/commerce/utils.py
@@ -13,7 +13,6 @@ from django.urls import reverse
from django.utils.translation import gettext as _
from common.djangoapps.course_modes.models import CourseMode
-from common.djangoapps.student.models import CourseEnrollmentAttribute
from openedx.core.djangoapps.commerce.utils import (
get_ecommerce_api_base_url,
get_ecommerce_api_client,
@@ -23,10 +22,6 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_
from openedx.core.djangoapps.theming import helpers as theming_helpers
from .models import CommerceConfiguration
-from .waffle import ( # lint-amnesty, pylint: disable=invalid-django-waffle-import
- should_redirect_to_commerce_coordinator_checkout,
- should_redirect_to_commerce_coordinator_refunds,
-)
from edx_django_utils.plugins import pluggable_override
log = logging.getLogger(__name__)
@@ -110,15 +105,6 @@ class EcommerceService:
"""
return self.get_absolute_ecommerce_url(self.config.basket_checkout_page)
- def get_add_to_basket_url(self):
- """ Return the URL for the payment page based on the waffle switch.
- Example:
- http://localhost/enabled_service_api_path
- """
- if should_redirect_to_commerce_coordinator_checkout():
- return urljoin(settings.COMMERCE_COORDINATOR_URL_ROOT, settings.COORDINATOR_CHECKOUT_REDIRECT_PATH)
- return self.payment_page_url()
-
@pluggable_override('OVERRIDE_GET_CHECKOUT_PAGE_URL')
def get_checkout_page_url(self, *skus, **kwargs):
""" Construct the URL to the ecommerce checkout page and include products.
@@ -261,10 +247,6 @@ def refund_seat(course_enrollment, change_mode=False):
course_key_str = str(course_enrollment.course_id)
enrollee = course_enrollment.user
- if should_redirect_to_commerce_coordinator_refunds():
- if _refund_in_commerce_coordinator(course_enrollment, change_mode):
- return
-
service_user = User.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME)
api_client = get_ecommerce_api_client(service_user)
@@ -295,75 +277,6 @@ def refund_seat(course_enrollment, change_mode=False):
return refund_ids
-def _refund_in_commerce_coordinator(course_enrollment, change_mode):
- """
- Helper function to perform refund in Commerce Coordinator.
-
- Parameters:
- course_enrollment (CourseEnrollment): the enrollment to refund.
- change_mode (bool): whether the enrollment should be auto-enrolled into
- the default course mode after refund.
-
- Returns:
- bool: True if refund was performed. False if refund is not applicable
- to Commerce Coordinator.
- """
- enrollment_source_system = course_enrollment.get_order_attribute_value("source_system")
- course_key_str = str(course_enrollment.course_id)
-
- # Commerce Coordinator enrollments will have an orders.source_system enrollment attribute.
- # Redirect to Coordinator only if the source_system is safelisted as Coordinator's in settings.
-
- if enrollment_source_system and enrollment_source_system in settings.COMMERCE_COORDINATOR_REFUND_SOURCE_SYSTEMS:
- log.info('Redirecting refund to Commerce Coordinator for user [%s], course [%s]...',
- course_enrollment.user_id, course_key_str)
-
- # Re-use Ecommerce API client factory to build an API client for Commerce Coordinator...
- service_user = get_user_model().objects.get(
- username=settings.COMMERCE_COORDINATOR_SERVICE_WORKER_USERNAME
- )
- api_client = get_ecommerce_api_client(service_user)
- refunds_url = urljoin(
- settings.COMMERCE_COORDINATOR_URL_ROOT,
- settings.COMMERCE_COORDINATOR_REFUND_PATH
- )
-
- # Build request, raising exception if Coordinator returns non-200.
- enrollment_attributes = CourseEnrollmentAttribute.get_enrollment_attributes(course_enrollment)
-
- try:
- api_client.post(
- refunds_url,
- json={
- 'course_id': course_key_str,
- 'username': course_enrollment.username,
- 'enrollment_attributes': enrollment_attributes
- }
- ).raise_for_status()
-
- except Exception as exc: # pylint: disable=broad-except
- # Catch any possible exceptions from the Commerce Coordinator service to ensure we fail gracefully
- log.exception(
- "Unexpected exception while attempting to refund user in Coordinator [%s], "
- "course key [%s] message: [%s]",
- course_enrollment.username,
- course_key_str,
- str(exc)
- )
-
- # Refund was successfully sent to Commerce Coordinator
- log.info('Refund successfully sent to Commerce Coordinator for user [%s], course [%s].',
- course_enrollment.user_id, course_key_str)
- if change_mode:
- auto_enroll(course_enrollment)
- return True
- else:
- # Refund was not meant to be sent to Commerce Coordinator
- log.info('Continuing refund without Commerce Coordinator redirect for user [%s], course [%s]...',
- course_enrollment.user_id, course_key_str)
- return False
-
-
def auto_enroll(course_enrollment):
"""
Helper method to update an enrollment to a default course mode.
diff --git a/lms/djangoapps/commerce/waffle.py b/lms/djangoapps/commerce/waffle.py
deleted file mode 100644
index a36586a52d..0000000000
--- a/lms/djangoapps/commerce/waffle.py
+++ /dev/null
@@ -1,49 +0,0 @@
-"""
-Configuration for features of Commerce App
-"""
-from edx_toggles.toggles import WaffleFlag
-
-# Namespace for Commerce waffle flags.
-WAFFLE_FLAG_NAMESPACE = "commerce"
-
-# .. toggle_name: commerce.transition_to_coordinator.checkout
-# .. toggle_implementation: WaffleFlag
-# .. toggle_default: False
-# .. toggle_description: Allows to redirect checkout to Commerce Coordinator API
-# .. toggle_use_cases: temporary
-# .. toggle_creation_date: 2023-11-22
-# .. toggle_target_removal_date: TBA
-# .. toggle_tickets: SONIC-99
-# .. toggle_status: supported
-ENABLE_TRANSITION_TO_COORDINATOR_CHECKOUT = WaffleFlag(
- f"{WAFFLE_FLAG_NAMESPACE}.transition_to_coordinator.checkout",
- __name__,
-)
-
-# .. toggle_name: commerce.transition_to_coordinator.refund
-# .. toggle_implementation: WaffleFlag
-# .. toggle_default: False
-# .. toggle_description: Allows to redirect refunds to Commerce Coordinator API
-# .. toggle_use_cases: temporary
-# .. toggle_creation_date: 2024-03-26
-# .. toggle_target_removal_date: TBA
-# .. toggle_tickets: SONIC-382
-# .. toggle_status: supported
-ENABLE_TRANSITION_TO_COORDINATOR_REFUNDS = WaffleFlag(
- f"{WAFFLE_FLAG_NAMESPACE}.transition_to_coordinator.refunds",
- __name__,
-)
-
-
-def should_redirect_to_commerce_coordinator_checkout():
- """
- Redirect learners to Commerce Coordinator checkout.
- """
- return ENABLE_TRANSITION_TO_COORDINATOR_CHECKOUT.is_enabled()
-
-
-def should_redirect_to_commerce_coordinator_refunds():
- """
- Redirect learners to Commerce Coordinator refunds.
- """
- return ENABLE_TRANSITION_TO_COORDINATOR_REFUNDS.is_enabled()
diff --git a/lms/djangoapps/course_blocks/transformers/library_content.py b/lms/djangoapps/course_blocks/transformers/library_content.py
index 616cf68f4b..10ef8c2138 100644
--- a/lms/djangoapps/course_blocks/transformers/library_content.py
+++ b/lms/djangoapps/course_blocks/transformers/library_content.py
@@ -14,7 +14,7 @@ from openedx.core.djangoapps.content.block_structure.transformer import (
BlockStructureTransformer,
FilteringTransformerMixin
)
-from xmodule.library_content_block import LibraryContentBlock # lint-amnesty, pylint: disable=wrong-import-order
+from xmodule.library_content_block import LegacyLibraryContentBlock # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from ..utils import get_student_module_as_dict
@@ -47,7 +47,6 @@ class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransfo
Collects any information that's necessary to execute this
transformer's transform method.
"""
- block_structure.request_xblock_fields('mode')
block_structure.request_xblock_fields('max_count')
block_structure.request_xblock_fields('category')
store = modulestore()
@@ -83,7 +82,6 @@ class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransfo
if library_children:
all_library_children.update(library_children)
selected = []
- mode = block_structure.get_xblock_field(block_key, 'mode')
max_count = block_structure.get_xblock_field(block_key, 'max_count')
if max_count < 0:
max_count = len(library_children)
@@ -100,7 +98,7 @@ class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransfo
# Update selected
previous_count = len(selected)
- block_keys = LibraryContentBlock.make_selection(selected, library_children, max_count, mode)
+ block_keys = LegacyLibraryContentBlock.make_selection(selected, library_children, max_count)
selected = block_keys['selected']
# Save back any changes
@@ -176,7 +174,7 @@ class ContentLibraryTransformer(FilteringTransformerMixin, BlockStructureTransfo
with tracker.get_tracker().context(full_event_name, context):
tracker.emit(full_event_name, event_data)
- LibraryContentBlock.publish_selected_children_events(
+ LegacyLibraryContentBlock.publish_selected_children_events(
block_keys,
format_block_keys,
publish_event,
diff --git a/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py b/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py
index 0f8227e320..abd1dc5375 100644
--- a/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py
+++ b/lms/djangoapps/course_goals/management/commands/goal_reminder_email.py
@@ -23,7 +23,7 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_
from openedx.core.djangoapps.user_api.preferences.api import get_user_preference
from openedx.core.lib.celery.task_utils import emulate_http_request
from openedx.features.course_duration_limits.access import get_user_course_expiration_date
-from openedx.features.course_experience import ENABLE_COURSE_GOALS
+from openedx.features.course_experience import ENABLE_COURSE_GOALS, ENABLE_SES_FOR_GOALREMINDER
from openedx.features.course_experience.url_helpers import get_learning_mfe_home_url
log = logging.getLogger(__name__)
@@ -86,13 +86,24 @@ def send_ace_message(goal):
'programs_url': getattr(settings, 'ACE_EMAIL_PROGRAMS_URL', None),
})
+ options = {'transactional': True}
+
+ is_ses_enabled = ENABLE_SES_FOR_GOALREMINDER.is_enabled(goal.course_key)
+
+ if is_ses_enabled:
+ options = {
+ 'transactional': True,
+ 'from_address': settings.LMS_COMM_DEFAULT_FROM_EMAIL,
+ 'override_default_channel': 'django_email',
+ }
+
msg = Message(
name="goalreminder",
app_label="course_goals",
recipient=Recipient(user.id, user.email),
language=language,
context=message_context,
- options={'transactional': True},
+ options=options,
)
with emulate_http_request(site, user):
diff --git a/lms/djangoapps/course_goals/management/commands/tests/test_goal_reminder_email.py b/lms/djangoapps/course_goals/management/commands/tests/test_goal_reminder_email.py
index ad60420e0d..5b98b202d4 100644
--- a/lms/djangoapps/course_goals/management/commands/tests/test_goal_reminder_email.py
+++ b/lms/djangoapps/course_goals/management/commands/tests/test_goal_reminder_email.py
@@ -5,6 +5,7 @@ from pytz import UTC
from unittest import mock # lint-amnesty, pylint: disable=wrong-import-order
import ddt
+from django.conf import settings
from django.core.management import call_command
from django.test import TestCase
from edx_toggles.toggles.testutils import override_waffle_flag
@@ -20,7 +21,7 @@ from lms.djangoapps.course_goals.tests.factories import (
from lms.djangoapps.certificates.data import CertificateStatuses
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms
-from openedx.features.course_experience import ENABLE_COURSE_GOALS
+from openedx.features.course_experience import ENABLE_COURSE_GOALS, ENABLE_SES_FOR_GOALREMINDER
# Some constants just for clarity of tests (assuming week starts on a Monday, as March 2021 used below does)
MONDAY = 0
@@ -180,3 +181,33 @@ class TestGoalReminderEmailCommand(TestCase):
def test_old_course(self, end):
self.make_valid_goal(overview__end=end)
self.call_command(expect_sent=False)
+
+ @mock.patch('lms.djangoapps.course_goals.management.commands.goal_reminder_email.ace.send')
+ def test_params_with_ses(self, mock_ace):
+ """Test that the parameters of the msg passed to ace.send() are set correctly when SES is enabled"""
+ with override_waffle_flag(ENABLE_SES_FOR_GOALREMINDER, active=None):
+ goal = self.make_valid_goal()
+ flag = get_waffle_flag_model().get(ENABLE_SES_FOR_GOALREMINDER.name)
+ flag.users.add(goal.user)
+
+ with freeze_time('2021-03-02 10:00:00'):
+ call_command('goal_reminder_email')
+
+ assert mock_ace.call_count == 1
+ msg = mock_ace.call_args[0][0]
+ assert msg.options['override_default_channel'] == 'django_email'
+ assert msg.options['from_address'] == settings.LMS_COMM_DEFAULT_FROM_EMAIL
+
+ @mock.patch('lms.djangoapps.course_goals.management.commands.goal_reminder_email.ace.send')
+ def test_params_without_ses(self, mock_ace):
+ """Test that the parameters of the msg passed to ace.send() are set correctly when SES is not enabled"""
+ self.make_valid_goal()
+
+ with freeze_time('2021-03-02 10:00:00'):
+ call_command('goal_reminder_email')
+
+ assert mock_ace.call_count == 1
+ msg = mock_ace.call_args[0][0]
+ assert msg.options['transactional'] is True
+ assert 'override_default_channel' not in msg.options
+ assert 'from_address' not in msg.options
diff --git a/lms/djangoapps/courseware/block_render.py b/lms/djangoapps/courseware/block_render.py
index 1bae903224..de92692ce4 100644
--- a/lms/djangoapps/courseware/block_render.py
+++ b/lms/djangoapps/courseware/block_render.py
@@ -45,7 +45,7 @@ from lms.djangoapps.teams.services import TeamsService
from openedx.core.lib.xblock_services.call_to_action import CallToActionService
from xmodule.contentstore.django import contentstore
from xmodule.exceptions import NotFoundError, ProcessingError
-from xmodule.library_tools import LibraryToolsService
+from xmodule.library_tools import LegacyLibraryToolsService
from xmodule.modulestore.django import XBlockI18nService, modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.partitions.partitions_service import PartitionService
@@ -626,7 +626,7 @@ def prepare_runtime_for_user(
),
'completion': CompletionService(user=user, context_key=course_id) if user and user.is_authenticated else None,
'i18n': XBlockI18nService,
- 'library_tools': LibraryToolsService(store, user_id=user.id if user else None),
+ 'library_tools': LegacyLibraryToolsService(store, user_id=user.id if user else None),
'partitions': PartitionService(course_id=course_id, cache=DEFAULT_REQUEST_CACHE.data),
'settings': SettingsService(),
'user_tags': UserTagsService(user=user, course_id=course_id),
diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py
index 79a52db8a0..1c57b23d9b 100644
--- a/lms/djangoapps/courseware/views/views.py
+++ b/lms/djangoapps/courseware/views/views.py
@@ -46,7 +46,11 @@ from rest_framework.response import Response
from rest_framework.throttling import UserRateThrottle
from token_utils.api import unpack_token_for
from web_fragments.fragment import Fragment
-from xmodule.course_block import COURSE_VISIBILITY_PUBLIC, COURSE_VISIBILITY_PUBLIC_OUTLINE
+from xmodule.course_block import (
+ COURSE_VISIBILITY_PUBLIC,
+ COURSE_VISIBILITY_PUBLIC_OUTLINE,
+ CATALOG_VISIBILITY_CATALOG_AND_ABOUT,
+)
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
from xmodule.tabs import CourseTabList
@@ -288,7 +292,10 @@ def courses(request):
course_discovery_meanings = getattr(settings, 'COURSE_DISCOVERY_MEANINGS', {})
set_default_filter = ENABLE_COURSE_DISCOVERY_DEFAULT_LANGUAGE_FILTER.is_enabled()
if not settings.FEATURES.get('ENABLE_COURSE_DISCOVERY'):
- courses_list = get_courses(request.user)
+ courses_list = get_courses(
+ request.user,
+ filter_={"catalog_visibility": CATALOG_VISIBILITY_CATALOG_AND_ABOUT},
+ )
if configuration_helpers.get_value("ENABLE_COURSE_SORTING_BY_START_DATE",
settings.FEATURES["ENABLE_COURSE_SORTING_BY_START_DATE"]):
diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py
index 4e372280ce..b0eb7c89dc 100644
--- a/lms/djangoapps/discussion/rest_api/discussions_notifications.py
+++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py
@@ -3,7 +3,7 @@ Discussion notifications sender util.
"""
import re
-from bs4 import BeautifulSoup
+from bs4 import BeautifulSoup, Tag
from django.conf import settings
from django.utils.text import Truncator
@@ -380,6 +380,30 @@ def remove_html_tags(text):
return re.sub(clean, '', text)
+def strip_empty_tags(soup):
+ """
+ Strip starting and ending empty tags from the soup object
+ """
+ def strip_tag(element, reverse=False):
+ """
+ Checks if element is empty and removes it
+ """
+ if not element.get_text(strip=True):
+ element.extract()
+ return True
+ if isinstance(element, Tag):
+ child_list = element.contents[::-1] if reverse else element.contents
+ for child in child_list:
+ if not strip_tag(child):
+ break
+ return False
+
+ while soup.contents:
+ if not (strip_tag(soup.contents[0]) or strip_tag(soup.contents[-1], reverse=True)):
+ break
+ return soup
+
+
def clean_thread_html_body(html_body):
"""
Get post body with tags removed and limited to 500 characters
@@ -392,7 +416,8 @@ def clean_thread_html_body(html_body):
"video", "track", # Video Tags
"audio", # Audio Tags
"embed", "object", "iframe", # Embedded Content
- "script"
+ "script",
+ "b", "strong", "i", "em", "u", "s", "strike", "del", "ins", "mark", "sub", "sup", # Text Formatting
]
# Remove the specified tags while keeping their content
@@ -400,18 +425,29 @@ def clean_thread_html_body(html_body):
for match in html_body.find_all(tag):
match.unwrap()
+ if not html_body.find():
+ return str(html_body)
+
# Replace tags that are not allowed in email
tags_to_update = [
{"source": "button", "target": "span"},
- {"source": "h1", "target": "h4"},
- {"source": "h2", "target": "h4"},
- {"source": "h3", "target": "h4"},
+ *[
+ {"source": tag, "target": "p"}
+ for tag in ["div", "section", "article", "h1", "h2", "h3", "h4", "h5", "h6"]
+ ],
]
for tag_dict in tags_to_update:
for source_tag in html_body.find_all(tag_dict['source']):
target_tag = html_body.new_tag(tag_dict['target'], **source_tag.attrs)
- if source_tag.string:
- target_tag.string = source_tag.string
- source_tag.replace_with(target_tag)
+ if source_tag.contents:
+ for content in list(source_tag.contents):
+ target_tag.append(content)
+ source_tag.insert_before(target_tag)
+ source_tag.extract()
+ for tag in html_body.find_all(True):
+ tag.attrs = {}
+ tag['style'] = 'margin: 0'
+
+ html_body = strip_empty_tags(html_body)
return str(html_body)
diff --git a/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py b/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py
index d92e1000fe..9e4a76aa40 100644
--- a/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py
+++ b/lms/djangoapps/discussion/rest_api/tests/test_discussions_notifications.py
@@ -104,14 +104,14 @@ class TestCleanThreadHtmlBody(unittest.TestCase):
This is a link to a page.
Here is an image:
Embedded video:
- Script test:
+ Script test:
Some other content that should remain.
"""
- expected_output = ("This is a link to a page.
"
- "Here is an image:
"
- "Embedded video:
"
- "Script test: alert('hello');
"
- "Some other content that should remain.
")
+ expected_output = ('This is a link to a page.
'
+ 'Here is an image:
'
+ 'Embedded video:
'
+ 'Script test: alert("hello");
'
+ 'Some other content that should remain.
')
result = clean_thread_html_body(html_body)
@@ -132,19 +132,16 @@ class TestCleanThreadHtmlBody(unittest.TestCase):
"""
Test that the clean_thread_html_body function truncates the HTML body to 500 characters
"""
- html_body = """
- This is a long text that should be truncated to 500 characters.
- """ * 20 # Repeat to exceed 500 characters
-
- result = clean_thread_html_body(html_body)
- self.assertGreaterEqual(500, len(result))
+ html_body = "This is a long text that should be truncated to 500 characters." * 20
+ result = clean_thread_html_body(f"{html_body}
")
+ self.assertGreaterEqual(525, len(result)) # 500 characters + 25 characters for the HTML tags
def test_no_tags_to_remove(self):
"""
Test that the clean_thread_html_body function does not remove any tags if there are no unwanted tags
"""
html_body = "This paragraph has no tags to remove.
"
- expected_output = "This paragraph has no tags to remove.
"
+ expected_output = 'This paragraph has no tags to remove.
'
result = clean_thread_html_body(html_body)
self.assertEqual(result, expected_output)
@@ -169,28 +166,49 @@ class TestCleanThreadHtmlBody(unittest.TestCase):
result = clean_thread_html_body(html_body)
self.assertEqual(result.strip(), expected_output)
+ def test_tag_replace(self):
+ """
+ Tests if the clean_thread_html_body function replaces tags
+ """
+ for tag in ["div", "section", "article", "h1", "h2", "h3", "h4", "h5", "h6"]:
+ html_body = f'<{tag}>Text{tag}>'
+ result = clean_thread_html_body(html_body)
+ self.assertEqual(result, 'Text
')
+
def test_button_tag_replace(self):
"""
Tests that the clean_thread_html_body function replaces the button tag with span tag
"""
- # Tests for button replacement tag with text
html_body = 'Button '
- expected_output = 'Button '
+ expected_output = 'Button '
result = clean_thread_html_body(html_body)
self.assertEqual(result, expected_output)
- # Tests button tag replacement without text
+ html_body = '
abc
abc
'
+ expected_output = '
abc
'\
+ 'abc
'
+ result = clean_thread_html_body(html_body)
+ self.assertEqual(result, expected_output)
+
+ def test_button_tag_removal(self):
+ """
+ Tests button tag with no text is removed if at start or end
+ """
html_body = ' '
- expected_output = ' '
+ expected_output = ''
result = clean_thread_html_body(html_body)
self.assertEqual(result, expected_output)
- def test_heading_tag_replace(self):
+ def test_attributes_removal_from_tag(self):
+ # Tests for removal of attributes from tags
+ html_body = 'Paragraph
'
+ result = clean_thread_html_body(html_body)
+ self.assertEqual(result, 'Paragraph
')
+
+ def test_strip_empty_tags(self):
"""
- Tests that the clean_thread_html_body function replaces the h1, h2 and h3 tags with h4 tag
+ Tests if the clean_thread_html_body function removes starting and ending empty tags
"""
- for tag in ['h1', 'h2', 'h3']:
- html_body = f'<{tag}>Heading{tag}>'
- expected_output = 'Heading '
- result = clean_thread_html_body(html_body)
- self.assertEqual(result, expected_output)
+ html_body = ''
+ result = clean_thread_html_body(html_body)
+ self.assertEqual(result, '
content
')
diff --git a/lms/djangoapps/grades/rest_api/v1/views.py b/lms/djangoapps/grades/rest_api/v1/views.py
index 5f49f2299a..b6835fd61e 100644
--- a/lms/djangoapps/grades/rest_api/v1/views.py
+++ b/lms/djangoapps/grades/rest_api/v1/views.py
@@ -378,7 +378,7 @@ class SubmissionHistoryView(GradeViewMixin, PaginatedAPIView):
def get(self, request, course_id=None):
"""
Get submission history details. This submission history is related to only
- ProblemBlock and it doesn't support LibraryContentBlock or ContentLibraries
+ ProblemBlock and it doesn't support LegacyLibraryContentBlock or ContentLibraries
as of now.
**Usecases**:
@@ -463,7 +463,7 @@ class SubmissionHistoryView(GradeViewMixin, PaginatedAPIView):
@staticmethod
def get_problem_blocks(course):
""" Get a list of problem xblock for the course.
- This doesn't support LibraryContentBlock or ContentLibraries
+ This doesn't support LegacyLibraryContentBlock or ContentLibraries
as of now
"""
blocks = []
diff --git a/lms/djangoapps/verify_student/management/commands/approve_id_verifications.py b/lms/djangoapps/verify_student/management/commands/approve_id_verifications.py
index 3a08ede0aa..4c45f415cf 100644
--- a/lms/djangoapps/verify_student/management/commands/approve_id_verifications.py
+++ b/lms/djangoapps/verify_student/management/commands/approve_id_verifications.py
@@ -10,9 +10,11 @@ from pprint import pformat
from django.core.management.base import BaseCommand, CommandError
+from common.djangoapps.student.models_api import get_name, get_pending_name_change
from lms.djangoapps.verify_student.api import send_approval_email
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
from lms.djangoapps.verify_student.utils import earliest_allowed_verification_date
+from openedx.features.name_affirmation_api.utils import get_name_affirmation_service
log = logging.getLogger(__name__)
@@ -149,5 +151,37 @@ class Command(BaseCommand):
for verification in existing_id_verifications:
verification.approve(service='idv_verifications command')
send_approval_email(verification)
+ self._approve_verified_name_for_software_secure_verification(verification)
return list(failed_user_ids)
+
+ def _approve_verified_name_for_software_secure_verification(self, verification):
+ """
+ This method manually creates a verified name given a SoftwareSecurePhotoVerification object.
+ """
+
+ name_affirmation_service = get_name_affirmation_service()
+
+ if name_affirmation_service:
+ from edx_name_affirmation.exceptions import VerifiedNameDoesNotExist # pylint: disable=import-error
+
+ pending_name_change = get_pending_name_change(verification.user)
+ if pending_name_change:
+ full_name = pending_name_change.new_name
+ else:
+ full_name = get_name(verification.user.id)
+
+ try:
+ name_affirmation_service.update_verified_name_status(
+ verification.user,
+ 'approved',
+ verification_attempt_id=verification.id
+ )
+ except VerifiedNameDoesNotExist:
+ name_affirmation_service.create_verified_name(
+ verification.user,
+ verification.name,
+ full_name,
+ verification_attempt_id=verification.id,
+ status='approved',
+ )
diff --git a/lms/djangoapps/verify_student/management/commands/tests/test_approve_id_verifications.py b/lms/djangoapps/verify_student/management/commands/tests/test_approve_id_verifications.py
index e6e580c1d1..6eccee1947 100644
--- a/lms/djangoapps/verify_student/management/commands/tests/test_approve_id_verifications.py
+++ b/lms/djangoapps/verify_student/management/commands/tests/test_approve_id_verifications.py
@@ -6,6 +6,8 @@ import ddt
import logging
import os
import tempfile
+from unittest import skipUnless
+from unittest.mock import MagicMock, patch
import pytest
from django.core import mail
@@ -15,9 +17,12 @@ from testfixtures import LogCapture
from common.djangoapps.student.tests.factories import UserFactory, UserProfileFactory
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
+from openedx.features.name_affirmation_api.utils import get_name_affirmation_service
LOGGER_NAME = 'lms.djangoapps.verify_student.management.commands.approve_id_verifications'
+name_affirmation_service = get_name_affirmation_service()
+
@ddt.ddt
class TestApproveIDVerificationsCommand(TestCase):
@@ -158,3 +163,57 @@ class TestApproveIDVerificationsCommand(TestCase):
"""
with pytest.raises(CommandError):
call_command('approve_id_verifications', 'invalid/user_id/file/path')
+
+ @skipUnless(name_affirmation_service is not None, 'Requires Name Affirmation')
+ @patch('lms.djangoapps.verify_student.management.commands.approve_id_verifications.get_name_affirmation_service')
+ def test_create_verified_names(self, mock_get_service):
+ mock_service = MagicMock()
+ mock_get_service.return_value = mock_service
+
+ verification = SoftwareSecurePhotoVerification.objects.create(
+ user=self.user1_profile.user,
+ name=self.user1_profile.name,
+ status='submitted',
+ )
+
+ call_command('approve_id_verifications', self.tmp_file_path)
+ mock_service.update_verified_name_status.assert_called_with(
+ self.user1_profile.user,
+ 'approved',
+ verification_attempt_id=verification.id,
+ )
+
+ @skipUnless(name_affirmation_service is not None, 'Requires Name Affirmation')
+ @patch('lms.djangoapps.verify_student.management.commands.approve_id_verifications.get_name')
+ @patch('lms.djangoapps.verify_student.management.commands.approve_id_verifications.get_pending_name_change')
+ @patch('lms.djangoapps.verify_student.management.commands.approve_id_verifications.get_name_affirmation_service')
+ @ddt.data(
+ '',
+ MagicMock(new_name='test')
+ )
+ def test_create_update_verified_names(self, pending_name, mock_get_service, mock_get_pending, mock_get_name):
+ from edx_name_affirmation.exceptions import VerifiedNameDoesNotExist # pylint: disable=import-error
+
+ mock_service = MagicMock()
+ mock_get_service.return_value = mock_service
+ mock_service.update_verified_name_status.side_effect = VerifiedNameDoesNotExist()
+
+ mock_get_pending.return_value = pending_name
+ mock_get_name.return_value = self.user1_profile.name
+
+ verification = SoftwareSecurePhotoVerification.objects.create(
+ user=self.user1_profile.user,
+ name=self.user1_profile.name,
+ status='submitted',
+ )
+
+ expected_name = 'test' if pending_name else self.user1_profile.name
+
+ call_command('approve_id_verifications', self.tmp_file_path)
+ mock_service.create_verified_name.assert_called_with(
+ verification.user,
+ verification.name,
+ expected_name,
+ verification_attempt_id=verification.id,
+ status='approved',
+ )
diff --git a/lms/envs/common.py b/lms/envs/common.py
index df8878f269..b59d60c751 100644
--- a/lms/envs/common.py
+++ b/lms/envs/common.py
@@ -4304,13 +4304,6 @@ ECOMMERCE_ORDERS_API_CACHE_TIMEOUT = 3600
ECOMMERCE_SERVICE_WORKER_USERNAME = 'ecommerce_worker'
ECOMMERCE_API_SIGNING_KEY = 'SET-ME-PLEASE'
-# E-Commerce Commerce Coordinator Configuration
-COMMERCE_COORDINATOR_URL_ROOT = 'http://localhost:8140'
-COMMERCE_COORDINATOR_REFUND_PATH = '/lms/refund/'
-COMMERCE_COORDINATOR_REFUND_SOURCE_SYSTEMS = ('SET-ME-PLEASE',)
-COMMERCE_COORDINATOR_SERVICE_WORKER_USERNAME = 'commerce_coordinator_worker'
-COORDINATOR_CHECKOUT_REDIRECT_PATH = '/lms/payment_page_redirect/'
-
# Exam Service
EXAMS_SERVICE_URL = 'http://localhost:18740/api/v1'
@@ -5541,3 +5534,6 @@ SURVEY_REPORT_EXTRA_DATA = {}
# .. for now it wil impact country listing in auth flow and user profile.
# .. eg ['US', 'CA']
DISABLED_COUNTRIES = []
+
+
+LMS_COMM_DEFAULT_FROM_EMAIL = "no-reply@example.com"
diff --git a/lms/templates/course_modes/track_selection.html b/lms/templates/course_modes/track_selection.html
index 557bd18844..39a5f54403 100644
--- a/lms/templates/course_modes/track_selection.html
+++ b/lms/templates/course_modes/track_selection.html
@@ -35,9 +35,9 @@ from openedx.core.djangolib.js_utils import js_escaped_string
$('.popover-icon').click(function(e){
e.stopPropagation();
if ($('.popover').css("visibility") == "hidden" || $('.popover').css("visibility") == "" ) {
- $('.popover').css({"visibility":"visible", "opacity": 1});
+ $('.popover').css({"visibility":"visible", "opacity": 1});
} else {
- $('.popover').css({"visibility":"hidden", "opacity": 0});
+ $('.popover').css({"visibility":"hidden", "opacity": 0});
}
});
});
@@ -52,7 +52,7 @@ from openedx.core.djangolib.js_utils import js_escaped_string
}
window.addEventListener("resize", onresize);
- // responsive: show more
+ // responsive: show more
$(function(){
if($(window).width() <= 768) {
$('.collapsible-item').css({"display":"none"});
@@ -64,7 +64,7 @@ from openedx.core.djangolib.js_utils import js_escaped_string
e.preventDefault();
$('.collapsible').css({"display":"none"});
$('.collapsible-item').css({"display":"list-item"});
- });
+ });
});
%block>
@@ -112,7 +112,7 @@ from openedx.core.djangolib.js_utils import js_escaped_string
${currency_symbol}${min_price} ${currency}
${_("Earn a certificate")}
- <%block name="track_selection_certificate_bullets"/>
+ <%block name="track_selection_certificate_bullets"/>
diff --git a/lms/templates/xblock_v2/xblock_iframe.html b/lms/templates/xblock_v2/xblock_iframe.html
deleted file mode 120000
index 7264c25346..0000000000
--- a/lms/templates/xblock_v2/xblock_iframe.html
+++ /dev/null
@@ -1 +0,0 @@
-../../../cms/templates/content_libraries/xblock_iframe.html
\ No newline at end of file
diff --git a/openedx/core/djangoapps/content/search/api.py b/openedx/core/djangoapps/content/search/api.py
index b5ed1bde78..17338f20ab 100644
--- a/openedx/core/djangoapps/content/search/api.py
+++ b/openedx/core/djangoapps/content/search/api.py
@@ -320,6 +320,7 @@ def rebuild_index(status_cb: Callable[[str], None] | None = None) -> None:
Fields.block_id,
Fields.block_type,
Fields.context_key,
+ Fields.usage_key,
Fields.org,
Fields.tags,
Fields.tags + "." + Fields.tags_taxonomy,
diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py
index eabeab9654..f520cd14e4 100644
--- a/openedx/core/djangoapps/content/search/documents.py
+++ b/openedx/core/djangoapps/content/search/documents.py
@@ -267,6 +267,13 @@ def _collections_for_content_object(object_id: UsageKey | LearningContextKey) ->
}
"""
+ result = {
+ Fields.collections: {
+ Fields.collections_display_name: [],
+ Fields.collections_key: [],
+ }
+ }
+
# Gather the collections associated with this object
collections = None
try:
@@ -279,14 +286,8 @@ def _collections_for_content_object(object_id: UsageKey | LearningContextKey) ->
log.warning(f"No component found for {object_id}")
if not collections:
- return {Fields.collections: {}}
+ return result
- result = {
- Fields.collections: {
- Fields.collections_display_name: [],
- Fields.collections_key: [],
- }
- }
for collection in collections:
result[Fields.collections][Fields.collections_display_name].append(collection.title)
result[Fields.collections][Fields.collections_key].append(collection.key)
diff --git a/openedx/core/djangoapps/content/search/handlers.py b/openedx/core/djangoapps/content/search/handlers.py
index 085387d336..24add6748d 100644
--- a/openedx/core/djangoapps/content/search/handlers.py
+++ b/openedx/core/djangoapps/content/search/handlers.py
@@ -179,13 +179,19 @@ def library_collection_updated_handler(**kwargs) -> None:
log.error("Received null or incorrect data for event")
return
- # Update collection index synchronously to make sure that search index is updated before
- # the frontend invalidates/refetches index.
- # See content_library_updated_handler for more details.
- update_library_collection_index_doc.apply(args=[
- str(library_collection.library_key),
- library_collection.collection_key,
- ])
+ if library_collection.background:
+ update_library_collection_index_doc.delay(
+ str(library_collection.library_key),
+ library_collection.collection_key,
+ )
+ else:
+ # Update collection index synchronously to make sure that search index is updated before
+ # the frontend invalidates/refetches index.
+ # See content_library_updated_handler for more details.
+ update_library_collection_index_doc.apply(args=[
+ str(library_collection.library_key),
+ library_collection.collection_key,
+ ])
@receiver(CONTENT_OBJECT_ASSOCIATIONS_CHANGED)
diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py
index 4c6227af30..c89d84490e 100644
--- a/openedx/core/djangoapps/content/search/tests/test_api.py
+++ b/openedx/core/djangoapps/content/search/tests/test_api.py
@@ -219,10 +219,10 @@ class TestSearchApi(ModuleStoreTestCase):
doc_vertical["tags"] = {}
doc_problem1 = copy.deepcopy(self.doc_problem1)
doc_problem1["tags"] = {}
- doc_problem1["collections"] = {}
+ doc_problem1["collections"] = {'display_name': [], 'key': []}
doc_problem2 = copy.deepcopy(self.doc_problem2)
doc_problem2["tags"] = {}
- doc_problem2["collections"] = {}
+ doc_problem2["collections"] = {'display_name': [], 'key': []}
doc_collection = copy.deepcopy(self.collection_dict)
doc_collection["tags"] = {}
@@ -263,7 +263,7 @@ class TestSearchApi(ModuleStoreTestCase):
doc_vertical["tags"] = {}
doc_problem2 = copy.deepcopy(self.doc_problem2)
doc_problem2["tags"] = {}
- doc_problem2["collections"] = {}
+ doc_problem2["collections"] = {'display_name': [], 'key': []}
orig_from_component = library_api.LibraryXBlockMetadata.from_component
@@ -662,7 +662,7 @@ class TestSearchApi(ModuleStoreTestCase):
doc_problem_without_collection = {
"id": self.doc_problem1["id"],
- "collections": {},
+ "collections": {'display_name': [], 'key': []},
}
# Should delete the collection document
diff --git a/openedx/core/djangoapps/content_libraries/api.py b/openedx/core/djangoapps/content_libraries/api.py
index b9f3779af5..90b73ef7f4 100644
--- a/openedx/core/djangoapps/content_libraries/api.py
+++ b/openedx/core/djangoapps/content_libraries/api.py
@@ -56,6 +56,7 @@ from datetime import datetime, timezone
import base64
import hashlib
import logging
+import mimetypes
import attr
import requests
@@ -68,6 +69,7 @@ from django.db import IntegrityError, transaction
from django.db.models import Q, QuerySet
from django.utils.translation import gettext as _
from edx_rest_api_client.client import OAuthAPIClient
+from django.urls import reverse
from lxml import etree
from opaque_keys.edx.keys import BlockTypeKey, UsageKey, UsageKeyV2
from opaque_keys.edx.locator import (
@@ -76,10 +78,10 @@ from opaque_keys.edx.locator import (
LibraryLocator as LibraryLocatorV1,
LibraryCollectionLocator,
)
-from opaque_keys import InvalidKeyError
from openedx_events.content_authoring.data import (
ContentLibraryData,
LibraryBlockData,
+ LibraryCollectionData,
)
from openedx_events.content_authoring.signals import (
CONTENT_LIBRARY_CREATED,
@@ -88,6 +90,7 @@ from openedx_events.content_authoring.signals import (
LIBRARY_BLOCK_CREATED,
LIBRARY_BLOCK_DELETED,
LIBRARY_BLOCK_UPDATED,
+ LIBRARY_COLLECTION_UPDATED,
)
from openedx_learning.api import authoring as authoring_api
from openedx_learning.api.authoring_models import Collection, Component, MediaType, LearningPackage, PublishableEntity
@@ -95,12 +98,13 @@ from organizations.models import Organization
from xblock.core import XBlock
from xblock.exceptions import XBlockNotFoundError
-from openedx.core.djangoapps.xblock.api import get_component_from_usage_key, xblock_type_display_name
+from openedx.core.djangoapps.xblock.api import (
+ get_component_from_usage_key,
+ get_xblock_app_config,
+ xblock_type_display_name,
+)
from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_for_learning_core
-from xmodule.library_root_xblock import LibraryRoot as LibraryRootV1
-from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
-from xmodule.modulestore.exceptions import ItemNotFoundError
from . import permissions, tasks
from .constants import ALL_RIGHTS_RESERVED, COMPLEX
@@ -204,6 +208,15 @@ class ContentLibraryPermissionEntry:
access_level = attr.ib(AccessLevel.NO_ACCESS)
+@attr.s
+class CollectionMetadata:
+ """
+ Class to represent collection metadata in a content library.
+ """
+ key = attr.ib(type=str)
+ title = attr.ib(type=str)
+
+
@attr.s
class LibraryXBlockMetadata:
"""
@@ -219,9 +232,10 @@ class LibraryXBlockMetadata:
published_by = attr.ib("")
has_unpublished_changes = attr.ib(False)
created = attr.ib(default=None, type=datetime)
+ collections = attr.ib(type=list[CollectionMetadata], factory=list)
@classmethod
- def from_component(cls, library_key, component):
+ def from_component(cls, library_key, component, associated_collections=None):
"""
Construct a LibraryXBlockMetadata from a Component object.
"""
@@ -248,6 +262,7 @@ class LibraryXBlockMetadata:
last_draft_created=last_draft_created,
last_draft_created_by=last_draft_created_by,
has_unpublished_changes=component.versioning.has_unpublished_changes,
+ collections=associated_collections or [],
)
@@ -408,8 +423,8 @@ def get_library(library_key):
# updated version of content that a course could pull in. But more recently,
# we've decided to do those version references at the level of the
# individual blocks being used, since a Learning Core backed library is
- # intended to be used for many LibraryContentBlocks and not 1:1 like v1
- # libraries. The top level version stays for now because LibraryContentBlock
+ # intended to be referenced in multiple course locations and not 1:1 like v1
+ # libraries. The top level version stays for now because LegacyLibraryContentBlock
# uses it, but that should hopefully change before the Redwood release.
version = 0 if last_publish_log is None else last_publish_log.pk
published_by = None
@@ -690,7 +705,7 @@ def get_library_components(library_key, text_search=None, block_types=None) -> Q
return components
-def get_library_block(usage_key) -> LibraryXBlockMetadata:
+def get_library_block(usage_key, include_collections=False) -> LibraryXBlockMetadata:
"""
Get metadata about (the draft version of) one specific XBlock in a library.
@@ -713,20 +728,30 @@ def get_library_block(usage_key) -> LibraryXBlockMetadata:
if not draft_version:
raise ContentLibraryBlockNotFound(usage_key)
+ if include_collections:
+ associated_collections = authoring_api.get_entity_collections(
+ component.learning_package_id,
+ component.key,
+ ).values('key', 'title')
+ else:
+ associated_collections = None
xblock_metadata = LibraryXBlockMetadata.from_component(
library_key=usage_key.context_key,
component=component,
+ associated_collections=associated_collections,
)
return xblock_metadata
-def set_library_block_olx(usage_key, new_olx_str):
+def set_library_block_olx(usage_key, new_olx_str) -> int:
"""
Replace the OLX source of the given XBlock.
This is only meant for use by developers or API client applications, as
very little validation is done and this can easily result in a broken XBlock
that won't load.
+
+ Returns the version number of the newly created ComponentVersion.
"""
# because this old pylint can't understand attr.ib() objects, pylint: disable=no-member
assert isinstance(usage_key, LibraryUsageLocatorV2)
@@ -763,7 +788,7 @@ def set_library_block_olx(usage_key, new_olx_str):
text=new_olx_str,
created=now,
)
- authoring_api.create_next_version(
+ new_component_version = authoring_api.create_next_component_version(
component.pk,
title=new_title,
content_to_replace={
@@ -779,6 +804,8 @@ def set_library_block_olx(usage_key, new_olx_str):
)
)
+ return new_component_version.version_num
+
def library_component_usage_key(
library_key: LibraryLocatorV2,
@@ -1001,18 +1028,48 @@ def get_library_block_static_asset_files(usage_key) -> list[LibraryXBlockStaticF
Returns a list of LibraryXBlockStaticFile objects, sorted by path.
- TODO: This is not yet implemented for Learning Core backed libraries.
TODO: Should this be in the general XBlock API rather than the libraries API?
"""
- return []
+ component = get_component_from_usage_key(usage_key)
+ component_version = component.versioning.draft
+
+ # If there is no Draft version, then this was soft-deleted
+ if component_version is None:
+ return []
+
+ # cvc = the ComponentVersionContent through table
+ cvc_set = (
+ component_version
+ .componentversioncontent_set
+ .filter(content__has_file=True)
+ .order_by('key')
+ .select_related('content')
+ )
+
+ site_root_url = get_xblock_app_config().get_site_root_url()
+
+ return [
+ LibraryXBlockStaticFile(
+ path=cvc.key,
+ size=cvc.content.size,
+ url=site_root_url + reverse(
+ 'content_libraries:library-assets',
+ kwargs={
+ 'component_version_uuid': component_version.uuid,
+ 'asset_path': cvc.key,
+ }
+ ),
+ )
+ for cvc in cvc_set
+ ]
-def add_library_block_static_asset_file(usage_key, file_name, file_content) -> LibraryXBlockStaticFile:
+def add_library_block_static_asset_file(usage_key, file_path, file_content, user=None) -> LibraryXBlockStaticFile:
"""
Upload a static asset file into the library, to be associated with the
specified XBlock. Will silently overwrite an existing file of the same name.
- file_name should be a name like "doc.pdf". It may optionally contain slashes
+ file_path should be a name like "doc.pdf". It may optionally contain slashes
like 'en/doc.pdf'
file_content should be a binary string.
@@ -1024,10 +1081,67 @@ def add_library_block_static_asset_file(usage_key, file_name, file_content) -> L
video_block = UsageKey.from_string("lb:VideoTeam:python-intro:video:1")
add_library_block_static_asset_file(video_block, "subtitles-en.srt", subtitles.encode('utf-8'))
"""
- raise NotImplementedError("Static assets not yet implemented for Learning Core")
+ # File path validations copied over from v1 library logic. This can't really
+ # hurt us inside our system because we never use these paths in an actual
+ # file system–they're just string keys that point to hash-named data files
+ # in a common library (learning package) level directory. But it might
+ # become a security issue during import/export serialization.
+ if file_path != file_path.strip().strip('/'):
+ raise InvalidNameError("file_path cannot start/end with / or whitespace.")
+ if '//' in file_path or '..' in file_path:
+ raise InvalidNameError("Invalid sequence (// or ..) in file_path.")
+
+ component = get_component_from_usage_key(usage_key)
+
+ media_type_str, _encoding = mimetypes.guess_type(file_path)
+ # We use "application/octet-stream" as a generic fallback media type, per
+ # RFC 2046: https://datatracker.ietf.org/doc/html/rfc2046
+ # TODO: This probably makes sense to push down to openedx-learning?
+ media_type_str = media_type_str or "application/octet-stream"
+
+ now = datetime.now(tz=timezone.utc)
+
+ with transaction.atomic():
+ media_type = authoring_api.get_or_create_media_type(media_type_str)
+ content = authoring_api.get_or_create_file_content(
+ component.publishable_entity.learning_package.id,
+ media_type.id,
+ data=file_content,
+ created=now,
+ )
+ component_version = authoring_api.create_next_component_version(
+ component.pk,
+ content_to_replace={file_path: content.id},
+ created=now,
+ created_by=user.id if user else None,
+ )
+ transaction.on_commit(
+ lambda: LIBRARY_BLOCK_UPDATED.send_event(
+ library_block=LibraryBlockData(
+ library_key=usage_key.context_key,
+ usage_key=usage_key,
+ )
+ )
+ )
+
+ # Now figure out the URL for the newly created asset...
+ site_root_url = get_xblock_app_config().get_site_root_url()
+ local_path = reverse(
+ 'content_libraries:library-assets',
+ kwargs={
+ 'component_version_uuid': component_version.uuid,
+ 'asset_path': file_path,
+ }
+ )
+
+ return LibraryXBlockStaticFile(
+ path=file_path,
+ url=site_root_url + local_path,
+ size=content.size,
+ )
-def delete_library_block_static_asset_file(usage_key, file_name):
+def delete_library_block_static_asset_file(usage_key, file_path, user=None):
"""
Delete a static asset file from the library.
@@ -1037,7 +1151,24 @@ def delete_library_block_static_asset_file(usage_key, file_name):
video_block = UsageKey.from_string("lb:VideoTeam:python-intro:video:1")
delete_library_block_static_asset_file(video_block, "subtitles-en.srt")
"""
- raise NotImplementedError("Static assets not yet implemented for Learning Core")
+ component = get_component_from_usage_key(usage_key)
+ now = datetime.now(tz=timezone.utc)
+
+ with transaction.atomic():
+ component_version = authoring_api.create_next_component_version(
+ component.pk,
+ content_to_replace={file_path: None},
+ created=now,
+ created_by=user.id if user else None,
+ )
+ transaction.on_commit(
+ lambda: LIBRARY_BLOCK_UPDATED.send_event(
+ library_block=LibraryBlockData(
+ library_key=usage_key.context_key,
+ usage_key=usage_key,
+ )
+ )
+ )
def get_allowed_block_types(library_key): # pylint: disable=unused-argument
@@ -1235,6 +1366,60 @@ def update_library_collection_components(
return collection
+def set_library_component_collections(
+ library_key: LibraryLocatorV2,
+ component: Component,
+ *,
+ collection_keys: list[str],
+ created_by: int | None = None,
+ # As an optimization, callers may pass in a pre-fetched ContentLibrary instance
+ content_library: ContentLibrary | None = None,
+) -> Component:
+ """
+ It Associates the component with collections for the given collection keys.
+
+ Only collections in queryset are associated with component, all previous component-collections
+ associations are removed.
+
+ If you've already fetched the ContentLibrary, pass it in to avoid refetching.
+
+ Raises:
+ * ContentLibraryCollectionNotFound if any of the given collection_keys don't match Collections in the given library.
+
+ Returns the updated Component.
+ """
+ if not content_library:
+ content_library = ContentLibrary.objects.get_by_key(library_key) # type: ignore[attr-defined]
+ assert content_library
+ assert content_library.learning_package_id
+ assert content_library.library_key == library_key
+
+ # Note: Component.key matches its PublishableEntity.key
+ collection_qs = authoring_api.get_collections(content_library.learning_package_id).filter(
+ key__in=collection_keys
+ )
+
+ affected_collections = authoring_api.set_collections(
+ content_library.learning_package_id,
+ component,
+ collection_qs,
+ created_by=created_by,
+ )
+
+ # For each collection, trigger LIBRARY_COLLECTION_UPDATED signal and set background=True to trigger
+ # collection indexing asynchronously.
+ for collection in affected_collections:
+ LIBRARY_COLLECTION_UPDATED.send_event(
+ library_collection=LibraryCollectionData(
+ library_key=library_key,
+ collection_key=collection.key,
+ background=True,
+ )
+ )
+
+ return component
+
+
def get_library_collection_usage_key(
library_key: LibraryLocatorV2,
collection_key: str,
@@ -1265,77 +1450,6 @@ def get_library_collection_from_usage_key(
raise ContentLibraryCollectionNotFound from exc
-# V1/V2 Compatibility Helpers
-# (Should be removed as part of
-# https://github.com/openedx/edx-platform/issues/32457)
-# ======================================================
-
-def get_v1_or_v2_library(
- library_id: str | LibraryLocatorV1 | LibraryLocatorV2,
- version: str | int | None,
-) -> LibraryRootV1 | ContentLibraryMetadata | None:
- """
- Fetch either a V1 or V2 content library from a V1/V2 key (or key string) and version.
-
- V1 library versions are Mongo ObjectID strings.
- V2 library versions can be positive ints, or strings of positive ints.
- Passing version=None will return the latest version the library.
-
- Returns None if not found.
- If key is invalid, raises InvalidKeyError.
- For V1, if key has a version, it is ignored in favor of `version`.
- For V2, if version is provided but it isn't an int or parseable to one, we raise a ValueError.
-
- Examples:
- * get_v1_or_v2_library("library-v1:ProblemX+PR0B", None) ->
- * get_v1_or_v2_library("library-v1:ProblemX+PR0B", "65ff...") ->
- * get_v1_or_v2_library("lib:RG:rg-1", None) ->
- * get_v1_or_v2_library("lib:RG:rg-1", "36") ->
- * get_v1_or_v2_library("lib:RG:rg-1", "xyz") ->
- * get_v1_or_v2_library("notakey", "xyz") ->
-
- If you just want to get a V2 library, use `get_library` instead.
- """
- library_key: LibraryLocatorV1 | LibraryLocatorV2
- if isinstance(library_id, str):
- try:
- library_key = LibraryLocatorV1.from_string(library_id)
- except InvalidKeyError:
- library_key = LibraryLocatorV2.from_string(library_id)
- else:
- library_key = library_id
- if isinstance(library_key, LibraryLocatorV2):
- v2_version: int | None
- if version:
- v2_version = int(version)
- else:
- v2_version = None
- try:
- library = get_library(library_key)
- if v2_version is not None and library.version != v2_version:
- raise NotImplementedError(
- f"Tried to load version {v2_version} of learning_core-based library {library_key}. "
- f"Currently, only the latest version ({library.version}) may be loaded. "
- "This is a known issue. "
- "It will be fixed before the production release of learning_core-based (V2) content libraries. "
- )
- return library
- except ContentLibrary.DoesNotExist:
- return None
- elif isinstance(library_key, LibraryLocatorV1):
- v1_version: str | None
- if version:
- v1_version = str(version)
- else:
- v1_version = None
- store = modulestore()
- library_key = library_key.for_branch(ModuleStoreEnum.BranchName.library).for_version(v1_version)
- try:
- return store.get_library(library_key, remove_version=False, remove_branch=False, head_validation=False)
- except ItemNotFoundError:
- return None
-
-
# Import from Courseware
# ======================
diff --git a/openedx/core/djangoapps/content_libraries/library_context.py b/openedx/core/djangoapps/content_libraries/library_context.py
index 6ff426e735..4bda10eb12 100644
--- a/openedx/core/djangoapps/content_libraries/library_context.py
+++ b/openedx/core/djangoapps/content_libraries/library_context.py
@@ -1,19 +1,21 @@
"""
Definition of "Library" as a learning context.
"""
-
import logging
from django.core.exceptions import PermissionDenied
+from rest_framework.exceptions import NotFound
from openedx_events.content_authoring.data import LibraryBlockData
from openedx_events.content_authoring.signals import LIBRARY_BLOCK_UPDATED
+from opaque_keys.edx.keys import UsageKeyV2
+from opaque_keys.edx.locator import LibraryUsageLocatorV2, LibraryLocatorV2
+from openedx_learning.api import authoring as authoring_api
from openedx.core.djangoapps.content_libraries import api, permissions
from openedx.core.djangoapps.content_libraries.models import ContentLibrary
from openedx.core.djangoapps.xblock.api import LearningContext
-
-from openedx_learning.api import authoring as authoring_api
+from openedx.core.types import User as UserType
log = logging.getLogger(__name__)
@@ -30,47 +32,51 @@ class LibraryContextImpl(LearningContext):
super().__init__(**kwargs)
self.use_draft = kwargs.get('use_draft', None)
- def can_edit_block(self, user, usage_key):
+ def can_edit_block(self, user: UserType, usage_key: UsageKeyV2) -> bool:
"""
- Does the specified usage key exist in its context, and if so, does the
- specified user have permission to edit it (make changes to the authored
- data store)?
+ Assuming a block with the specified ID (usage_key) exists, does the
+ specified user have permission to edit it (make changes to the
+ fields / authored data store)?
- user: a Django User object (may be an AnonymousUser)
-
- usage_key: the UsageKeyV2 subclass used for this learning context
-
- Must return a boolean.
+ May raise ContentLibraryNotFound if the library does not exist.
"""
- try:
- api.require_permission_for_library_key(usage_key.lib_key, user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
- except (PermissionDenied, api.ContentLibraryNotFound):
- return False
+ assert isinstance(usage_key, LibraryUsageLocatorV2)
+ return self._check_perm(user, usage_key.lib_key, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY)
- return self.block_exists(usage_key)
+ def can_view_block_for_editing(self, user: UserType, usage_key: UsageKeyV2) -> bool:
+ """
+ Assuming a block with the specified ID (usage_key) exists, does the
+ specified user have permission to view its fields and OLX details (but
+ not necessarily to make changes to it)?
- def can_view_block(self, user, usage_key):
+ May raise ContentLibraryNotFound if the library does not exist.
+ """
+ assert isinstance(usage_key, LibraryUsageLocatorV2)
+ return self._check_perm(user, usage_key.lib_key, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
+
+ def can_view_block(self, user: UserType, usage_key: UsageKeyV2) -> bool:
"""
Does the specified usage key exist in its context, and if so, does the
specified user have permission to view it and interact with it (call
handlers, save user state, etc.)?
- user: a Django User object (may be an AnonymousUser)
-
- usage_key: the UsageKeyV2 subclass used for this learning context
-
- Must return a boolean.
+ May raise ContentLibraryNotFound if the library does not exist.
"""
+ assert isinstance(usage_key, LibraryUsageLocatorV2)
+ return self._check_perm(user, usage_key.lib_key, permissions.CAN_LEARN_FROM_THIS_CONTENT_LIBRARY)
+
+ def _check_perm(self, user: UserType, lib_key: LibraryLocatorV2, perm) -> bool:
+ """ Helper method to check a permission for the various can_ methods"""
try:
- api.require_permission_for_library_key(
- usage_key.lib_key, user, permissions.CAN_LEARN_FROM_THIS_CONTENT_LIBRARY,
- )
- except (PermissionDenied, api.ContentLibraryNotFound):
+ api.require_permission_for_library_key(lib_key, user, perm)
+ return True
+ except PermissionDenied:
return False
+ except api.ContentLibraryNotFound as exc:
+ # A 404 is probably what you want in this case, not a 500 error, so do that by default.
+ raise NotFound(f"Content Library '{lib_key}' does not exist") from exc
- return self.block_exists(usage_key)
-
- def block_exists(self, usage_key):
+ def block_exists(self, usage_key: LibraryUsageLocatorV2):
"""
Does the block for this usage_key exist in this Library?
@@ -82,7 +88,7 @@ class LibraryContextImpl(LearningContext):
version of it.
"""
try:
- content_lib = ContentLibrary.objects.get_by_key(usage_key.context_key)
+ content_lib = ContentLibrary.objects.get_by_key(usage_key.context_key) # type: ignore[attr-defined]
except ContentLibrary.DoesNotExist:
return False
@@ -97,12 +103,11 @@ class LibraryContextImpl(LearningContext):
local_key=usage_key.block_id,
)
- def send_block_updated_event(self, usage_key):
+ def send_block_updated_event(self, usage_key: UsageKeyV2):
"""
Send a "block updated" event for the library block with the given usage_key.
-
- usage_key: the UsageKeyV2 subclass used for this learning context
"""
+ assert isinstance(usage_key, LibraryUsageLocatorV2)
LIBRARY_BLOCK_UPDATED.send_event(
library_block=LibraryBlockData(
library_key=usage_key.lib_key,
diff --git a/openedx/core/djangoapps/content_libraries/permissions.py b/openedx/core/djangoapps/content_libraries/permissions.py
index c7da012c9f..17671b5659 100644
--- a/openedx/core/djangoapps/content_libraries/permissions.py
+++ b/openedx/core/djangoapps/content_libraries/permissions.py
@@ -2,7 +2,8 @@
Permissions for Content Libraries (v2, Learning-Core-based)
"""
from bridgekeeper import perms, rules
-from bridgekeeper.rules import Attribute, ManyRelation, Relation, in_current_groups
+from bridgekeeper.rules import Attribute, ManyRelation, Relation, blanket_rule, in_current_groups
+from django.conf import settings
from openedx.core.djangoapps.content_libraries.models import ContentLibraryPermission
@@ -41,6 +42,12 @@ has_explicit_admin_permission_for_library = (
)
+# Are we in Studio? (Is there a better or more contextual way to define this, e.g. get from learning context?)
+@blanket_rule
+def is_studio_request(_):
+ return settings.SERVICE_VARIANT == "cms"
+
+
########################### Permissions ###########################
# Is the user allowed to view XBlocks from the specified content library
@@ -51,10 +58,12 @@ CAN_LEARN_FROM_THIS_CONTENT_LIBRARY = 'content_libraries.learn_from_library'
perms[CAN_LEARN_FROM_THIS_CONTENT_LIBRARY] = (
# Global staff can learn from any library:
is_global_staff |
- # Regular users can learn if the library allows public learning:
+ # Regular and even anonymous users can learn if the library allows public learning:
Attribute('allow_public_learning', True) |
# Users/groups who are explicitly granted permission can learn from the library:
- (is_user_active & has_explicit_read_permission_for_library)
+ (is_user_active & has_explicit_read_permission_for_library) |
+ # Or, in Studio (but not the LMS) any users can access libraries with "public read" permissions:
+ (is_studio_request & is_user_active & Attribute('allow_public_read', True))
)
# Is the user allowed to create content libraries?
diff --git a/openedx/core/djangoapps/content_libraries/serializers.py b/openedx/core/djangoapps/content_libraries/serializers.py
index 51ba55cd6b..b19d27bed3 100644
--- a/openedx/core/djangoapps/content_libraries/serializers.py
+++ b/openedx/core/djangoapps/content_libraries/serializers.py
@@ -134,6 +134,14 @@ class ContentLibraryFilterSerializer(BaseFilterSerializer):
type = serializers.ChoiceField(choices=LIBRARY_TYPES, default=None, required=False)
+class CollectionMetadataSerializer(serializers.Serializer):
+ """
+ Serializer for CollectionMetadata
+ """
+ key = serializers.CharField()
+ title = serializers.CharField()
+
+
class LibraryXBlockMetadataSerializer(serializers.Serializer):
"""
Serializer for LibraryXBlockMetadata
@@ -161,6 +169,8 @@ class LibraryXBlockMetadataSerializer(serializers.Serializer):
slug = serializers.CharField(write_only=True)
tags_count = serializers.IntegerField(read_only=True)
+ collections = CollectionMetadataSerializer(many=True, required=False)
+
class LibraryXBlockTypeSerializer(serializers.Serializer):
"""
@@ -207,6 +217,7 @@ class LibraryXBlockOlxSerializer(serializers.Serializer):
Serializer for representing an XBlock's OLX
"""
olx = serializers.CharField()
+ version_num = serializers.IntegerField(read_only=True, required=False)
class LibraryXBlockStaticFileSerializer(serializers.Serializer):
@@ -305,3 +316,11 @@ class ContentLibraryCollectionComponentsUpdateSerializer(serializers.Serializer)
"""
usage_keys = serializers.ListField(child=UsageKeyV2Serializer(), allow_empty=False)
+
+
+class ContentLibraryComponentCollectionsUpdateSerializer(serializers.Serializer):
+ """
+ Serializer for adding/removing Collections to/from a Component.
+ """
+
+ collection_keys = serializers.ListField(child=serializers.CharField(), allow_empty=True)
diff --git a/openedx/core/djangoapps/content_libraries/signal_handlers.py b/openedx/core/djangoapps/content_libraries/signal_handlers.py
index fedee045a9..58f45d218e 100644
--- a/openedx/core/djangoapps/content_libraries/signal_handlers.py
+++ b/openedx/core/djangoapps/content_libraries/signal_handlers.py
@@ -20,8 +20,8 @@ from openedx_events.content_authoring.signals import (
LIBRARY_COLLECTION_DELETED,
LIBRARY_COLLECTION_UPDATED,
)
-from openedx_learning.api.authoring import get_collection_components, get_component, get_components
-from openedx_learning.api.authoring_models import Collection, CollectionPublishableEntity, Component
+from openedx_learning.api.authoring import get_component, get_components
+from openedx_learning.api.authoring_models import Collection, CollectionPublishableEntity, Component, PublishableEntity
from lms.djangoapps.grades.api import signals as grades_signals
@@ -167,9 +167,11 @@ def library_collection_entity_deleted(sender, instance, **kwargs):
"""
Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for components removed from a collection.
"""
- # Component.pk matches its entity.pk
- component = get_component(instance.entity_id)
- _library_collection_component_changed(component)
+ # Only trigger component updates if CollectionPublishableEntity was cascade deleted due to deletion of a collection.
+ if isinstance(kwargs.get('origin'), Collection):
+ # Component.pk matches its entity.pk
+ component = get_component(instance.entity_id)
+ _library_collection_component_changed(component)
@receiver(m2m_changed, sender=CollectionPublishableEntity, dispatch_uid="library_collection_entities_changed")
@@ -177,9 +179,6 @@ def library_collection_entities_changed(sender, instance, action, pk_set, **kwar
"""
Sends a CONTENT_OBJECT_ASSOCIATIONS_CHANGED event for components added/removed/cleared from a collection.
"""
- if not isinstance(instance, Collection):
- return
-
if action not in ["post_add", "post_remove", "post_clear"]:
return
@@ -191,18 +190,16 @@ def library_collection_entities_changed(sender, instance, action, pk_set, **kwar
log.error("{instance} is not associated with a content library.")
return
+ if isinstance(instance, PublishableEntity):
+ _library_collection_component_changed(instance.component, library.library_key)
+ return
+
+ # When action=="post_clear", pk_set==None
+ # Since the collection instance now has an empty entities set,
+ # we don't know which ones were removed, so we need to update associations for all library components.
+ components = get_components(instance.learning_package_id)
if pk_set:
- components = get_collection_components(
- instance.learning_package_id,
- instance.key,
- ).filter(pk__in=pk_set)
- else:
- # When action=="post_clear", pk_set==None
- # Since the collection instance now has an empty entities set,
- # we don't know which ones were removed, so we need to update associations for all library components.
- components = get_components(
- instance.learning_package_id,
- )
+ components = components.filter(pk__in=pk_set)
for component in components.all():
_library_collection_component_changed(component, library.library_key)
diff --git a/openedx/core/djangoapps/content_libraries/tasks.py b/openedx/core/djangoapps/content_libraries/tasks.py
index 9f4f7aaaf7..f56b4adfe3 100644
--- a/openedx/core/djangoapps/content_libraries/tasks.py
+++ b/openedx/core/djangoapps/content_libraries/tasks.py
@@ -21,33 +21,20 @@ import logging
from celery import shared_task
from celery_utils.logged_task import LoggedTask
from celery.utils.log import get_task_logger
-from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
-from django.core.exceptions import PermissionDenied
from edx_django_utils.monitoring import set_code_owner_attribute, set_code_owner_attribute_from_module
-from opaque_keys.edx.keys import UsageKey
-from opaque_keys.edx.locator import (
- BlockUsageLocator,
- LibraryLocatorV2,
- LibraryUsageLocatorV2,
- LibraryLocator as LibraryLocatorV1
-)
from user_tasks.tasks import UserTask, UserTaskStatus
from xblock.fields import Scope
-from common.djangoapps.student.auth import has_studio_write_access
from opaque_keys.edx.keys import CourseKey
-from openedx.core.djangoapps.content_libraries import api as library_api
-from openedx.core.djangoapps.xblock.api import load_block
+from opaque_keys.edx.locator import BlockUsageLocator
from openedx.core.lib import ensure_cms
from xmodule.capa_block import ProblemBlock
-from xmodule.library_content_block import ANY_CAPA_TYPE_VALUE, LibraryContentBlock
-from xmodule.library_root_xblock import LibraryRoot as LibraryRootV1
+from xmodule.library_content_block import ANY_CAPA_TYPE_VALUE, LegacyLibraryContentBlock
+from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
from xmodule.modulestore.mixed import MixedModuleStore
-from xmodule.modulestore.split_mongo import BlockKey
-from xmodule.util.keys import derive_key
from . import api
from .models import ContentLibraryBlockImportTask
@@ -84,77 +71,6 @@ def import_blocks_from_course(import_task_id, course_key_str, use_course_key_as_
)
-def _import_block(store, user_id, source_block, dest_parent_key):
- """
- Recursively import a learning core block and its children.`
- """
- def generate_block_key(source_key, dest_parent_key):
- """
- Deterministically generate an ID for the new block and return the key.
- Keys are generated such that they appear identical to a v1 library with
- the same input block_id, library name, library organization, and parent block using derive_key
- """
- if not isinstance(source_key.lib_key, LibraryLocatorV2):
- raise TypeError(f"Expected source library key of type LibraryLocatorV2, got {source_key.lib_key} instead.")
- source_key_as_v1_course_key = LibraryLocatorV1(
- org=source_key.lib_key.org,
- library=source_key.lib_key.slug,
- branch='library'
- )
- derived_block_key = derive_key(
- source=source_key_as_v1_course_key.make_usage_key(source_key.block_type, source_key.block_id),
- dest_parent=BlockKey(dest_parent_key.block_type, dest_parent_key.block_id),
- )
- return dest_parent_key.context_key.make_usage_key(*derived_block_key)
-
- source_key = source_block.scope_ids.usage_id
- new_block_key = generate_block_key(source_key, dest_parent_key)
- try:
- new_block = store.get_item(new_block_key)
- if new_block.parent.block_id != dest_parent_key.block_id:
- raise ValueError(
- "Expected existing block {} to be a child of {} but instead it's a child of {}".format(
- new_block_key, dest_parent_key, new_block.parent,
- )
- )
- except ItemNotFoundError:
- new_block = store.create_child(
- user_id,
- dest_parent_key,
- source_key.block_type,
- block_id=new_block_key.block_id,
- )
-
- # Prepare a list of this block's static assets; any assets that are referenced as /static/{path} (the
- # recommended way for referencing them) will stop working, and so we rewrite the url when importing.
- # Copying assets not advised because modulestore doesn't namespace assets to each block like learning core, which
- # might cause conflicts when the same filename is used across imported blocks.
- if isinstance(source_key, LibraryUsageLocatorV2):
- all_assets = library_api.get_library_block_static_asset_files(source_key)
- else:
- all_assets = []
-
- for field_name, field in source_block.fields.items():
- if field.scope not in (Scope.settings, Scope.content):
- continue # Only copy authored field data
- if field.is_set_on(source_block) or field.is_set_on(new_block):
- field_value = getattr(source_block, field_name)
- setattr(new_block, field_name, field_value)
- new_block.save()
- store.update_item(new_block, user_id)
-
- if new_block.has_children:
- # Delete existing children in the new block, which can be reimported again if they still exist in the
- # source library
- for existing_child_key in new_block.children:
- store.delete_item(existing_child_key, user_id)
- # Now import the children
- for child in source_block.get_children():
- _import_block(store, user_id, child, new_block_key)
-
- return new_block_key
-
-
def _filter_child(store, usage_key, capa_type):
"""
Return whether this block is both a problem and has a `capa_type` which is included in the filter.
@@ -172,49 +88,6 @@ def _problem_type_filter(store, library, capa_type):
return [key for key in library.children if _filter_child(store, key, capa_type)]
-def _import_from_learning_core(user_id, store, dest_block, source_block_ids):
- """
- Imports a block from a learning-core-based learning context (usually a
- content library) into modulestore, as a new child of dest_block.
- Any existing children of dest_block are replaced.
- """
- dest_key = dest_block.scope_ids.usage_id
- if not isinstance(dest_key, BlockUsageLocator):
- raise TypeError(f"Destination {dest_key} should be a modulestore course.")
- if user_id is None:
- raise ValueError("Cannot check user permissions - LibraryTools user_id is None")
-
- if len(set(source_block_ids)) != len(source_block_ids):
- # We don't support importing the exact same block twice because it would break the way we generate new IDs
- # for each block and then overwrite existing copies of blocks when re-importing the same blocks.
- raise ValueError("One or more library component IDs is a duplicate.")
-
- dest_course_key = dest_key.context_key
- user = User.objects.get(id=user_id)
- if not has_studio_write_access(user, dest_course_key):
- raise PermissionDenied()
-
- # Read the source block; this will also confirm that user has permission to read it.
- # (This could be slow and use lots of memory, except for the fact that LibraryContentBlock which calls this
- # should be limiting the number of blocks to a reasonable limit. We load them all now instead of one at a
- # time in order to raise any errors before we start actually copying blocks over.)
- orig_blocks = [load_block(UsageKey.from_string(key), user) for key in source_block_ids]
-
- with store.bulk_operations(dest_course_key):
- child_ids_updated = set()
-
- for block in orig_blocks:
- new_block_id = _import_block(store, user_id, block, dest_key)
- child_ids_updated.add(new_block_id)
-
- # Remove any existing children that are no longer used
- for old_child_id in set(dest_block.children) - child_ids_updated:
- store.delete_item(old_child_id, user_id)
- # If this was called from a handler, it will save dest_block at the end, so we must update
- # dest_block.children to avoid it saving the old value of children and deleting the new ones.
- dest_block.children = store.get_item(dest_key).children
-
-
class LibrarySyncChildrenTask(UserTask): # pylint: disable=abstract-method
"""
Base class for tasks which operate upon library_content children.
@@ -244,7 +117,7 @@ def sync_from_library(
self: LibrarySyncChildrenTask,
user_id: int,
dest_block_id: str,
- library_version: str | int | None,
+ library_version: str | None,
) -> None:
"""
Celery task to update the children of the library_content block at `dest_block_id`.
@@ -300,8 +173,8 @@ def _sync_children(
task: LibrarySyncChildrenTask,
store: MixedModuleStore,
user_id: int,
- dest_block: LibraryContentBlock,
- library_version: int | str | None,
+ dest_block: LegacyLibraryContentBlock,
+ library_version: str | None,
) -> None:
"""
Implementation helper for `sync_from_library` and `duplicate_children` Celery tasks.
@@ -309,41 +182,29 @@ def _sync_children(
Can update children with a specific library `library_version`, or latest (`library_version=None`).
"""
source_blocks = []
- library_key = dest_block.source_library_key
- filter_children = (dest_block.capa_type != ANY_CAPA_TYPE_VALUE)
- library = library_api.get_v1_or_v2_library(library_key, version=library_version)
- if not library:
+ library_key = dest_block.source_library_key.for_branch(
+ ModuleStoreEnum.BranchName.library
+ ).for_version(library_version)
+ try:
+ library = store.get_library(library_key, remove_version=False, remove_branch=False, head_validation=False)
+ except ItemNotFoundError:
task.status.fail(f"Requested library {library_key} not found.")
- elif isinstance(library, LibraryRootV1):
- if filter_children:
- # Apply simple filtering based on CAPA problem types:
- source_blocks.extend(_problem_type_filter(store, library, dest_block.capa_type))
- else:
- source_blocks.extend(library.children)
- with store.bulk_operations(dest_block.scope_ids.usage_id.context_key):
- try:
- dest_block.source_library_version = str(library.location.library_key.version_guid)
- store.update_item(dest_block, user_id)
- dest_block.children = store.copy_from_template(
- source_blocks, dest_block.location, user_id, head_validation=True
- )
- # ^-- copy_from_template updates the children in the DB
- # but we must also set .children here to avoid overwriting the DB again
- except Exception as exception: # pylint: disable=broad-except
- TASK_LOGGER.exception('Error importing children for %s', dest_block.scope_ids.usage_id, exc_info=True)
- if task.status.state != UserTaskStatus.FAILED:
- task.status.fail({'raw_error_msg': str(exception)})
- raise
- elif isinstance(library, library_api.ContentLibraryMetadata):
- # TODO: add filtering by capa_type when V2 library will support different problem types
+ return
+ filter_children = (dest_block.capa_type != ANY_CAPA_TYPE_VALUE)
+ if filter_children:
+ # Apply simple filtering based on CAPA problem types:
+ source_blocks.extend(_problem_type_filter(store, library, dest_block.capa_type))
+ else:
+ source_blocks.extend(library.children)
+ with store.bulk_operations(dest_block.scope_ids.usage_id.context_key):
try:
- source_block_ids = [
- str(library_api.LibraryXBlockMetadata.from_component(library_key, component).usage_key)
- for component in library_api.get_library_components(library_key)
- ]
- _import_from_learning_core(user_id, store, dest_block, source_block_ids)
- dest_block.source_library_version = str(library.version)
+ dest_block.source_library_version = str(library.location.library_key.version_guid)
store.update_item(dest_block, user_id)
+ dest_block.children = store.copy_from_template(
+ source_blocks, dest_block.location, user_id, head_validation=True
+ )
+ # ^-- copy_from_template updates the children in the DB
+ # but we must also set .children here to avoid overwriting the DB again
except Exception as exception: # pylint: disable=broad-except
TASK_LOGGER.exception('Error importing children for %s', dest_block.scope_ids.usage_id, exc_info=True)
if task.status.state != UserTaskStatus.FAILED:
@@ -354,8 +215,8 @@ def _sync_children(
def _copy_overrides(
store: MixedModuleStore,
user_id: int,
- source_block: LibraryContentBlock,
- dest_block: LibraryContentBlock
+ source_block: LegacyLibraryContentBlock,
+ dest_block: LegacyLibraryContentBlock
) -> None:
"""
Copy any overrides the user has made on children of `source` over to the children of `dest_block`, recursively.
diff --git a/openedx/core/djangoapps/content_libraries/tests/base.py b/openedx/core/djangoapps/content_libraries/tests/base.py
index 987133255f..a046206e30 100644
--- a/openedx/core/djangoapps/content_libraries/tests/base.py
+++ b/openedx/core/djangoapps/content_libraries/tests/base.py
@@ -36,6 +36,7 @@ URL_LIB_LTI_JWKS = URL_LIB_LTI_PREFIX + 'pub/jwks/'
URL_LIB_LTI_LAUNCH = URL_LIB_LTI_PREFIX + 'launch/'
URL_BLOCK_RENDER_VIEW = '/api/xblock/v2/xblocks/{block_key}/view/{view_name}/'
+URL_BLOCK_EMBED_VIEW = '/xblocks/v2/{block_key}/embed/{view_name}/' # Returns HTML not JSON so its URL is different
URL_BLOCK_GET_HANDLER_URL = '/api/xblock/v2/xblocks/{block_key}/handler_url/{handler_name}/'
URL_BLOCK_METADATA_URL = '/api/xblock/v2/xblocks/{block_key}/'
URL_BLOCK_FIELDS_URL = '/api/xblock/v2/xblocks/{block_key}/fields/'
@@ -300,6 +301,24 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
url = URL_BLOCK_RENDER_VIEW.format(block_key=block_key, view_name=view_name)
return self._api('get', url, None, expect_response)
+ def _embed_block(
+ self,
+ block_key,
+ *,
+ view_name="student_view",
+ version: str | int | None = None,
+ expect_response=200,
+ ) -> str:
+ """
+ Get an HTML response that displays the given XBlock. Returns HTML.
+ """
+ url = URL_BLOCK_EMBED_VIEW.format(block_key=block_key, view_name=view_name)
+ if version is not None:
+ url += f"?version={version}"
+ response = self.client.get(url)
+ assert response.status_code == expect_response, 'Unexpected response code {}:'.format(response.status_code)
+ return response.content.decode()
+
def _get_block_handler_url(self, block_key, handler_name):
"""
Get the URL to call a specific XBlock's handler.
@@ -308,3 +327,12 @@ class ContentLibrariesRestApiTest(APITransactionTestCase):
"""
url = URL_BLOCK_GET_HANDLER_URL.format(block_key=block_key, handler_name=handler_name)
return self._api('get', url, None, expect_response=200)["handler_url"]
+
+ def _get_library_block_fields(self, block_key, expect_response=200):
+ """ Get the fields of a specific block in the library. This API is only used by the MFE editors. """
+ result = self._api('get', URL_BLOCK_FIELDS_URL.format(block_key=block_key), None, expect_response)
+ return result
+
+ def _set_library_block_fields(self, block_key, new_fields, expect_response=200):
+ """ Set the fields of a specific block in the library. This API is only used by the MFE editors. """
+ return self._api('post', URL_BLOCK_FIELDS_URL.format(block_key=block_key), new_fields, expect_response)
diff --git a/openedx/core/djangoapps/content_libraries/tests/fields_test_block.py b/openedx/core/djangoapps/content_libraries/tests/fields_test_block.py
new file mode 100644
index 0000000000..0db5ac28d7
--- /dev/null
+++ b/openedx/core/djangoapps/content_libraries/tests/fields_test_block.py
@@ -0,0 +1,60 @@
+"""
+Block for testing variously scoped XBlock fields.
+"""
+import json
+
+from webob import Response
+from web_fragments.fragment import Fragment
+from xblock.core import XBlock, Scope
+from xblock import fields
+
+
+class FieldsTestBlock(XBlock):
+ """
+ Block for testing variously scoped XBlock fields and XBlock handlers.
+
+ This has only authored fields. See also UserStateTestBlock which has user fields.
+ """
+ BLOCK_TYPE = "fields-test"
+ has_score = False
+
+ display_name = fields.String(scope=Scope.settings, name='User State Test Block')
+ setting_field = fields.String(scope=Scope.settings, name='A setting')
+ content_field = fields.String(scope=Scope.content, name='A setting')
+
+ @XBlock.json_handler
+ def update_fields(self, data, suffix=None): # pylint: disable=unused-argument
+ """
+ Update the authored fields of this block
+ """
+ self.display_name = data["display_name"]
+ self.setting_field = data["setting_field"]
+ self.content_field = data["content_field"]
+ return {}
+
+ @XBlock.handler
+ def get_fields(self, request, suffix=None): # pylint: disable=unused-argument
+ """
+ Get the various fields of this XBlock.
+ """
+ return Response(
+ json.dumps({
+ "display_name": self.display_name,
+ "setting_field": self.setting_field,
+ "content_field": self.content_field,
+ }),
+ content_type='application/json',
+ charset='UTF-8',
+ )
+
+ def student_view(self, _context):
+ """
+ Return the student view.
+ """
+ fragment = Fragment()
+ fragment.add_content(f'{self.display_name} \n')
+ fragment.add_content(f'SF: {self.setting_field}
\n')
+ fragment.add_content(f'CF: {self.content_field}
\n')
+ handler_url = self.runtime.handler_url(self, 'get_fields')
+ fragment.add_content(f'handler URL: {handler_url}
\n')
+ return fragment
diff --git a/openedx/core/djangoapps/content_libraries/tests/test_api.py b/openedx/core/djangoapps/content_libraries/tests/test_api.py
index 8041c508dc..c526e7b1a1 100644
--- a/openedx/core/djangoapps/content_libraries/tests/test_api.py
+++ b/openedx/core/djangoapps/content_libraries/tests/test_api.py
@@ -308,6 +308,13 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe
description="Description for Collection 2",
created_by=self.user.id,
)
+ self.col3 = api.create_library_collection(
+ self.lib2.library_key,
+ collection_key="COL3",
+ title="Collection 3",
+ description="Description for Collection 3",
+ created_by=self.user.id,
+ )
# Create some library blocks in lib1
self.lib1_problem_block = self._add_block_to_library(
@@ -316,6 +323,10 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe
self.lib1_html_block = self._add_block_to_library(
self.lib1.library_key, "html", "html1",
)
+ # Create some library blocks in lib2
+ self.lib2_problem_block = self._add_block_to_library(
+ self.lib2.library_key, "problem", "problem2",
+ )
def test_create_library_collection(self):
event_receiver = mock.Mock()
@@ -498,3 +509,55 @@ class ContentLibraryCollectionsTest(ContentLibrariesRestApiTest, OpenEdxEventsTe
],
)
assert self.lib1_problem_block["id"] in str(exc.exception)
+
+ def test_set_library_component_collections(self):
+ event_receiver = mock.Mock()
+ CONTENT_OBJECT_ASSOCIATIONS_CHANGED.connect(event_receiver)
+ collection_update_event_receiver = mock.Mock()
+ LIBRARY_COLLECTION_UPDATED.connect(collection_update_event_receiver)
+ assert not list(self.col2.entities.all())
+ component = api.get_component_from_usage_key(UsageKey.from_string(self.lib2_problem_block["id"]))
+
+ api.set_library_component_collections(
+ self.lib2.library_key,
+ component,
+ collection_keys=[self.col2.key, self.col3.key],
+ )
+
+ assert len(authoring_api.get_collection(self.lib2.learning_package_id, self.col2.key).entities.all()) == 1
+ assert len(authoring_api.get_collection(self.lib2.learning_package_id, self.col3.key).entities.all()) == 1
+ self.assertDictContainsSubset(
+ {
+ "signal": CONTENT_OBJECT_ASSOCIATIONS_CHANGED,
+ "sender": None,
+ "content_object": ContentObjectChangedData(
+ object_id=self.lib2_problem_block["id"],
+ changes=["collections"],
+ ),
+ },
+ event_receiver.call_args_list[0].kwargs,
+ )
+ self.assertDictContainsSubset(
+ {
+ "signal": LIBRARY_COLLECTION_UPDATED,
+ "sender": None,
+ "library_collection": LibraryCollectionData(
+ self.lib2.library_key,
+ collection_key=self.col2.key,
+ background=True,
+ ),
+ },
+ collection_update_event_receiver.call_args_list[0].kwargs,
+ )
+ self.assertDictContainsSubset(
+ {
+ "signal": LIBRARY_COLLECTION_UPDATED,
+ "sender": None,
+ "library_collection": LibraryCollectionData(
+ self.lib2.library_key,
+ collection_key=self.col3.key,
+ background=True,
+ ),
+ },
+ collection_update_event_receiver.call_args_list[1].kwargs,
+ )
diff --git a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py
index d995a2c796..03b32e08ad 100644
--- a/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py
+++ b/openedx/core/djangoapps/content_libraries/tests/test_content_libraries.py
@@ -502,6 +502,30 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix
# Add a 'problem' XBlock to the library:
self._add_block_to_library(lib_id, block_type, 'test-block', expect_response=expect_response)
+ def test_library_not_found(self):
+ """Test that requests fail with 404 when the library does not exist"""
+ valid_not_found_key = 'lb:valid:key:video:1'
+ response = self.client.get(URL_BLOCK_METADATA_URL.format(block_key=valid_not_found_key))
+ self.assertEqual(response.status_code, 404)
+ self.assertEqual(response.json(), {
+ 'detail': "Content Library 'lib:valid:key' does not exist",
+ })
+
+ def test_block_not_found(self):
+ """Test that requests fail with 404 when the library exists but the XBlock does not"""
+ lib = self._create_library(
+ slug="test_lib_block_event_delete",
+ title="Event Test Library",
+ description="Testing event in library"
+ )
+ library_key = LibraryLocatorV2.from_string(lib['id'])
+ non_existent_block_key = LibraryUsageLocatorV2(lib_key=library_key, block_type='video', usage_id='123')
+ response = self.client.get(URL_BLOCK_METADATA_URL.format(block_key=non_existent_block_key))
+ self.assertEqual(response.status_code, 404)
+ self.assertEqual(response.json(), {
+ 'detail': f"The component '{non_existent_block_key}' does not exist.",
+ })
+
# Test that permissions are enforced for content libraries
def test_library_permissions(self): # pylint: disable=too-many-statements
@@ -635,22 +659,28 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix
# A random user cannot read OLX nor assets (this library has allow_public_read False):
with self.as_user(random_user):
self._get_library_block_olx(block3_key, expect_response=403)
+ self._get_library_block_fields(block3_key, expect_response=403)
self._get_library_block_assets(block3_key, expect_response=403)
- self._get_library_block_asset(block3_key, file_name="whatever.png", expect_response=403)
+ self._get_library_block_asset(block3_key, file_name="static/whatever.png", expect_response=403)
+ # Nor can they preview the block:
+ self._render_block_view(block3_key, view_name="student_view", expect_response=403)
# But if we grant allow_public_read, then they can:
with self.as_user(admin):
self._update_library(lib_id, allow_public_read=True)
- # self._set_library_block_asset(block3_key, "whatever.png", b"data")
+ self._set_library_block_asset(block3_key, "static/whatever.png", b"data")
with self.as_user(random_user):
self._get_library_block_olx(block3_key)
+ self._render_block_view(block3_key, view_name="student_view")
+ f = self._get_library_block_fields(block3_key)
# self._get_library_block_assets(block3_key)
# self._get_library_block_asset(block3_key, file_name="whatever.png")
- # Users without authoring permission cannot edit nor delete XBlocks (this library has allow_public_read False):
+ # Users without authoring permission cannot edit nor delete XBlocks:
for user in [reader, random_user]:
with self.as_user(user):
self._set_library_block_olx(block3_key, " ", expect_response=403)
- # self._set_library_block_asset(block3_key, "test.txt", b"data", expect_response=403)
+ self._set_library_block_fields(block3_key, {"data": " ", "metadata": {}}, expect_response=403)
+ self._set_library_block_asset(block3_key, "static/test.txt", b"data", expect_response=403)
self._delete_library_block(block3_key, expect_response=403)
self._commit_library_changes(lib_id, expect_response=403)
self._revert_library_changes(lib_id, expect_response=403)
@@ -659,9 +689,10 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix
with self.as_user(author_group_member):
olx = self._get_library_block_olx(block3_key)
self._set_library_block_olx(block3_key, olx)
- # self._get_library_block_assets(block3_key)
- # self._set_library_block_asset(block3_key, "test.txt", b"data")
- # self._get_library_block_asset(block3_key, file_name="test.txt")
+ self._set_library_block_fields(block3_key, {"data": olx, "metadata": {}})
+ self._get_library_block_assets(block3_key)
+ self._set_library_block_asset(block3_key, "static/test.txt", b"data")
+ self._get_library_block_asset(block3_key, file_name="static/test.txt")
self._delete_library_block(block3_key)
self._commit_library_changes(lib_id)
self._revert_library_changes(lib_id) # This is a no-op after the commit, but should still have 200 response
@@ -884,7 +915,6 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix
event_receiver.call_args.kwargs
)
- @skip("We still need to re-implement static asset handling.")
def test_library_block_add_asset_update_event(self):
"""
Check that LIBRARY_BLOCK_CREATED event is sent when a static asset is
@@ -903,7 +933,7 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix
block = self._add_block_to_library(lib_id, "unit", "u1")
block_id = block["id"]
- self._set_library_block_asset(block_id, "test.txt", b"data")
+ self._set_library_block_asset(block_id, "static/test.txt", b"data")
usage_key = LibraryUsageLocatorV2(
lib_key=library_key,
@@ -924,7 +954,6 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix
event_receiver.call_args.kwargs
)
- @skip("We still need to re-implement static asset handling.")
def test_library_block_del_asset_update_event(self):
"""
Check that LIBRARY_BLOCK_CREATED event is sent when a static asset is
@@ -943,9 +972,9 @@ class ContentLibrariesTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMix
block = self._add_block_to_library(lib_id, "unit", "u1")
block_id = block["id"]
- self._set_library_block_asset(block_id, "test.txt", b"data")
+ self._set_library_block_asset(block_id, "static/test.txt", b"data")
- self._delete_library_block_asset(block_id, 'text.txt')
+ self._delete_library_block_asset(block_id, 'static/text.txt')
usage_key = LibraryUsageLocatorV2(
lib_key=library_key,
@@ -1087,12 +1116,3 @@ class ContentLibraryXBlockValidationTest(APITestCase):
secure_token='random',
)))
self.assertEqual(response.status_code, 404)
-
- def test_not_found_fails_correctly(self):
- """Test fails with 404 when xblock key is valid but not found."""
- valid_not_found_key = 'lb:valid:key:video:1'
- response = self.client.get(URL_BLOCK_METADATA_URL.format(block_key=valid_not_found_key))
- self.assertEqual(response.status_code, 404)
- self.assertEqual(response.json(), {
- 'detail': f"XBlock {valid_not_found_key} does not exist, or you don't have permission to view it.",
- })
diff --git a/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py b/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py
new file mode 100644
index 0000000000..712117e3d2
--- /dev/null
+++ b/openedx/core/djangoapps/content_libraries/tests/test_embed_block.py
@@ -0,0 +1,184 @@
+"""
+Tests for the XBlock v2 runtime's "embed" view, using Content Libraries
+
+This view is used in the MFE to preview XBlocks that are in the library.
+"""
+import re
+
+import ddt
+from django.core.exceptions import ValidationError
+from django.test.utils import override_settings
+from openedx_events.tests.utils import OpenEdxEventsTestMixin
+import pytest
+from xblock.core import XBlock
+
+from openedx.core.djangoapps.content_libraries.tests.base import (
+ ContentLibrariesRestApiTest
+)
+from openedx.core.djangolib.testing.utils import skip_unless_cms
+from .fields_test_block import FieldsTestBlock
+
+
+@skip_unless_cms
+@ddt.ddt
+@override_settings(CORS_ORIGIN_WHITELIST=[]) # For some reason, this setting isn't defined in our test environment?
+class LibrariesEmbedViewTestCase(ContentLibrariesRestApiTest, OpenEdxEventsTestMixin):
+ """
+ Tests for embed_view and interacting with draft/published/past versions of
+ Learning-Core-based XBlocks (in Content Libraries).
+
+ These tests use the REST API, which in turn relies on the Python API.
+ Some tests may use the python API directly if necessary to provide
+ coverage of any code paths not accessible via the REST API.
+
+ In general, these tests should
+ (1) Use public APIs only - don't directly create data using other methods,
+ which results in a less realistic test and ties the test suite too
+ closely to specific implementation details.
+ (Exception: users can be provisioned using a user factory)
+ (2) Assert that fields are present in responses, but don't assert that the
+ entire response has some specific shape. That way, things like adding
+ new fields to an API response, which are backwards compatible, won't
+ break any tests, but backwards-incompatible API changes will.
+
+ WARNING: every test should have a unique library slug, because even though
+ the django/mysql database gets reset for each test case, the lookup between
+ library slug and bundle UUID does not because it's assumed to be immutable
+ and cached forever.
+ """
+
+ @XBlock.register_temp_plugin(FieldsTestBlock, FieldsTestBlock.BLOCK_TYPE)
+ def test_embed_vew_versions(self):
+ """
+ Test that the embed_view renders a block and can render different versions of it.
+ """
+ # Create a library:
+ lib = self._create_library(slug="test-eb-1", title="Test Library", description="")
+ lib_id = lib["id"]
+ # Create an XBlock. This will be the empty version 1:
+ create_response = self._add_block_to_library(lib_id, FieldsTestBlock.BLOCK_TYPE, "block1")
+ block_id = create_response["id"]
+ # Create version 2 of the block by setting its OLX:
+ olx_response = self._set_library_block_olx(block_id, """
+
+ """)
+ assert olx_response["version_num"] == 2
+ # Create version 3 of the block by setting its OLX again:
+ olx_response = self._set_library_block_olx(block_id, """
+
+ """)
+ assert olx_response["version_num"] == 3
+ # Publish the library:
+ self._commit_library_changes(lib_id)
+
+ # Create the draft (version 4) of the block:
+ olx_response = self._set_library_block_olx(block_id, """
+
+ """)
+
+ # Now render the "embed block" view. This test only runs in CMS so it should default to the draft:
+ html = self._embed_block(block_id)
+
+ def check_fields(display_name, setting_value, content_value):
+ assert f'{display_name} ' in html
+ assert f'SF: {setting_value}
' in html
+ assert f'CF: {content_value}
' in html
+ handler_url = re.search(r'handler URL: ([^<]+)
', html).group(1)
+ assert handler_url.startswith('http')
+ handler_result = self.client.get(handler_url).json()
+ assert handler_result == {
+ "display_name": display_name,
+ "setting_field": setting_value,
+ "content_field": content_value,
+ }
+ check_fields('Field Test Block (Draft, v4)', 'Draft setting value 4.', 'Draft content value 4.')
+
+ # But if we request the published version, we get that:
+ html = self._embed_block(block_id, version="published")
+ check_fields('Field Test Block (Published, v3)', 'Published setting value 3.', 'Published content value 3.')
+
+ # And if we request a specific version, we get that:
+ html = self._embed_block(block_id, version=3)
+ check_fields('Field Test Block (Published, v3)', 'Published setting value 3.', 'Published content value 3.')
+
+ # And if we request a specific version, we get that:
+ html = self._embed_block(block_id, version=2)
+ check_fields('Field Test Block (Old, v2)', 'Old setting value 2.', 'Old content value 2.')
+
+ html = self._embed_block(block_id, version=4)
+ check_fields('Field Test Block (Draft, v4)', 'Draft setting value 4.', 'Draft content value 4.')
+
+ @XBlock.register_temp_plugin(FieldsTestBlock, FieldsTestBlock.BLOCK_TYPE)
+ def test_handlers_modifying_published_data(self):
+ """
+ Test that if we requested any version other than "draft", the handlers should not allow _writing_ to authored
+ field data (because you'd be overwriting the latest draft version with changes based on an old version).
+
+ We may decide to relax this restriction in the future. Not sure how important it is.
+
+ Writing to student state is OK.
+ """
+ # Create a library:
+ lib = self._create_library(slug="test-eb-2", title="Test Library", description="")
+ lib_id = lib["id"]
+ # Create an XBlock. This will be the empty version 1:
+ create_response = self._add_block_to_library(lib_id, FieldsTestBlock.BLOCK_TYPE, "block1")
+ block_id = create_response["id"]
+
+ # Now render the "embed block" view. This test only runs in CMS so it should default to the draft:
+ html = self._embed_block(block_id)
+
+ def call_update_handler(**kwargs):
+ handler_url = re.search(r'handler URL: ([^<]+)
', html).group(1)
+ assert handler_url.startswith('http')
+ handler_url = handler_url.replace('get_fields', 'update_fields')
+ response = self.client.post(handler_url, kwargs, format='json')
+ assert response.status_code == 200
+
+ def check_fields(display_name, setting_field, content_field):
+ assert f'{display_name} ' in html
+ assert f'SF: {setting_field}
' in html
+ assert f'CF: {content_field}
' in html
+
+ # Call the update handler to change the fields on the draft:
+ call_update_handler(display_name="DN-01", setting_field="SV-01", content_field="CV-01")
+
+ # Render the block again and check that the handler was able to update the fields:
+ html = self._embed_block(block_id)
+ check_fields(display_name="DN-01", setting_field="SV-01", content_field="CV-01")
+
+ # Publish the library:
+ self._commit_library_changes(lib_id)
+
+ # Now try changing the authored fields of the published version using a handler:
+ html = self._embed_block(block_id, version="published")
+ expected_msg = "Do not make changes to a component starting from the published or past versions."
+ with pytest.raises(ValidationError, match=expected_msg) as err:
+ call_update_handler(display_name="DN-X", setting_field="SV-X", content_field="CV-X")
+
+ # Now try changing the authored fields of a specific past version using a handler:
+ html = self._embed_block(block_id, version=2)
+ with pytest.raises(ValidationError, match=expected_msg) as err:
+ call_update_handler(display_name="DN-X", setting_field="SV-X", content_field="CV-X")
+
+ # Make sure the fields were not updated:
+ html = self._embed_block(block_id)
+ check_fields(display_name="DN-01", setting_field="SV-01", content_field="CV-01")
+
+ # TODO: test that any static assets referenced in the student_view html are loaded as the correct version, and not
+ # always loaded as "latest draft".
+
+ # TODO: if we are ever able to run these tests in the LMS, test that the LMS only allows accessing the published
+ # version.
diff --git a/openedx/core/djangoapps/content_libraries/tests/test_static_assets.py b/openedx/core/djangoapps/content_libraries/tests/test_static_assets.py
index 92ff4c1767..a5f69f94b1 100644
--- a/openedx/core/djangoapps/content_libraries/tests/test_static_assets.py
+++ b/openedx/core/djangoapps/content_libraries/tests/test_static_assets.py
@@ -1,11 +1,16 @@
"""
Tests for static asset files in Learning-Core-based Content Libraries
"""
-from unittest import skip
+from uuid import UUID
+from opaque_keys.edx.keys import UsageKey
+
+from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.content_libraries.tests.base import (
ContentLibrariesRestApiTest,
)
+from openedx.core.djangoapps.xblock.api import get_component_from_usage_key
+from openedx.core.djangolib.testing.utils import skip_unless_cms
# Binary data representing an SVG image file
SVG_DATA = """
@@ -23,15 +28,10 @@ I'm Anant Agarwal, I'm the president of edX,
"""
-@skip("Assets are being reimplemented in Learning Core. Disable until that's ready.")
+@skip_unless_cms
class ContentLibrariesStaticAssetsTest(ContentLibrariesRestApiTest):
"""
Tests for static asset files in Learning-Core-based Content Libraries
-
- WARNING: every test should have a unique library slug, because even though
- the django/mysql database gets reset for each test case, the lookup between
- library slug and bundle UUID does not because it's assumed to be immutable
- and cached forever.
"""
def test_asset_filenames(self):
@@ -79,7 +79,7 @@ class ContentLibrariesStaticAssetsTest(ContentLibrariesRestApiTest):
/>
""")
# Upload the transcript file
- self._set_library_block_asset(block_id, "3_yD_cEKoCk-en.srt", TRANSCRIPT_DATA)
+ self._set_library_block_asset(block_id, "static/3_yD_cEKoCk-en.srt", TRANSCRIPT_DATA)
transcript_handler_url = self._get_block_handler_url(block_id, "transcript")
@@ -108,3 +108,79 @@ class ContentLibrariesStaticAssetsTest(ContentLibrariesRestApiTest):
self._commit_library_changes(library["id"])
check_sjson()
check_download()
+
+
+@skip_unless_cms
+class ContentLibrariesComponentVersionAssetTest(ContentLibrariesRestApiTest):
+ """
+ Tests for the view that actually delivers the Library asset in Studio.
+ """
+
+ def setUp(self):
+ super().setUp()
+
+ library = self._create_library(slug="asset-lib2", title="Static Assets Test Library")
+ block = self._add_block_to_library(library["id"], "html", "html1")
+ self._set_library_block_asset(block["id"], "static/test.svg", SVG_DATA)
+ usage_key = UsageKey.from_string(block["id"])
+ self.component = get_component_from_usage_key(usage_key)
+ self.draft_component_version = self.component.versioning.draft
+
+ def test_good_responses(self):
+ get_response = self.client.get(
+ f"/library_assets/{self.draft_component_version.uuid}/static/test.svg"
+ )
+ assert get_response.status_code == 200
+ content = b''.join(chunk for chunk in get_response.streaming_content)
+ assert content == SVG_DATA
+
+ good_head_response = self.client.head(
+ f"/library_assets/{self.draft_component_version.uuid}/static/test.svg"
+ )
+ assert good_head_response.headers == get_response.headers
+
+ def test_missing(self):
+ """Test asset requests that should 404."""
+ # Non-existent version...
+ wrong_version_uuid = UUID('11111111-1111-1111-1111-111111111111')
+ response = self.client.get(
+ f"/library_assets/{wrong_version_uuid}/static/test.svg"
+ )
+ assert response.status_code == 404
+
+ # Non-existent file...
+ response = self.client.get(
+ f"/library_assets/{self.draft_component_version.uuid}/static/missing.svg"
+ )
+ assert response.status_code == 404
+
+ # File-like ComponenVersionContent entry that isn't an actually
+ # downloadable file...
+ response = self.client.get(
+ f"/library_assets/{self.draft_component_version.uuid}/block.xml"
+ )
+ assert response.status_code == 404
+
+ def test_anonymous_user(self):
+ """Anonymous users shouldn't get access to library assets."""
+ self.client.logout()
+ response = self.client.get(
+ f"/library_assets/{self.draft_component_version.uuid}/static/test.svg"
+ )
+ assert response.status_code == 403
+
+ def test_unauthorized_user(self):
+ """User who is not a Content Library staff should not have access."""
+ self.client.logout()
+ student = UserFactory.create(
+ username="student",
+ email="student@example.com",
+ password="student-pass",
+ is_staff=False,
+ is_superuser=False,
+ )
+ self.client.login(username="student", password="student-pass")
+ get_response = self.client.get(
+ f"/library_assets/{self.draft_component_version.uuid}/static/test.svg"
+ )
+ assert get_response.status_code == 403
diff --git a/openedx/core/djangoapps/content_libraries/urls.py b/openedx/core/djangoapps/content_libraries/urls.py
index 9455f0de5e..b9dc05fabc 100644
--- a/openedx/core/djangoapps/content_libraries/urls.py
+++ b/openedx/core/djangoapps/content_libraries/urls.py
@@ -57,6 +57,8 @@ urlpatterns = [
path('blocks//', include([
# Get metadata about a specific XBlock in this library, or delete the block:
path('', views.LibraryBlockView.as_view()),
+ # Update collections for a given component
+ path('collections/', views.LibraryBlockCollectionsView.as_view(), name='update-collections'),
# Get the LTI URL of a specific XBlock
path('lti/', views.LibraryBlockLtiUrlView.as_view(), name='lti-url'),
# Get the OLX source code of the specified block:
@@ -73,4 +75,9 @@ urlpatterns = [
path('pub/jwks/', views.LtiToolJwksView.as_view(), name='lti-pub-jwks'),
])),
])),
+ path(
+ 'library_assets//',
+ views.component_version_asset,
+ name='library-assets',
+ ),
]
diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py
index 835a5de1f1..4e48805ac5 100644
--- a/openedx/core/djangoapps/content_libraries/views.py
+++ b/openedx/core/djangoapps/content_libraries/views.py
@@ -71,14 +71,16 @@ import logging
from django.conf import settings
from django.contrib.auth import authenticate, get_user_model, login
from django.contrib.auth.models import Group
+from django.core.exceptions import ObjectDoesNotExist
from django.db.transaction import atomic, non_atomic_requests
-from django.http import Http404, HttpResponseBadRequest, JsonResponse
+from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse, StreamingHttpResponse
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.csrf import csrf_exempt
+from django.views.decorators.http import require_safe
from django.views.generic.base import TemplateResponseMixin, View
from pylti1p3.contrib.django import DjangoCacheDataStorage, DjangoDbToolConf, DjangoMessageLaunch, DjangoOIDCLogin
from pylti1p3.exception import LtiException, OIDCException
@@ -86,6 +88,7 @@ from pylti1p3.exception import LtiException, OIDCException
import edx_api_doc_tools as apidocs
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
+from openedx_learning.api import authoring
from organizations.api import ensure_organization
from organizations.exceptions import InvalidOrganizationException
from organizations.models import Organization
@@ -106,6 +109,7 @@ from openedx.core.djangoapps.content_libraries.serializers import (
ContentLibraryPermissionLevelSerializer,
ContentLibraryPermissionSerializer,
ContentLibraryUpdateSerializer,
+ ContentLibraryComponentCollectionsUpdateSerializer,
LibraryXBlockCreationSerializer,
LibraryXBlockMetadataSerializer,
LibraryXBlockTypeSerializer,
@@ -617,7 +621,7 @@ class LibraryBlockView(APIView):
"""
key = LibraryUsageLocatorV2.from_string(usage_key_str)
api.require_permission_for_library_key(key.lib_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY)
- result = api.get_library_block(key)
+ result = api.get_library_block(key, include_collections=True)
return Response(LibraryXBlockMetadataSerializer(result).data)
@@ -640,6 +644,41 @@ class LibraryBlockView(APIView):
return Response({})
+@method_decorator(non_atomic_requests, name="dispatch")
+@view_auth_classes()
+class LibraryBlockCollectionsView(APIView):
+ """
+ View to set collections for a component.
+ """
+ @convert_exceptions
+ def patch(self, request, usage_key_str) -> Response:
+ """
+ Sets Collections for a Component.
+
+ Collection and Components must all be part of the given library/learning package.
+ """
+ key = LibraryUsageLocatorV2.from_string(usage_key_str)
+ content_library = api.require_permission_for_library_key(
+ key.lib_key,
+ request.user,
+ permissions.CAN_EDIT_THIS_CONTENT_LIBRARY
+ )
+ component = api.get_component_from_usage_key(key)
+ serializer = ContentLibraryComponentCollectionsUpdateSerializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+
+ collection_keys = serializer.validated_data['collection_keys']
+ api.set_library_component_collections(
+ library_key=key.lib_key,
+ component=component,
+ collection_keys=collection_keys,
+ created_by=self.request.user.id,
+ content_library=content_library,
+ )
+
+ return Response({'count': len(collection_keys)})
+
+
@method_decorator(non_atomic_requests, name="dispatch")
@view_auth_classes()
class LibraryBlockLtiUrlView(APIView):
@@ -692,10 +731,10 @@ class LibraryBlockOlxView(APIView):
serializer.is_valid(raise_exception=True)
new_olx_str = serializer.validated_data["olx"]
try:
- api.set_library_block_olx(key, new_olx_str)
+ version_num = api.set_library_block_olx(key, new_olx_str)
except ValueError as err:
raise ValidationError(detail=str(err)) # lint-amnesty, pylint: disable=raise-missing-from
- return Response(LibraryXBlockOlxSerializer({"olx": new_olx_str}).data)
+ return Response(LibraryXBlockOlxSerializer({"olx": new_olx_str, "version_num": version_num}).data)
@method_decorator(non_atomic_requests, name="dispatch")
@@ -756,7 +795,7 @@ class LibraryBlockAssetView(APIView):
raise ValidationError("File too big")
file_content = file_wrapper.read()
try:
- result = api.add_library_block_static_asset_file(usage_key, file_path, file_content)
+ result = api.add_library_block_static_asset_file(usage_key, file_path, file_content, request.user)
except ValueError:
raise ValidationError("Invalid file path") # lint-amnesty, pylint: disable=raise-missing-from
return Response(LibraryXBlockStaticFileSerializer(result).data)
@@ -771,7 +810,7 @@ class LibraryBlockAssetView(APIView):
usage_key.lib_key, request.user, permissions.CAN_EDIT_THIS_CONTENT_LIBRARY,
)
try:
- api.delete_library_block_static_asset_file(usage_key, file_path)
+ api.delete_library_block_static_asset_file(usage_key, file_path, request.user)
except ValueError:
raise ValidationError("Invalid file path") # lint-amnesty, pylint: disable=raise-missing-from
return Response(status=status.HTTP_204_NO_CONTENT)
@@ -919,7 +958,7 @@ class LtiToolLaunchView(TemplateResponseMixin, LtiToolView):
LTI platform. Other features and resouces are ignored.
"""
- template_name = 'content_libraries/xblock_iframe.html'
+ template_name = 'xblock_v2/xblock_iframe.html'
@property
def launch_data(self):
@@ -1107,3 +1146,74 @@ class LtiToolJwksView(LtiToolView):
Return the JWKS.
"""
return JsonResponse(self.lti_tool_config.get_jwks(), safe=False)
+
+
+@require_safe
+def component_version_asset(request, component_version_uuid, asset_path):
+ """
+ Serves static assets associated with particular Component versions.
+
+ Important notes:
+ * This is meant for Studio/authoring use ONLY. It requires read access to
+ the content library.
+ * It uses the UUID because that's easier to parse than the key field (which
+ could be part of an OpaqueKey, but could also be almost anything else).
+ * This is not very performant, and we still want to use the X-Accel-Redirect
+ method for serving LMS traffic in the longer term (and probably Studio
+ eventually).
+ """
+ try:
+ component_version = authoring.get_component_version_by_uuid(
+ component_version_uuid
+ )
+ except ObjectDoesNotExist as exc:
+ raise Http404() from exc
+
+ # Permissions check...
+ learning_package = component_version.component.learning_package
+ library_key = LibraryLocatorV2.from_string(learning_package.key)
+ api.require_permission_for_library_key(
+ library_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY,
+ )
+
+ # We already have logic for getting the correct content and generating the
+ # proper headers in Learning Core, but the response generated here is an
+ # X-Accel-Redirect and lacks the actual content. We eventually want to use
+ # this response in conjunction with a media reverse proxy (Caddy or Nginx),
+ # but in the short term we're just going to remove the redirect and stream
+ # the content directly.
+ redirect_response = authoring.get_redirect_response_for_component_asset(
+ component_version_uuid,
+ asset_path,
+ public=False,
+ learner_downloadable_only=False,
+ )
+
+ # If there was any error, we return that response because it will have the
+ # correct headers set and won't have any X-Accel-Redirect header set.
+ if redirect_response.status_code != 200:
+ return redirect_response
+
+ # If we got here, we know that the asset exists and it's okay to download.
+ cv_content = component_version.componentversioncontent_set.get(key=asset_path)
+ content = cv_content.content
+
+ # Delete the re-direct part of the response headers. We'll copy the rest.
+ headers = redirect_response.headers
+ headers.pop('X-Accel-Redirect')
+
+ # We need to set the content size header manually because this is a
+ # streaming response. It's not included in the redirect headers because it's
+ # not needed there (the reverse-proxy would have direct access to the file).
+ headers['Content-Length'] = content.size
+
+ if request.method == "HEAD":
+ return HttpResponse(headers=headers)
+
+ # Otherwise it's going to be a GET response. We don't support response
+ # offsets or anything fancy, because we don't expect to run this view at
+ # LMS-scale.
+ return StreamingHttpResponse(
+ content.read_file().chunks(),
+ headers=redirect_response.headers,
+ )
diff --git a/openedx/core/djangoapps/notifications/config/waffle.py b/openedx/core/djangoapps/notifications/config/waffle.py
index 4a1481f6f5..b74cd84dca 100644
--- a/openedx/core/djangoapps/notifications/config/waffle.py
+++ b/openedx/core/djangoapps/notifications/config/waffle.py
@@ -49,3 +49,13 @@ ENABLE_ORA_GRADE_NOTIFICATION = CourseWaffleFlag(f"{WAFFLE_NAMESPACE}.enable_ora
# .. toggle_warning: When the flag is ON, Notifications Grouping feature is enabled.
# .. toggle_tickets: INF-1472
ENABLE_NOTIFICATION_GROUPING = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_notification_grouping', __name__)
+
+# .. toggle_name: notifications.enable_new_notification_view
+# .. toggle_implementation: WaffleFlag
+# .. toggle_default: False
+# .. toggle_description: Waffle flag to enable new notification view
+# .. toggle_use_cases: temporary, open_edx
+# .. toggle_creation_date: 2024-09-30
+# .. toggle_target_removal_date: 2025-10-10
+# .. toggle_tickets: INF-1603
+ENABLE_NEW_NOTIFICATION_VIEW = WaffleFlag(f"{WAFFLE_NAMESPACE}.enable_new_notification_view", __name__)
diff --git a/openedx/core/djangoapps/notifications/email/utils.py b/openedx/core/djangoapps/notifications/email/utils.py
index 582e867d62..81a245b2cf 100644
--- a/openedx/core/djangoapps/notifications/email/utils.py
+++ b/openedx/core/djangoapps/notifications/email/utils.py
@@ -3,6 +3,7 @@ Email Notifications Utils
"""
import datetime
import json
+import logging
from bs4 import BeautifulSoup
from django.conf import settings
@@ -31,6 +32,7 @@ from .notification_icons import NotificationTypeIcons
User = get_user_model()
+log = logging.getLogger(__name__)
def is_email_notification_flag_enabled(user=None):
@@ -411,4 +413,6 @@ def update_user_preferences_from_patch(encrypted_username, encrypted_patch):
if pref_value else EmailCadence.NEVER
type_prefs['email_cadence'] = cadence_value
preference.save()
+ if not user.id:
+ log.info(f" - user.id is null - {encrypted_username} ")
notification_preference_unsubscribe_event(user)
diff --git a/openedx/core/djangoapps/notifications/events.py b/openedx/core/djangoapps/notifications/events.py
index 91b12075a8..74e6e56e41 100644
--- a/openedx/core/djangoapps/notifications/events.py
+++ b/openedx/core/djangoapps/notifications/events.py
@@ -167,6 +167,5 @@ def notification_preference_unsubscribe_event(user):
'username': user.username,
'event_type': 'email_digest_unsubscribe'
}
- with tracker.get_tracker().context(NOTIFICATION_PREFERENCE_UNSUBSCRIBE, event_data):
- tracker.emit(NOTIFICATION_PREFERENCE_UNSUBSCRIBE, event_data)
- segment.track(user.id, NOTIFICATION_PREFERENCE_UNSUBSCRIBE, event_data)
+ tracker.emit(NOTIFICATION_PREFERENCE_UNSUBSCRIBE, event_data)
+ segment.track(user.id, NOTIFICATION_PREFERENCE_UNSUBSCRIBE, event_data)
diff --git a/openedx/core/djangoapps/notifications/utils.py b/openedx/core/djangoapps/notifications/utils.py
index 249eaf8749..fa948dcf42 100644
--- a/openedx/core/djangoapps/notifications/utils.py
+++ b/openedx/core/djangoapps/notifications/utils.py
@@ -5,7 +5,7 @@ from typing import Dict, List
from common.djangoapps.student.models import CourseAccessRole, CourseEnrollment
from openedx.core.djangoapps.django_comment_common.models import Role
-from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS
+from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS, ENABLE_NEW_NOTIFICATION_VIEW
from openedx.core.lib.cache_utils import request_cached
@@ -47,6 +47,13 @@ def get_show_notifications_tray(user):
return show_notifications_tray
+def get_is_new_notification_view_enabled():
+ """
+ Returns True if the waffle flag for the new notification view is enabled, False otherwise.
+ """
+ return ENABLE_NEW_NOTIFICATION_VIEW.is_enabled()
+
+
def get_list_in_batches(input_list, batch_size):
"""
Divides the list of objects into list of list of objects each of length batch_size.
diff --git a/openedx/core/djangoapps/notifications/views.py b/openedx/core/djangoapps/notifications/views.py
index e6798c7c5c..e87274088f 100644
--- a/openedx/core/djangoapps/notifications/views.py
+++ b/openedx/core/djangoapps/notifications/views.py
@@ -39,7 +39,7 @@ from .serializers import (
UserCourseNotificationPreferenceSerializer,
UserNotificationPreferenceUpdateSerializer,
)
-from .utils import get_show_notifications_tray
+from .utils import get_show_notifications_tray, get_is_new_notification_view_enabled
@allow_any_authenticated_user()
@@ -329,6 +329,7 @@ class NotificationCountView(APIView):
)
count_total = 0
show_notifications_tray = get_show_notifications_tray(self.request.user)
+ is_new_notification_view_enabled = get_is_new_notification_view_enabled()
count_by_app_name_dict = {
app_name: 0
for app_name in COURSE_NOTIFICATION_APPS
@@ -344,7 +345,8 @@ class NotificationCountView(APIView):
"show_notifications_tray": show_notifications_tray,
"count": count_total,
"count_by_app_name": count_by_app_name_dict,
- "notification_expiry_days": settings.NOTIFICATIONS_EXPIRY
+ "notification_expiry_days": settings.NOTIFICATIONS_EXPIRY,
+ "is_new_notification_view_enabled": is_new_notification_view_enabled
})
diff --git a/openedx/core/djangoapps/programs/tasks.py b/openedx/core/djangoapps/programs/tasks.py
index 42548fd494..d3b4b867e4 100644
--- a/openedx/core/djangoapps/programs/tasks.py
+++ b/openedx/core/djangoapps/programs/tasks.py
@@ -315,14 +315,16 @@ def award_program_certificates(self, username): # lint-amnesty, pylint: disable
if str(programs_without_certificates[0]).lower() == "all":
return
- LOGGER.info(f"Running task award_program_certificates for user {student}")
+ LOGGER.info(f"Running task award_program_certificates for user {student.id}")
try:
completed_programs = {}
for site in Site.objects.all():
completed_programs.update(get_completed_programs(site, student))
if not completed_programs:
- LOGGER.warning(f"Task award_program_certificates was called for user {student} with no completed programs")
+ LOGGER.warning(
+ f"Task award_program_certificates was called for user {student.id} with no completed programs"
+ )
return
# determine which program certificates have been awarded to the user
@@ -331,7 +333,7 @@ def award_program_certificates(self, username): # lint-amnesty, pylint: disable
# program is part of the "programs without certificates" list in our site configuration
awarded_and_skipped_program_uuids = list(set(existing_program_uuids + list(programs_without_certificates)))
except Exception as exc:
- error_msg = f"Failed to determine program certificates to be awarded for user {student}: {exc}"
+ error_msg = f"Failed to determine program certificates to be awarded for user {student.id}: {exc}"
LOGGER.exception(error_msg)
raise MaxRetriesExceededError(
f"Failed to award a program certificate to user {student.id}. Reason: {error_msg}"
@@ -360,18 +362,18 @@ def award_program_certificates(self, username): # lint-amnesty, pylint: disable
for program_uuid in new_program_uuids:
try:
award_program_certificate(credentials_client, student, program_uuid)
- LOGGER.info(f"Awarded program certificate to user {student} in program {program_uuid}")
+ LOGGER.info(f"Awarded program certificate to user {student.id} in program {program_uuid}")
except HTTPError as exc:
if exc.response.status_code == 404:
LOGGER.warning(
- f"Unable to award a program certificate to user {student} in program {program_uuid}. A "
+ f"Unable to award a program certificate to user {student.id} in program {program_uuid}. A "
f"certificate configuration for program {program_uuid} could not be found, the program might "
"not be configured correctly in Credentials"
)
elif exc.response.status_code == 429:
# Let celery handle retry attempts and backoff
error_msg = (
- f"Rate limited. Attempting to award certificate to user {student} in program {program_uuid}."
+ f"Rate limited. Attempting to award certificate to user {student.id} in program {program_uuid}."
)
LOGGER.warning(error_msg)
raise MaxRetriesExceededError(
@@ -379,33 +381,33 @@ def award_program_certificates(self, username): # lint-amnesty, pylint: disable
) from exc
else:
LOGGER.warning(
- f"Unable to award program certificate to user {student} in program {program_uuid}. The program "
- "might not be configured correctly in Credentials"
+ f"Unable to award program certificate to user {student.id} in program {program_uuid}. The "
+ "program might not be configured correctly in Credentials"
)
except Exception as exc: # pylint: disable=broad-except
# keep trying to award other certs, but let celery retry the whole task to fix any missing entries
LOGGER.exception(
- f"Failed to award program certificate to user {student} in program {program_uuid}: {exc}"
+ f"Failed to award program certificate to user {student.id} in program {program_uuid}: {exc}"
)
failed_program_certificate_award_attempts.append(program_uuid)
if failed_program_certificate_award_attempts:
# N.B. This logic assumes that this task is idempotent
- LOGGER.info(f"Retrying failed tasks to award program certificate(s) to user {student}")
+ LOGGER.info(f"Retrying failed tasks to award program certificate(s) to user {student.id}")
# The error message may change on each reattempt but will never be raised until the max number of retries
# have been exceeded. It is unlikely that this list will change by the time it reaches its maximimum number
# of attempts.
error_msg = (
- f"Failed to award program certificate(s) for user {student} in programs "
+ f"Failed to award program certificate(s) for user {student.id} in programs "
f"{failed_program_certificate_award_attempts}"
)
raise MaxRetriesExceededError(
f"Failed to award a program certificate to user {student.id}. Reason: {error_msg}"
)
else:
- LOGGER.warning(f"User {student} is not eligible for any new program certificates")
+ LOGGER.warning(f"User {student.id} is not eligible for any new program certificates")
- LOGGER.info(f"Successfully completed the task award_program_certificates for user {student}")
+ LOGGER.info(f"Successfully completed the task award_program_certificates for user {student.id}")
# pylint: disable=W0613
@@ -504,7 +506,7 @@ def award_course_certificate(self, username, course_run_key):
)
return
- LOGGER.info(f"Running task award_course_certificate for user {user}")
+ LOGGER.info(f"Running task award_course_certificate for user {user.id}")
try:
course_key = CourseKey.from_string(course_run_key)
except InvalidKeyError as exc:
@@ -574,7 +576,7 @@ def award_course_certificate(self, username, course_run_key):
org=course_key.org,
)
except Exception as exc:
- error_msg = f"Failed to post course certificate to be awarded for user {user}."
+ error_msg = f"Failed to post course certificate to be awarded for user {user.id}."
raise MaxRetriesExceededError(
f"Failed to award course certificate for user {user.id} for course {course_run_key}. Reason: {error_msg}"
) from exc
@@ -628,14 +630,14 @@ def revoke_program_certificates(self, username, course_key): # lint-amnesty, py
)
return
- LOGGER.info(f"Running task revoke_program_certificates for user {student}")
+ LOGGER.info(f"Running task revoke_program_certificates for user {student.id}")
try:
inverted_programs = get_inverted_programs(student)
course_specific_programs = inverted_programs.get(course_key)
if not course_specific_programs:
LOGGER.warning(
- f"Task revoke_program_certificates was called for user {student} and course run {course_key} with no "
- "engaged programs"
+ f"Task revoke_program_certificates was called for user {student.id} and course run {course_key} with "
+ "no engaged programs"
)
return
@@ -644,7 +646,7 @@ def revoke_program_certificates(self, username, course_key): # lint-amnesty, py
except Exception as exc:
error_msg = (
f"Failed to determine if any program certificates associated with course run {course_key} should be "
- f"revoked from user {student}"
+ f"revoked from user {student.id}"
)
LOGGER.exception(error_msg)
raise MaxRetriesExceededError(
@@ -668,17 +670,17 @@ def revoke_program_certificates(self, username, course_key): # lint-amnesty, py
for program_uuid in program_uuids_to_revoke:
try:
revoke_program_certificate(credentials_client, username, program_uuid)
- LOGGER.info(f"Revoked program certificate from user {student} in program {program_uuid}")
+ LOGGER.info(f"Revoked program certificate from user {student.id} in program {program_uuid}")
except HTTPError as exc:
if exc.response.status_code == 404:
LOGGER.warning(
- f"Unable to revoke program certificate from user {student} in program {program_uuid}, a "
+ f"Unable to revoke program certificate from user {student.id} in program {program_uuid}, a "
"program certificate could not be found"
)
elif exc.response.status_code == 429:
# Let celery handle retry attempts and backoff
error_msg = (
- f"Rate limited. Attempting to revoke a program certificate from user {student} in program "
+ f"Rate limited. Attempting to revoke a program certificate from user {student.id} in program "
f"{program_uuid}."
)
LOGGER.warning(error_msg)
@@ -687,23 +689,23 @@ def revoke_program_certificates(self, username, course_key): # lint-amnesty, py
) from exc
else:
LOGGER.warning(
- f"Unable to revoke program certificate from user {student} in program {program_uuid}"
+ f"Unable to revoke program certificate from user {student.id} in program {program_uuid}"
)
except Exception as exc: # pylint: disable=broad-except
# keep trying to revoke other certs, but let celery retry the whole task to fix any missing entries
LOGGER.exception(
- f"Failed to revoke program certificate from user {student} in program {program_uuid}: {exc}"
+ f"Failed to revoke program certificate from user {student.id} in program {program_uuid}: {exc}"
)
failed_program_certificate_revoke_attempts.append(program_uuid)
if failed_program_certificate_revoke_attempts:
# N.B. This logic assumes that this task is idempotent
- LOGGER.info(f"Failed task to revoke program certificate(s) from user {student}")
+ LOGGER.info(f"Failed task to revoke program certificate(s) from user {student .id}")
# The error message may change on each reattempt but will never be raised until the max number of retries
# have been exceeded. It is unlikely that this list will change by the time it reaches its maximimum number
# of attempts.
error_msg = (
- f"Failed to revoke program certificate(s) from user {student} for programs "
+ f"Failed to revoke program certificate(s) from user {student.id} for programs "
f"{failed_program_certificate_revoke_attempts}"
)
raise MaxRetriesExceededError(
@@ -711,9 +713,9 @@ def revoke_program_certificates(self, username, course_key): # lint-amnesty, py
f"Reason: {error_msg}"
)
else:
- LOGGER.info(f"No program certificates to revoke from user {student}")
+ LOGGER.info(f"No program certificates to revoke from user {student.id}")
- LOGGER.info(f"Successfully completed the task revoke_program_certificates for user {student}")
+ LOGGER.info(f"Successfully completed the task revoke_program_certificates for user {student.id}")
@shared_task(
diff --git a/openedx/core/djangoapps/programs/tests/test_tasks.py b/openedx/core/djangoapps/programs/tests/test_tasks.py
index d30cc4e868..e2b1c554c8 100644
--- a/openedx/core/djangoapps/programs/tests/test_tasks.py
+++ b/openedx/core/djangoapps/programs/tests/test_tasks.py
@@ -365,10 +365,10 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
tasks.award_program_certificates.delay(self.student.username).get()
assert mock_award_program_certificate.call_count == 3
mock_warning.assert_called_once_with(
- f"Failed to award program certificate to user {self.student} in program 1: boom"
+ f"Failed to award program certificate to user {self.student.id} in program 1: boom"
)
- mock_info.assert_any_call(f"Awarded program certificate to user {self.student} in program 1")
- mock_info.assert_any_call(f"Awarded program certificate to user {self.student} in program 2")
+ mock_info.assert_any_call(f"Awarded program certificate to user {self.student.id} in program 1")
+ mock_info.assert_any_call(f"Awarded program certificate to user {self.student.id} in program 2")
def test_retry_on_programs_api_errors(self, mock_get_completed_programs, *_mock_helpers):
"""
@@ -835,10 +835,10 @@ class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiC
assert mock_revoke_program_certificate.call_count == 3
mock_warning.assert_called_once_with(
- f"Failed to revoke program certificate from user {self.student} in program 1: boom"
+ f"Failed to revoke program certificate from user {self.student.id} in program 1: boom"
)
- mock_info.assert_any_call(f"Revoked program certificate from user {self.student} in program 1")
- mock_info.assert_any_call(f"Revoked program certificate from user {self.student} in program 2")
+ mock_info.assert_any_call(f"Revoked program certificate from user {self.student.id} in program 1")
+ mock_info.assert_any_call(f"Revoked program certificate from user {self.student.id} in program 2")
def test_retry_on_credentials_api_errors(
self,
diff --git a/openedx/core/djangoapps/waffle_utils/__init__.py b/openedx/core/djangoapps/waffle_utils/__init__.py
index c21d052cfc..95fa360d5e 100644
--- a/openedx/core/djangoapps/waffle_utils/__init__.py
+++ b/openedx/core/djangoapps/waffle_utils/__init__.py
@@ -5,7 +5,7 @@ we keep here some extra classes for usage within edx-platform. These classes cov
import logging
from edx_toggles.toggles import WaffleFlag
-from opaque_keys.edx.keys import CourseKey
+from opaque_keys.edx.keys import CourseKey, LearningContextKey
log = logging.getLogger(__name__)
@@ -99,7 +99,11 @@ class CourseWaffleFlag(WaffleFlag):
def is_enabled(self, course_key=None): # pylint: disable=arguments-differ
"""
- Returns whether or not the flag is enabled within the context of a given course.
+ Returns whether or not the flag is enabled within the context of a given
+ course.
+
+ Can also be given the key of any other learning context (like a content
+ library), but it will act like a regular waffle flag in that case.
Arguments:
course_key (Optional[CourseKey]): The course to check for override before
@@ -107,12 +111,12 @@ class CourseWaffleFlag(WaffleFlag):
outside the context of any course.
"""
if course_key:
- assert isinstance(
- course_key, CourseKey
- ), "Provided course_key '{}' is not instance of CourseKey.".format(
- course_key
- )
- is_enabled_for_course = self._get_course_override_value(course_key)
- if is_enabled_for_course is not None:
- return is_enabled_for_course
+ if isinstance(course_key, CourseKey):
+ is_enabled_for_course = self._get_course_override_value(course_key)
+ if is_enabled_for_course is not None:
+ return is_enabled_for_course
+ else:
+ # In case this gets called with a content library key, that's fine - just ignore it and
+ # act like a normal waffle flag. We currently don't support library-specific overrides.
+ assert isinstance(course_key, LearningContextKey), "expected a course key or other learning context key"
return super().is_enabled()
diff --git a/openedx/core/djangoapps/xblock/api.py b/openedx/core/djangoapps/xblock/api.py
index 80a4bd57ea..3cdafd4c90 100644
--- a/openedx/core/djangoapps/xblock/api.py
+++ b/openedx/core/djangoapps/xblock/api.py
@@ -8,29 +8,31 @@ Note that these views are only for interacting with existing blocks. Other
Studio APIs cover use cases like adding/deleting/editing blocks.
"""
# pylint: disable=unused-import
-
+from enum import Enum
from datetime import datetime
import logging
import threading
+from django.core.exceptions import PermissionDenied
from django.urls import reverse
from django.utils.translation import gettext as _
from openedx_learning.api import authoring as authoring_api
-from openedx_learning.api.authoring_models import Component
+from openedx_learning.api.authoring_models import Component, ComponentVersion
from opaque_keys.edx.keys import UsageKeyV2
from opaque_keys.edx.locator import BundleDefinitionLocator, LibraryUsageLocatorV2
from rest_framework.exceptions import NotFound
from xblock.core import XBlock
-from xblock.exceptions import NoSuchViewError
+from xblock.exceptions import NoSuchUsage, NoSuchViewError
from xblock.plugin import PluginMissingError
+from openedx.core.types import User as UserType
from openedx.core.djangoapps.xblock.apps import get_xblock_app_config
from openedx.core.djangoapps.xblock.learning_context.manager import get_learning_context_impl
from openedx.core.djangoapps.xblock.runtime.learning_core_runtime import (
LearningCoreFieldData,
LearningCoreXBlockRuntime,
)
-from openedx.core.djangoapps.xblock.runtime.runtime import XBlockRuntimeSystem as _XBlockRuntimeSystem
+from .data import CheckPerm, LatestVersion
from .utils import get_secure_token_for_xblock_handler, get_xblock_id_for_anonymous_user
from .runtime.learning_core_runtime import LearningCoreXBlockRuntime
@@ -43,46 +45,40 @@ from openedx.core.djangoapps.xblock.learning_context import LearningContext
log = logging.getLogger(__name__)
-def get_runtime_system():
+def get_runtime(user: UserType):
"""
- Return a new XBlockRuntimeSystem.
+ Return a new XBlockRuntime.
- TODO: Refactor to get rid of the XBlockRuntimeSystem entirely and just
- create the LearningCoreXBlockRuntime and return it. We used to want to keep
- around a long lived runtime system (a factory that returns runtimes) for
- caching purposes, and have it dynamically construct a runtime on request.
- Now we're just re-constructing both the system and the runtime in this call
- and returning it every time, because:
-
- 1. We no longer have slow, Blockstore-style definitions to cache, so the
- performance of this is perfectly acceptable.
- 2. Having a singleton increases complexity and the chance of bugs.
- 3. Creating the XBlockRuntimeSystem every time only takes about 10-30 µs.
-
- Given that, the extra XBlockRuntimeSystem class just adds confusion. But
- despite that, it's tested, working code, and so I'm putting off refactoring
- for now.
+ Each XBlockRuntime is bound to one user (and usually one request or one
+ celery task). It is typically used just to load and render a single block,
+ but the API _does_ allow a single runtime instance to load multiple blocks
+ (as long as they're for the same user).
"""
- params = get_xblock_app_config().get_runtime_system_params()
+ params = get_xblock_app_config().get_runtime_params()
params.update(
- runtime_class=LearningCoreXBlockRuntime,
handler_url=get_handler_url,
authored_data_store=LearningCoreFieldData(),
)
- runtime = _XBlockRuntimeSystem(**params)
+ runtime = LearningCoreXBlockRuntime(user, **params)
return runtime
-def load_block(usage_key, user):
+def load_block(
+ usage_key: UsageKeyV2,
+ user: UserType,
+ *,
+ check_permission: CheckPerm | None = CheckPerm.CAN_LEARN,
+ version: int | LatestVersion = LatestVersion.AUTO,
+):
"""
Load the specified XBlock for the given user.
Returns an instantiated XBlock.
Exceptions:
- NotFound - if the XBlock doesn't exist or if the user doesn't have the
- necessary permissions
+ NotFound - if the XBlock doesn't exist
+ PermissionDenied - if the user doesn't have the necessary permissions
Args:
usage_key(OpaqueKey): block identifier
@@ -94,18 +90,32 @@ def load_block(usage_key, user):
# Now, check if the block exists in this context and if the user has
# permission to render this XBlock view:
- if user is not None and not context_impl.can_view_block(user, usage_key):
- # We do not know if the block was not found or if the user doesn't have
- # permission, but we want to return the same result in either case:
- raise NotFound(f"XBlock {usage_key} does not exist, or you don't have permission to view it.")
+ if check_permission and user is not None:
+ if check_permission == CheckPerm.CAN_EDIT:
+ has_perm = context_impl.can_edit_block(user, usage_key)
+ elif check_permission == CheckPerm.CAN_READ_AS_AUTHOR:
+ has_perm = context_impl.can_view_block_for_editing(user, usage_key)
+ elif check_permission == CheckPerm.CAN_LEARN:
+ has_perm = context_impl.can_view_block(user, usage_key)
+ else:
+ has_perm = False
+ if not has_perm:
+ raise PermissionDenied(f"You don't have permission to access the component '{usage_key}'.")
# TODO: load field overrides from the context
# e.g. a course might specify that all 'problem' XBlocks have 'max_attempts'
# set to 3.
# field_overrides = context_impl.get_field_overrides(usage_key)
- runtime = get_runtime_system().get_runtime(user=user)
+ runtime = get_runtime(user=user)
- return runtime.get_block(usage_key)
+ try:
+ return runtime.get_block(usage_key, version=version)
+ except NoSuchUsage as exc:
+ # Convert NoSuchUsage to NotFound so we do the right thing (404 not 500) by default.
+ raise NotFound(f"The component '{usage_key}' does not exist.") from exc
+ except ComponentVersion.DoesNotExist as exc:
+ # Convert ComponentVersion.DoesNotExist to NotFound so we do the right thing (404 not 500) by default.
+ raise NotFound(f"The requested version of component '{usage_key}' does not exist.") from exc
def get_block_metadata(block, includes=()):
@@ -238,7 +248,13 @@ def render_block_view(block, view_name, user): # pylint: disable=unused-argumen
return fragment
-def get_handler_url(usage_key, handler_name, user):
+def get_handler_url(
+ usage_key: UsageKeyV2,
+ handler_name: str,
+ user: UserType | None,
+ *,
+ version: int | LatestVersion = LatestVersion.AUTO,
+):
"""
A method for getting the URL to any XBlock handler. The URL must be usable
without any authentication (no cookie, no OAuth/JWT), and may expire. (So
@@ -255,14 +271,19 @@ def get_handler_url(usage_key, handler_name, user):
usage_key - Usage Key (Opaque Key object or string)
handler_name - Name of the handler or a dummy name like 'any_handler'
user - Django User (registered or anonymous)
+ version - Run the handler against a specific version of the
+ block (e.g. when viewing an old version of it in
+ Studio). Some blocks use handlers to load their data
+ so it's important the handler matches the student_view
+ etc.
This view does not check/care if the XBlock actually exists.
"""
usage_key_str = str(usage_key)
site_root_url = get_xblock_app_config().get_site_root_url()
- if not user: # lint-amnesty, pylint: disable=no-else-raise
+ if not user:
raise TypeError("Cannot get handler URLs without specifying a specific user ID.")
- elif user.is_authenticated:
+ if user.is_authenticated:
user_id = user.id
elif user.is_anonymous:
user_id = get_xblock_id_for_anonymous_user(user)
@@ -272,12 +293,16 @@ def get_handler_url(usage_key, handler_name, user):
# and this XBlock:
secure_token = get_secure_token_for_xblock_handler(user_id, usage_key_str)
# Now generate the URL to that handler:
- path = reverse('xblock_api:xblock_handler', kwargs={
+ kwargs = {
'usage_key_str': usage_key_str,
'user_id': user_id,
'secure_token': secure_token,
'handler_name': handler_name,
- })
+ }
+ path = reverse('xblock_api:xblock_handler', kwargs=kwargs)
+ if version != LatestVersion.AUTO:
+ path += "?version=" + (str(version) if isinstance(version, int) else version.value)
+
# We must return an absolute URL. We can't just use
# rest_framework.reverse.reverse to get the absolute URL because this method
# can be called by the XBlock from python as well and in that case we don't
diff --git a/openedx/core/djangoapps/xblock/apps.py b/openedx/core/djangoapps/xblock/apps.py
index 5ba2361322..e4f07666c3 100644
--- a/openedx/core/djangoapps/xblock/apps.py
+++ b/openedx/core/djangoapps/xblock/apps.py
@@ -5,7 +5,7 @@ from django.apps import AppConfig, apps
from django.conf import settings
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
-from .data import StudentDataMode
+from .data import StudentDataMode, AuthoredDataMode
class XBlockAppConfig(AppConfig):
@@ -16,9 +16,9 @@ class XBlockAppConfig(AppConfig):
verbose_name = 'New XBlock Runtime'
label = 'xblock_new' # The name 'xblock' is already taken by ORA2's 'openassessment.xblock' app :/
- def get_runtime_system_params(self):
+ def get_runtime_params(self):
"""
- Get the XBlockRuntimeSystem parameters appropriate for viewing and/or
+ Get the LearningCoreXBlockRuntime parameters appropriate for viewing and/or
editing XBlock content.
"""
raise NotImplementedError
@@ -43,13 +43,14 @@ class LmsXBlockAppConfig(XBlockAppConfig):
LMS-specific configuration of the XBlock Runtime django app.
"""
- def get_runtime_system_params(self):
+ def get_runtime_params(self):
"""
- Get the XBlockRuntimeSystem parameters appropriate for viewing and/or
+ Get the LearningCoreXBlockRuntime parameters appropriate for viewing and/or
editing XBlock content in the LMS
"""
return dict(
student_data_mode=StudentDataMode.Persisted,
+ authored_data_mode=AuthoredDataMode.STRICTLY_PUBLISHED,
)
def get_site_root_url(self):
@@ -65,13 +66,14 @@ class StudioXBlockAppConfig(XBlockAppConfig):
Studio-specific configuration of the XBlock Runtime django app.
"""
- def get_runtime_system_params(self):
+ def get_runtime_params(self):
"""
- Get the XBlockRuntimeSystem parameters appropriate for viewing and/or
+ Get the LearningCoreXBlockRuntime parameters appropriate for viewing and/or
editing XBlock content in Studio
"""
return dict(
student_data_mode=StudentDataMode.Ephemeral,
+ authored_data_mode=AuthoredDataMode.DEFAULT_DRAFT,
)
def get_site_root_url(self):
diff --git a/openedx/core/djangoapps/xblock/data.py b/openedx/core/djangoapps/xblock/data.py
index 1da850caed..67cfda8dd4 100644
--- a/openedx/core/djangoapps/xblock/data.py
+++ b/openedx/core/djangoapps/xblock/data.py
@@ -11,3 +11,39 @@ class StudentDataMode(Enum):
"""
Ephemeral = 'ephemeral'
Persisted = 'persisted'
+
+
+class AuthoredDataMode(Enum):
+ """
+ Runtime configuration which determines whether published or draft versions of content is used by default.
+ """
+ # Published only: used by the LMS. ONLY the published version of an XBlock is ever loaded. Users/APIs cannot request
+ # the draft version nor a specific version.
+ STRICTLY_PUBLISHED = 'published'
+ # Default draft: used by Studio. By default the "lastest draft" version of an XBlock is used, but users/APIs can
+ # also request to see the published version or any specific (old) version.
+ DEFAULT_DRAFT = 'persisted'
+
+
+class CheckPerm(Enum):
+ """
+ Options for the default permission check done by load_block()
+ """
+ # can view the published block and call handlers etc. but not necessarily view its OLX source nor field data
+ CAN_LEARN = 1
+ # read-only studio view: can see the block (draft or published), see its OLX, see its field data, etc.
+ CAN_READ_AS_AUTHOR = 2
+ # can view everything and make changes to the block
+ CAN_EDIT = 3
+
+
+class LatestVersion(Enum):
+ """
+ Options for specifying which version of an XBlock you want to load, if not specifying a specific version.
+ """
+ # Get the latest draft
+ DRAFT = "draft"
+ # Get the latest published version
+ PUBLISHED = "published"
+ # Get the default (based on AuthoredDataMode, i.e. published for LMS APIs, draft for Studio APIs)
+ AUTO = "auto"
diff --git a/openedx/core/djangoapps/xblock/learning_context/learning_context.py b/openedx/core/djangoapps/xblock/learning_context/learning_context.py
index 2dc5155dc4..b535e84ca7 100644
--- a/openedx/core/djangoapps/xblock/learning_context/learning_context.py
+++ b/openedx/core/djangoapps/xblock/learning_context/learning_context.py
@@ -2,6 +2,8 @@
A "Learning Context" is a course, a library, a program, or some other collection
of content where learning happens.
"""
+from openedx.core.types import User as UserType
+from opaque_keys.edx.keys import UsageKeyV2
class LearningContext:
@@ -23,11 +25,11 @@ class LearningContext:
parameters without changing the API.
"""
- def can_edit_block(self, user, usage_key): # pylint: disable=unused-argument
+ def can_edit_block(self, user: UserType, usage_key: UsageKeyV2) -> bool: # pylint: disable=unused-argument
"""
- Does the specified usage key exist in its context, and if so, does the
- specified user have permission to edit it (make changes to the authored
- data store)?
+ Assuming a block with the specified ID (usage_key) exists, does the
+ specified user have permission to edit it (make changes to the
+ fields / authored data store)?
user: a Django User object (may be an AnonymousUser)
@@ -37,11 +39,20 @@ class LearningContext:
"""
return False
- def can_view_block(self, user, usage_key): # pylint: disable=unused-argument
+ def can_view_block_for_editing(self, user: UserType, usage_key: UsageKeyV2) -> bool:
"""
- Does the specified usage key exist in its context, and if so, does the
+ Assuming a block with the specified ID (usage_key) exists, does the
+ specified user have permission to view its fields and OLX details (but
+ not necessarily to make changes to it)?
+ """
+ return self.can_edit_block(user, usage_key)
+
+ def can_view_block(self, user: UserType, usage_key: UsageKeyV2) -> bool: # pylint: disable=unused-argument
+ """
+ Assuming a block with the specified ID (usage_key) exists, does the
specified user have permission to view it and interact with it (call
- handlers, save user state, etc.)?
+ handlers, save user state, etc.)? This is also sometimes called the
+ "can_learn" permission.
user: a Django User object (may be an AnonymousUser)
diff --git a/openedx/core/djangoapps/xblock/rest_api/urls.py b/openedx/core/djangoapps/xblock/rest_api/urls.py
index dec2fc562f..6432291178 100644
--- a/openedx/core/djangoapps/xblock/rest_api/urls.py
+++ b/openedx/core/djangoapps/xblock/rest_api/urls.py
@@ -32,6 +32,6 @@ urlpatterns = [
path('xblocks/v2//', include([
# render one of this XBlock's views (e.g. student_view) for embedding in an iframe
# NOTE: this endpoint is **unstable** and subject to changes after Sumac
- re_path(r'^embed/(?P[\w\-]+)/$', views.embed_block_view),
+ path('embed//', views.embed_block_view),
])),
]
diff --git a/openedx/core/djangoapps/xblock/rest_api/views.py b/openedx/core/djangoapps/xblock/rest_api/views.py
index 7934e24bd2..32e19ebad8 100644
--- a/openedx/core/djangoapps/xblock/rest_api/views.py
+++ b/openedx/core/djangoapps/xblock/rest_api/views.py
@@ -1,7 +1,6 @@
"""
Views that implement a RESTful API for interacting with XBlocks.
"""
-import itertools
import json
from common.djangoapps.util.json_request import JsonResponse
@@ -14,7 +13,7 @@ from django.shortcuts import render
from django.utils.translation import gettext as _
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.csrf import csrf_exempt
-from rest_framework import permissions
+from rest_framework import permissions, serializers
from rest_framework.decorators import api_view, permission_classes # lint-amnesty, pylint: disable=unused-import
from rest_framework.exceptions import PermissionDenied, AuthenticationFailed, NotFound
from rest_framework.response import Response
@@ -29,6 +28,8 @@ import openedx.core.djangoapps.site_configuration.helpers as configuration_helpe
from openedx.core.djangoapps.xblock.learning_context.manager import get_learning_context_impl
from openedx.core.lib.api.view_utils import view_auth_classes
from ..api import (
+ CheckPerm,
+ LatestVersion,
get_block_metadata,
get_block_display_name,
get_handler_url as _get_handler_url,
@@ -42,6 +43,25 @@ User = get_user_model()
invalid_not_found_fmt = "XBlock {usage_key} does not exist, or you don't have permission to view it."
+def parse_version_request(version_str: str | None) -> LatestVersion | int:
+ """
+ Given a version parameter from a query string (?version=14, ?version=draft,
+ ?version=published), get the LatestVersion parameter to use with the API.
+ """
+ if version_str is None:
+ return LatestVersion.AUTO # AUTO = published if we're in the LMS, draft if we're in Studio.
+ if version_str == "draft":
+ return LatestVersion.DRAFT
+ if version_str == "published":
+ return LatestVersion.PUBLISHED
+ try:
+ return int(version_str)
+ except ValueError:
+ raise serializers.ValidationError( # pylint: disable=raise-missing-from
+ "Invalid version specifier '{version_str}'. Expected 'draft', 'published', or an integer."
+ )
+
+
@api_view(['GET'])
@view_auth_classes(is_authenticated=False)
@permission_classes((permissions.AllowAny, )) # Permissions are handled at a lower level, by the learning context
@@ -107,16 +127,23 @@ def embed_block_view(request, usage_key_str, view_name):
except InvalidKeyError as e:
raise NotFound(invalid_not_found_fmt.format(usage_key=usage_key_str)) from e
+ # Check if a specific version has been requested
+ version = parse_version_request(request.GET.get("version"))
+
try:
- block = load_block(usage_key, request.user)
+ block = load_block(usage_key, request.user, check_permission=CheckPerm.CAN_LEARN, version=version)
except NoSuchUsage as exc:
raise NotFound(f"{usage_key} not found") from exc
fragment = _render_block_view(block, view_name, request.user)
handler_urls = {
- str(key): _get_handler_url(key, 'handler_name', request.user)
- for key in itertools.chain([block.scope_ids.usage_id], getattr(block, 'children', []))
+ str(block.usage_key): _get_handler_url(block.usage_key, 'handler_name', request.user, version=version)
}
+ # Currently we don't support child blocks so we don't need this pre-loading of child handler URLs:
+ # handler_urls = {
+ # str(key): _get_handler_url(key, 'handler_name', request.user)
+ # for key in itertools.chain([block.scope_ids.usage_id], getattr(block, 'children', []))
+ # }
lms_root_url = configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL)
context = {
'fragment': fragment,
@@ -204,7 +231,8 @@ def xblock_handler(request, user_id, secure_token, usage_key_str, handler_name,
raise AuthenticationFailed("Invalid user ID format.")
request_webob = DjangoWebobRequest(request) # Convert from django request to the webob format that XBlocks expect
- block = load_block(usage_key, user)
+
+ block = load_block(usage_key, user, version=parse_version_request(request.GET.get("version")))
# Run the handler, and save any resulting XBlock field value changes:
response_webob = block.handle(handler_name, request_webob, suffix)
response = webob_to_django_response(response_webob)
@@ -246,12 +274,17 @@ class BlockFieldsView(APIView):
except InvalidKeyError as e:
raise NotFound(invalid_not_found_fmt.format(usage_key=usage_key_str)) from e
- block = load_block(usage_key, request.user)
+ # The "fields" view requires "read as author" permissions because the fields can contain answers, etc.
+ block = load_block(usage_key, request.user, check_permission=CheckPerm.CAN_READ_AS_AUTHOR)
+ # It would make more sense if this just had a "fields" dict with all the content+settings fields, but
+ # for backwards compatibility we call the settings metadata and split it up like this, ignoring all content
+ # fields except "data".
block_dict = {
- "display_name": get_block_display_name(block), # potentially duplicated from metadata
- "data": block.data,
- "metadata": block.get_explicitly_set_fields_by_scope(Scope.settings),
+ "display_name": get_block_display_name(block), # note this is also present in metadata
+ "metadata": self.get_explicitly_set_fields_by_scope(block, Scope.settings),
}
+ if hasattr(block, "data"):
+ block_dict["data"] = block.data
return Response(block_dict)
@atomic
@@ -265,12 +298,12 @@ class BlockFieldsView(APIView):
raise NotFound(invalid_not_found_fmt.format(usage_key=usage_key_str)) from e
user = request.user
- block = load_block(usage_key, user)
+ block = load_block(usage_key, user, check_permission=CheckPerm.CAN_EDIT)
data = request.data.get("data")
metadata = request.data.get("metadata")
- old_metadata = block.get_explicitly_set_fields_by_scope(Scope.settings)
- old_content = block.get_explicitly_set_fields_by_scope(Scope.content)
+ old_metadata = self.get_explicitly_set_fields_by_scope(block, Scope.settings)
+ old_content = self.get_explicitly_set_fields_by_scope(block, Scope.content)
# only update data if it was passed
if data is not None:
@@ -307,8 +340,26 @@ class BlockFieldsView(APIView):
context_impl = get_learning_context_impl(usage_key)
context_impl.send_block_updated_event(usage_key)
- return Response({
- "id": str(block.location),
- "data": data,
- "metadata": block.get_explicitly_set_fields_by_scope(Scope.settings),
- })
+ block_dict = {
+ "id": str(block.usage_key),
+ "display_name": get_block_display_name(block), # note this is also present in metadata
+ "metadata": self.get_explicitly_set_fields_by_scope(block, Scope.settings),
+ }
+ if hasattr(block, "data"):
+ block_dict["data"] = block.data
+ return Response(block_dict)
+
+ def get_explicitly_set_fields_by_scope(self, block, scope=Scope.content):
+ """
+ Get a dictionary of the fields for the given scope which are set explicitly on the given xblock.
+
+ (Including any set to None.)
+ """
+ result = {}
+ for field in block.fields.values(): # lint-amnesty, pylint: disable=no-member
+ if field.scope == scope and field.is_set_on(block):
+ try:
+ result[field.name] = field.read_json(block)
+ except TypeError as exc:
+ raise TypeError(f"Unable to read field {field.name} from block {block.usage_key}") from exc
+ return result
diff --git a/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py b/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py
index 26aa7af60f..8d22626e08 100644
--- a/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py
+++ b/openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py
@@ -7,7 +7,7 @@ import logging
from collections import defaultdict
from datetime import datetime, timezone
-from django.core.exceptions import ObjectDoesNotExist
+from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db.transaction import atomic
from openedx_learning.api import authoring as authoring_api
@@ -20,6 +20,7 @@ from xblock.fields import Field, Scope, ScopeIds
from xblock.field_data import FieldData
from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_for_learning_core
+from ..data import AuthoredDataMode, LatestVersion
from ..learning_context.manager import get_learning_context_impl
from .runtime import XBlockRuntime
@@ -161,7 +162,7 @@ class LearningCoreXBlockRuntime(XBlockRuntime):
(eventually) asset storage.
"""
- def get_block(self, usage_key, for_parent=None):
+ def get_block(self, usage_key, for_parent=None, *, version: int | LatestVersion = LatestVersion.AUTO):
"""
Fetch an XBlock from Learning Core data models.
@@ -173,10 +174,21 @@ class LearningCoreXBlockRuntime(XBlockRuntime):
# We can do this more efficiently in a single query later, but for now
# just get it the easy way.
component = self._get_component_from_usage_key(usage_key)
- # TODO: For now, this runtime will only be used in CMS, so it's fine to just return the Draft version.
- # However, we will need the runtime to return the Published version for LMS (and Draft for LMS-Preview).
- # We should base this Draft vs Published decision on a runtime initialization parameter.
- component_version = component.versioning.draft
+
+ if version == LatestVersion.AUTO:
+ if self.authored_data_mode == AuthoredDataMode.DEFAULT_DRAFT:
+ version = LatestVersion.DRAFT
+ else:
+ version = LatestVersion.PUBLISHED
+ if self.authored_data_mode == AuthoredDataMode.STRICTLY_PUBLISHED and version != LatestVersion.PUBLISHED:
+ raise ValidationError("This runtime only allows accessing the published version of components")
+ if version == LatestVersion.DRAFT:
+ component_version = component.versioning.draft
+ elif version == LatestVersion.PUBLISHED:
+ component_version = component.versioning.published
+ else:
+ assert isinstance(version, int)
+ component_version = component.versioning.version_num(version)
if component_version is None:
raise NoSuchUsage(usage_key)
@@ -205,13 +217,16 @@ class LearningCoreXBlockRuntime(XBlockRuntime):
else:
block = block_class.parse_xml(xml_node, runtime=self, keys=keys)
+ # Store the version request on the block so we can retrieve it when needed for generating handler URLs etc.
+ block._runtime_requested_version = version # pylint: disable=protected-access
+
# Update field data with parsed values. We can't call .save() because it will call save_block(), below.
block.force_save_fields(block._get_fields_to_save()) # pylint: disable=protected-access
# We've pre-loaded the fields for this block, so the FieldData shouldn't
# consider these values "changed" in its sense of "you have to persist
# these because we've altered the field values from what was stored".
- self.system.authored_data_store.mark_unchanged(block)
+ self.authored_data_store.mark_unchanged(block)
return block
@@ -221,9 +236,16 @@ class LearningCoreXBlockRuntime(XBlockRuntime):
This gets called by block.save() - do not call this directly.
"""
- if not self.system.authored_data_store.has_changes(block):
+ if not self.authored_data_store.has_changes(block):
return # No changes, so no action needed.
+ if block._runtime_requested_version != LatestVersion.DRAFT: # pylint: disable=protected-access
+ # Not sure if this is an important restriction but it seems like overwriting the latest version based on
+ # an old version is likely an accident, so for now we're not going to allow it.
+ raise ValidationError(
+ "Do not make changes to a component starting from the published or past versions. Use the latest draft."
+ )
+
# Verify that the user has permission to write to authored data in this
# learning context:
if self.user is not None:
@@ -254,7 +276,7 @@ class LearningCoreXBlockRuntime(XBlockRuntime):
},
created=now,
)
- self.system.authored_data_store.mark_unchanged(block)
+ self.authored_data_store.mark_unchanged(block)
def _get_component_from_usage_key(self, usage_key):
"""
diff --git a/openedx/core/djangoapps/xblock/runtime/runtime.py b/openedx/core/djangoapps/xblock/runtime/runtime.py
index 5746af491d..c1fb6870c0 100644
--- a/openedx/core/djangoapps/xblock/runtime/runtime.py
+++ b/openedx/core/djangoapps/xblock/runtime/runtime.py
@@ -1,9 +1,8 @@
"""
Common base classes for all new XBlock runtimes.
"""
-from __future__ import annotations
import logging
-from typing import Callable, Optional
+from typing import Callable, Protocol
from urllib.parse import urljoin # pylint: disable=import-error
import crum
@@ -15,7 +14,7 @@ from django.contrib.auth import get_user_model
from django.core.cache import cache
from django.core.exceptions import PermissionDenied
from eventtracking import tracker
-from opaque_keys.edx.keys import UsageKey, LearningContextKey
+from opaque_keys.edx.keys import UsageKeyV2, LearningContextKey
from web_fragments.fragment import Fragment
from xblock.core import XBlock
from xblock.exceptions import NoSuchServiceError
@@ -38,7 +37,7 @@ from lms.djangoapps.grades.api import signals as grades_signals
from openedx.core.types import User as UserType
from openedx.core.djangoapps.enrollments.services import EnrollmentsService
from openedx.core.djangoapps.xblock.apps import get_xblock_app_config
-from openedx.core.djangoapps.xblock.data import StudentDataMode
+from openedx.core.djangoapps.xblock.data import AuthoredDataMode, StudentDataMode, LatestVersion
from openedx.core.djangoapps.xblock.runtime.ephemeral_field_data import EphemeralKeyValueStore
from openedx.core.djangoapps.xblock.runtime.mixin import LmsBlockMixin
from openedx.core.djangoapps.xblock.utils import get_xblock_id_for_anonymous_user
@@ -63,6 +62,19 @@ def make_track_function():
return function
+class GetHandlerFunction(Protocol):
+ """ Type definition for our "get handler" callback """
+ def __call__(
+ self,
+ usage_key: UsageKeyV2,
+ handler_name: str,
+ user: UserType | None,
+ *,
+ version: int | LatestVersion = LatestVersion.AUTO,
+ ) -> str:
+ ...
+
+
class XBlockRuntime(RuntimeShim, Runtime):
"""
This class manages one or more instantiated XBlocks for a particular user,
@@ -94,19 +106,35 @@ class XBlockRuntime(RuntimeShim, Runtime):
# keep track of view name (student_view, studio_view, etc)
# currently only used to track if we're in the studio_view (see below under service())
view_name: str | None
+ # backing store for authored field data (mostly content+settings scopes)
+ authored_data_store: FieldData
- def __init__(self, system: XBlockRuntimeSystem, user: UserType | None):
+ def __init__(
+ self,
+ user: UserType | None,
+ *,
+ handler_url: GetHandlerFunction,
+ student_data_mode: StudentDataMode,
+ authored_data_mode: AuthoredDataMode,
+ authored_data_store: FieldData,
+ id_reader: IdReader | None = None,
+ ):
super().__init__(
- id_reader=system.id_reader,
+ id_reader=id_reader or OpaqueKeyReader(),
mixins=(
LmsBlockMixin, # Adds Non-deprecated LMS/Studio functionality
XBlockShim, # Adds deprecated LMS/Studio functionality / backwards compatibility
),
default_class=None,
select=None,
- id_generator=system.id_generator,
+ id_generator=MemoryIdManager(), # We don't really use id_generator until we need to support asides
)
- self.system = system
+ assert student_data_mode in (StudentDataMode.Ephemeral, StudentDataMode.Persisted)
+ self.authored_data_mode = authored_data_mode
+ self.authored_data_store = authored_data_store
+ self.children_data_store = None
+ self.student_data_mode = student_data_mode
+ self.handler_url_fn = handler_url
self.user = user
# self.user_id must be set as a separate attribute since base class sets it:
if self.user is None:
@@ -126,7 +154,10 @@ class XBlockRuntime(RuntimeShim, Runtime):
if thirdparty:
log.warning("thirdparty handlers are not supported by this runtime for XBlock %s.", type(block))
- url = self.system.handler_url(block.scope_ids.usage_id, handler_name, self.user)
+ # Note: it's important that we call handlers based on the same version of the block
+ # (draft block -> draft data available to handler; published block -> published data available to handler)
+ kwargs = {"version": block._runtime_requested_version} if hasattr(block, "_runtime_requested_version") else {} # pylint: disable=protected-access
+ url = self.handler_url_fn(block.usage_key, handler_name, self.user, **kwargs)
if suffix:
if not url.endswith('/'):
url += '/'
@@ -275,7 +306,7 @@ class XBlockRuntime(RuntimeShim, Runtime):
# the preview engine, and 'main' otherwise.
# For backwards compatibility, we check the student_data_mode (Ephemeral indicates CMS) and the
# view_name for 'studio_view.' self.view_name is set by render() below.
- if self.system.student_data_mode == StudentDataMode.Ephemeral and self.view_name != 'studio_view':
+ if self.student_data_mode == StudentDataMode.Ephemeral and self.view_name != 'studio_view':
return MakoService(namespace_prefix='lms.')
return MakoService()
elif service_name == "i18n":
@@ -301,14 +332,12 @@ class XBlockRuntime(RuntimeShim, Runtime):
return EventPublishingService(self.user, context_key, make_track_function())
elif service_name == 'enrollments':
return EnrollmentsService()
+ elif service_name == 'error_tracker':
+ return make_error_tracker()
- # Check if the XBlockRuntimeSystem wants to handle this:
- service = self.system.get_service(block, service_name)
# Otherwise, fall back to the base implementation which loads services
# defined in the constructor:
- if service is None:
- service = super().service(block, service_name)
- return service
+ return super().service(block, service_name)
def _init_field_data_for_block(self, block: XBlock) -> FieldData:
"""
@@ -322,7 +351,7 @@ class XBlockRuntime(RuntimeShim, Runtime):
assert isinstance(self.user_id, str) and self.user_id.startswith("anon")
kvs = EphemeralKeyValueStore()
student_data_store = KvsFieldData(kvs)
- elif self.system.student_data_mode == StudentDataMode.Ephemeral:
+ elif self.student_data_mode == StudentDataMode.Ephemeral:
# We're in an environment like Studio where we want to let the
# author test blocks out but not permanently save their state.
kvs = EphemeralKeyValueStore()
@@ -341,10 +370,10 @@ class XBlockRuntime(RuntimeShim, Runtime):
student_data_store = KvsFieldData(kvs=DjangoKeyValueStore(field_data_cache))
return SplitFieldData({
- Scope.content: self.system.authored_data_store,
- Scope.settings: self.system.authored_data_store,
- Scope.parent: self.system.authored_data_store,
- Scope.children: self.system.children_data_store,
+ Scope.content: self.authored_data_store,
+ Scope.settings: self.authored_data_store,
+ Scope.parent: self.authored_data_store,
+ Scope.children: self.children_data_store,
Scope.user_state_summary: student_data_store,
Scope.user_state: student_data_store,
Scope.user_info: student_data_store,
@@ -407,62 +436,3 @@ class XBlockRuntime(RuntimeShim, Runtime):
"""
# Subclasses should override this
return None
-
-
-class XBlockRuntimeSystem:
- """
- This class is essentially a factory for XBlockRuntimes. This is a
- long-lived object which provides the behavior specific to the application
- that wants to use XBlocks. Unlike XBlockRuntime, a single instance of this
- class can be used with many different XBlocks, whereas each XBlock gets its
- own instance of XBlockRuntime.
- """
- def __init__(
- self,
- handler_url: Callable[[UsageKey, str, UserType | None], str],
- student_data_mode: StudentDataMode,
- runtime_class: type[XBlockRuntime],
- id_reader: Optional[IdReader] = None,
- authored_data_store: Optional[FieldData] = None,
- ):
- """
- args:
- handler_url: A method to get URLs to call XBlock handlers. It must
- implement this signature:
- handler_url(
- usage_key: UsageKey,
- handler_name: str,
- user: User | AnonymousUser | None
- ) -> str
- student_data_mode: Specifies whether student data should be kept
- in a temporary in-memory store (e.g. Studio) or persisted
- forever in the database.
- runtime_class: What runtime to use, e.g. LearningCoreXBlockRuntime
- """
- self.handler_url = handler_url
- self.id_reader = id_reader or OpaqueKeyReader()
- self.id_generator = MemoryIdManager() # We don't really use id_generator until we need to support asides
- self.runtime_class = runtime_class
- self.authored_data_store = authored_data_store
- self.children_data_store = None
- assert student_data_mode in (StudentDataMode.Ephemeral, StudentDataMode.Persisted)
- self.student_data_mode = student_data_mode
-
- def get_runtime(self, user: UserType | None) -> XBlockRuntime:
- """
- Get the XBlock runtime for the specified Django user. The user can be
- a regular user, an AnonymousUser, or None.
- """
- return self.runtime_class(self, user)
-
- def get_service(self, block, service_name: str):
- """
- Get a runtime service
-
- Runtime services may come from this XBlockRuntimeSystem,
- or if this method returns None, they may come from the
- XBlockRuntime.
- """
- if service_name == 'error_tracker':
- return make_error_tracker()
- return None # None means see if XBlockRuntime offers this service
diff --git a/openedx/core/lib/xblock_utils/__init__.py b/openedx/core/lib/xblock_utils/__init__.py
index 26127dbfb3..a8b76541b6 100644
--- a/openedx/core/lib/xblock_utils/__init__.py
+++ b/openedx/core/lib/xblock_utils/__init__.py
@@ -452,7 +452,7 @@ def xblock_resource_pkg(block):
ProblemBlock, and most other built-in blocks currently. Handling for these
assets does not interact with this function.
2. The (preferred) standard XBlock runtime resource loading system, used by
- LibraryContentBlock. Handling for these assets *does* interact with this
+ LegacyLibraryContentBlock. Handling for these assets *does* interact with this
function.
We hope to migrate to (2) eventually, tracked by:
diff --git a/openedx/features/course_experience/__init__.py b/openedx/features/course_experience/__init__.py
index f8a662709e..a45d863e09 100644
--- a/openedx/features/course_experience/__init__.py
+++ b/openedx/features/course_experience/__init__.py
@@ -34,6 +34,16 @@ COURSE_PRE_START_ACCESS_FLAG = WaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.pre_start_ac
# .. toggle_warning: This temporary feature toggle does not have a target removal date.
ENABLE_COURSE_GOALS = CourseWaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.enable_course_goals', __name__) # lint-amnesty, pylint: disable=toggle-missing-annotation
+# .. toggle_name: course_experience.enable_ses_for_goalreminder
+# .. toggle_implementation: CourseWaffleFlag
+# .. toggle_default: False
+# .. toggle_description: Used to determine whether or not to use AWS SES to send goal reminder emails for the course.
+# .. toggle_use_cases: opt_in, temporary
+# .. toggle_creation_date: 2024-10-06
+# .. toggle_target_removal_date: None
+# .. toggle_warning: This temporary feature toggle does not have a target removal date.
+ENABLE_SES_FOR_GOALREMINDER = CourseWaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.enable_ses_for_goalreminder', __name__) # lint-amnesty, pylint: disable=toggle-missing-annotation
+
# Waffle flag to enable anonymous access to a course
SEO_WAFFLE_FLAG_NAMESPACE = 'seo'
COURSE_ENABLE_UNENROLLED_ACCESS_FLAG = CourseWaffleFlag( # lint-amnesty, pylint: disable=toggle-missing-annotation
diff --git a/openedx/tests/completion_integration/test_services.py b/openedx/tests/completion_integration/test_services.py
index f4088678d9..7a6fad8ece 100644
--- a/openedx/tests/completion_integration/test_services.py
+++ b/openedx/tests/completion_integration/test_services.py
@@ -10,7 +10,7 @@ from completion.test_utils import CompletionWaffleTestMixin
from django.conf import settings
from django.test import override_settings
from opaque_keys.edx.keys import CourseKey
-from xmodule.library_tools import LibraryToolsService
+from xmodule.library_tools import LegacyLibraryToolsService
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory, LibraryFactory
from xmodule.tests import prepare_block_runtime
@@ -122,7 +122,7 @@ class CompletionServiceTestCase(CompletionWaffleTestMixin, SharedModuleStoreTest
Bind a block (part of self.course) so we can access student-specific data.
"""
prepare_block_runtime(block.runtime, course_id=block.location.course_key)
- block.runtime._services.update({'library_tools': LibraryToolsService(self.store, self.user.id)}) # lint-amnesty, pylint: disable=protected-access
+ block.runtime._services.update({'library_tools': LegacyLibraryToolsService(self.store, self.user.id)}) # lint-amnesty, pylint: disable=protected-access
def get_block(descriptor):
"""Mocks module_system get_block_for_descriptor function"""
diff --git a/pylint_django_settings.py b/pylint_django_settings.py
index 75ceab2505..46abfd81f8 100644
--- a/pylint_django_settings.py
+++ b/pylint_django_settings.py
@@ -1,5 +1,3 @@
-from pylint.interfaces import IAstroidChecker
-from pylint.checkers import BaseChecker
from pylint_django.checkers import ForeignKeyStringsChecker
from pylint_plugin_utils import get_checker
@@ -8,52 +6,47 @@ class ArgumentCompatibilityError(Exception):
pass
-class SetDjangoSettingsChecker(BaseChecker):
+def _get_django_settings_module(arguments):
"""
- This isn't a checker, but setting django settings module when pylint command is ran.
- This is to avoid 'django-not-configured' pylint warning
+ Determines the appropriate Django settings module based on the pylint command-line arguments.
+ It prevents the use of cms modules alongside lms or common modules.
+ :param arguments: List of command-line arguments passed to pylint
+ :return: A string representing the correct Django settings module ('cms.envs.test' or 'lms.envs.test')
+ :raises ArgumentCompatibilityError: If both cms and lms/common modules are present
"""
- __implements__ = IAstroidChecker
+ cms_module, lms_module, common_module = False, False, False
- name = 'set-django-settings'
+ for arg in arguments:
+ if arg.startswith("cms"):
+ cms_module = True
+ elif arg.startswith("lms"):
+ lms_module = True
+ elif arg.startswith("common"):
+ common_module = True
- msgs = {'R0991': ('bogus', 'bogus', 'bogus')}
+ if cms_module and (lms_module or common_module):
+ # when cms module is present in pylint command, it can't be parired with (lms, common)
+ # as common and lms gives error with cms test settings
+ raise ArgumentCompatibilityError(
+ "Modules from both common and lms cannot be paired with cms when running pylint"
+ )
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- def open(self):
- name_checker = get_checker(self.linter, ForeignKeyStringsChecker)
- # pylint command should not run with modules from both cms and (lms, common) at once
- cms_module = False
- lms_module = False
- common_module = False
- arguments = self.linter.cmdline_parser.parse_args()[1]
- for arg in arguments:
- if arg.startswith('cms'):
- cms_module = True
- elif arg.startswith('lms'):
- lms_module = True
- elif arg.startswith('common'):
- common_module = True
-
- if cms_module and (lms_module or common_module):
- # when cms module is present in pylint command, it can't be parired with (lms, common)
- # as common and lms gives error with cms test settings
- raise ArgumentCompatibilityError(
- "Modules from both common and lms can't be paired with cms while running pylint"
- )
- elif cms_module:
- # If a module from cms is present in pylint command arguments
- # and ony other module from (openedx, pavelib) is present
- # than test setting of cms is used
- name_checker.config.django_settings_module = 'cms.envs.test'
- else:
- # If any module form (lms, common, openedx, pavelib) is present in
- # pylint command arguments than test setting of lms is used
- name_checker.config.django_settings_module = 'lms.envs.test'
+ # Return the appropriate Django settings module based on the arguments
+ return "cms.envs.test" if cms_module else "lms.envs.test"
def register(linter):
- linter.register_checker(SetDjangoSettingsChecker(linter))
+ """
+ Placeholder function to register the plugin with pylint.
+ """
+ pass
+
+
+def load_configuration(linter):
+ """
+ Configures the Django settings module based on the command-line arguments passed to pylint.
+ """
+ name_checker = get_checker(linter, ForeignKeyStringsChecker)
+ arguments = linter.cmdline_parser.parse_args()[1]
+ name_checker.config.django_settings_module = _get_django_settings_module(arguments)
diff --git a/pylintrc b/pylintrc
index 96b657e46e..55a9bbab3b 100644
--- a/pylintrc
+++ b/pylintrc
@@ -64,12 +64,12 @@
# SERIOUSLY.
#
# ------------------------------
-# Generated by edx-lint version: 5.3.0
+# Generated by edx-lint version: 5.3.7
# ------------------------------
[MASTER]
ignore = ,.git,.tox,migrations,node_modules,.pycharm_helpers
persistent = yes
-load-plugins = edx_lint.pylint,pylint_django,pylint_celery,pylint_pytest
+load-plugins = edx_lint.pylint,pylint_django_settings,pylint_django,pylint_celery,pylint_pytest
[MESSAGES CONTROL]
enable =
@@ -259,6 +259,7 @@ enable =
useless-suppression,
disable =
bad-indentation,
+ broad-exception-raised,
consider-using-f-string,
duplicate-code,
file-ignored,
@@ -407,6 +408,6 @@ ext-import-graph =
int-import-graph =
[EXCEPTIONS]
-overgeneral-exceptions = Exception
+overgeneral-exceptions = builtins.Exception
-# 567bf30b121db79bc07a7028651f7efa0724e8a4
+# e624ea03d8124aa9cf2e577f830632344a0a07d9
diff --git a/pylintrc_tweaks b/pylintrc_tweaks
index 7911c08af9..1633da5c10 100644
--- a/pylintrc_tweaks
+++ b/pylintrc_tweaks
@@ -1,7 +1,7 @@
# pylintrc tweaks for use with edx_lint.
[MASTER]
ignore+ = ,.git,.tox,migrations,node_modules,.pycharm_helpers
-load-plugins = edx_lint.pylint,pylint_django,pylint_celery,pylint_pytest
+load-plugins = edx_lint.pylint,pylint_django_settings,pylint_django,pylint_celery,pylint_pytest
[MESSAGES CONTROL]
disable+ =
diff --git a/requirements.txt b/requirements.txt
deleted file mode 100644
index 0a17d4674b..0000000000
--- a/requirements.txt
+++ /dev/null
@@ -1,10 +0,0 @@
-attr==0.3.2
-build==0.9.0
-click==8.1.3
-importlib==1.0.4
-importlib-resources==5.10.0
-packaging==21.3
-pep517==0.13.0
-pip-tools==6.9.0
-pyparsing==3.0.9
-tomli==2.0.1
diff --git a/requirements/constraints.txt b/requirements/constraints.txt
index 3809b753a5..b40f357ace 100644
--- a/requirements/constraints.txt
+++ b/requirements/constraints.txt
@@ -7,7 +7,8 @@
# link to other information that will help people in the future to remove the
# pin when possible. Writing an issue against the offending project and
# linking to it here is good.
-
+# For further details on how to properly write constraints here please consult
+# https://openedx.atlassian.net/wiki/spaces/COMM/pages/4400250883/Adding+pinned+dependencies+in+constraint+file
# This file contains all common constraints for edx-repos
-c common_constraints.txt
@@ -18,127 +19,176 @@
# Ticket: https://github.com/openedx/edx-platform/issues/35334
algoliasearch<4.0.0
+# Date: 2024-03-14
+# Temporary to Support the python 3.11 Upgrade
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35281
+backports.zoneinfo;python_version<"3.9" # Newer versions have zoneinfo available in the standard library
+
+# Date: 2020-02-26
# As it is not clarified what exact breaking changes will be introduced as per
# the next major release, ensure the installed version is within boundaries.
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35280
celery>=5.2.2,<6.0.0
+# Date: 2021-05-17
+# greater version breaking upgrade builds
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35279
+click==8.1.6
-# The team that owns this package will manually bump this package rather than having it pulled in automatically.
-# This is to allow them to better control its deployment and to do it in a process that works better
-# for them.
-edx-enterprise==4.27.0
+# Date: 2022-07-20
+# edx-enterprise, snowflake-connector-python require charset-normalizer==2.0.0
+# Can be removed once snowflake-connector-python>2.7.9 is released with the fix.
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35278
+charset-normalizer<2.1.0
+# Date: 2024-02-02
# Stay on LTS version, remove once this is added to common constraint
Django<5.0
+# Date: 2020-02-10
# django-oauth-toolkit version >=2.0.0 has breaking changes. More details
# mentioned on this issue https://github.com/openedx/edx-platform/issues/32884
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35277
django-oauth-toolkit==1.7.1
+# Date: 2024-02-02
# incremental upgrade
django-simple-history==3.4.0
-# Adding pin to avoid any major upgrade
-pymongo<4.4.1
-
-# To override the constraint of edx-lint
-# This can be removed once https://github.com/openedx/edx-platform/issues/34586 is resolved
-# and the upstream constraint in edx-lint has been removed.
-event-tracking==3.0.0
-
+# Date: 2021-05-17
# greater version has breaking changes and requires some migration steps.
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35276
django-webpack-loader==0.7.0
-# At the time of writing this comment, we do not know whether py2neo>=2022
-# will support our currently-deployed Neo4j version (3.5).
-# Feel free to loosen this constraint if/when it is confirmed that a later
-# version of py2neo will work with Neo4j 3.5.
-py2neo<2022
-
-# edx-enterprise, snowflake-connector-python require charset-normalizer==2.0.0
-# Can be removed once snowflake-connector-python>2.7.9 is released with the fix.
-charset-normalizer<2.1.0
-
-# markdown>=3.4.0 has failures due to internal refactorings which causes the tests to fail
-# pinning the version untill the issue gets resolved in the package itself
-markdown<3.4.0
-
-# pycodestyle==2.9.0 generates false positive error E275.
-# Constraint can be removed once the issue https://github.com/PyCQA/pycodestyle/issues/1090 is fixed.
-pycodestyle<2.9.0
-
-pylint<2.16.0 # greater version failing quality test. Fix them in seperate ticket.
-
-# urllib3>=2.0.0 conflicts with elastic search && snowflake-connector-python packages
-# which require urllib3<2 for now.
-# Issue for unpinning: https://github.com/openedx/edx-platform/issues/32222
-urllib3<2.0.0
-
-
+# Date: 2023-06-20
# Adding pin to avoid any major upgrade
djangorestframework<3.15.0
+# Date: 2023-07-19
# The version of django-stubs we can use depends on which Django release we're using
# 1.16.0 works with Django 3.2 through 4.1
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35275
django-stubs==1.16.0
djangorestframework-stubs==3.14.0 # Pinned to match django-stubs. Remove this when we can remove the above pin.
+# Date: 2024-07-23
+# django-storages==1.14.4 breaks course imports
+# Two lines were added in 1.14.4 that make file_exists_in_storage function always return False,
+# as the default value of AWS_S3_FILE_OVERWRITE is True
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35170
+django-storages<1.14.4
+
+# Date: 2019-08-16
+# The team that owns this package will manually bump this package rather than having it pulled in automatically.
+# This is to allow them to better control its deployment and to do it in a process that works better
+# for them.
+edx-enterprise==4.28.0
+
+# Date: 2024-05-09
+# This has to be constrained as well because newer versions of edx-i18n-tools need the
+# newer version of lxml but that requirement was not made expilict in the 1.6.0 version
+# of the package. This can be un-pinned when we're upgrading lxml.
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35274
+edx-i18n-tools<1.6.0
+
+# Date: 2024-07-26
+# To override the constraint of edx-lint
+# This can be removed once https://github.com/openedx/edx-platform/issues/34586 is resolved
+# and the upstream constraint in edx-lint has been removed.
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35273
+event-tracking==3.0.0
+
+# Date: 2023-07-26
# Our legacy Sass code is incompatible with anything except this ancient libsass version.
# Here is a ticket to upgrade, but it's of debatable importance given that we are rapidly moving
# away from legacy LMS/CMS frontends:
# https://github.com/openedx/edx-platform/issues/31616
libsass==0.10.0
-# greater version breaking upgrade builds
-click==8.1.6
-
-# pinning this version to avoid updates while the library is being developed
-openedx-learning==0.13.1
-
-# Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise.
-openai<=0.28.1
-
-# optimizely-sdk 5.0.0 is breaking following test with segmentation fault
-# common/djangoapps/third_party_auth/tests/test_views.py::SAMLMetadataTest::test_secure_key_configuration
-# needs to be fixed in the follow up issue
-# https://github.com/openedx/edx-platform/issues/34103
-optimizely-sdk<5.0
-
+# Date: 2024-04-30
# lxml>=5.0 introduced breaking changes related to system dependencies
# lxml==5.2.1 introduced new extra so we'll nee to rename lxml --> lxml[html-clean]
# This constraint can be removed once we upgrade to Python 3.11
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35272
lxml<5.0
-# This has to be constrained as well because newer versions of edx-i18n-tools need the
-# newer version of lxml but that requirement was not made expilict in the 1.6.0 version
-# of the package. This can be un-pinned when we're upgrading lxml.
-edx-i18n-tools<1.6.0
-# xmlsec==1.3.14 breaking tests for all builds, can be removed once a fix is available
-xmlsec<1.3.14
+# Date: 2018-12-14
+# markdown>=3.4.0 has failures due to internal refactorings which causes the tests to fail
+# pinning the version untill the issue gets resolved in the package itself
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35271
+markdown<3.4.0
+# Date: 2024-04-24
# moto==5.0 contains breaking changes. Needs to be updated separately.
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35270
moto<5.0
-# path==16.12.0 breaks the unit test collections check
-# needs to be investigated and fixed separately
-path<16.12.0
-
-# Temporary to Support the python 3.11 Upgrade
-backports.zoneinfo;python_version<"3.9" # Newer versions have zoneinfo available in the standard library
-
-# Relevant GitHub Issue: https://github.com/openedx/edx-platform/issues/35126
+# Date: 2024-07-16
# We need to upgrade the version of elasticsearch to atleast 7.15 before we can upgrade to Numpy 2.0.0
# Otherwise we see a failure while running the following command:
# export DJANGO_SETTINGS_MODULE=cms.envs.test; python manage.py cms check_reserved_keywords --override_file db_keyword_overrides.yml --report_path reports/reserved_keywords --report_file cms_reserved_keyword_report.csv
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35126
numpy<2.0.0
-# django-storages==1.14.4 breaks course imports
-# Two lines were added in 1.14.4 that make file_exists_in_storage function always return False,
-# as the default value of AWS_S3_FILE_OVERWRITE is True
-django-storages<1.14.4
+# Date: 2024-01-26
+# optimizely-sdk 5.0.0 is breaking following test with segmentation fault
+# common/djangoapps/third_party_auth/tests/test_views.py::SAMLMetadataTest::test_secure_key_configuration
+# needs to be fixed in the follow up issue
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/34103
+optimizely-sdk<5.0
+# Date: 2023-09-18
+# pinning this version to avoid updates while the library is being developed
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35269
+openedx-learning==0.15.0
+
+# Date: 2023-11-29
+# Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise.
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35268
+openai<=0.28.1
+
+# Date: 2024-04-26
+# path==16.12.0 breaks the unit test collections check
+# needs to be investigated and fixed separately
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35267
+path<16.12.0
+
+# Date: 2022-08-03
+# pycodestyle==2.9.0 generates false positive error E275.
+# Constraint can be removed once the issue https://github.com/PyCQA/pycodestyle/issues/1090 is fixed.
+pycodestyle<2.9.0
+
+# Date: 2021-07-12
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/33560
+pylint<2.16.0 # greater version failing quality test. Fix them in seperate ticket.
+
+# Date: 2021-08-25
+# At the time of writing this comment, we do not know whether py2neo>=2022
+# will support our currently-deployed Neo4j version (3.5).
+# Feel free to loosen this constraint if/when it is confirmed that a later
+# version of py2neo will work with Neo4j 3.5.
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35266
+py2neo<2022
+
+# Date: 2020-04-08
+# Adding pin to avoid any major upgrade
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35265
+pymongo<4.4.1
+
+# Date: 2024-08-06
# social-auth-app-django 5.4.2 introduces a new migration that will not play nicely with large installations. This will touch
# user tables, which are quite large, especially on instances like edx.org.
# We are pinning this until after all the smaller migrations get handled and then we can migrate this all at once.
-# Ticket to unpin: https://github.com/edx/edx-arch-experiments/issues/760
+# Issue for unpinning: https://github.com/edx/edx-arch-experiments/issues/760
social-auth-app-django<=5.4.1
+
+# Date: 2023-11-05
+# urllib3>=2.0.0 conflicts with elastic search && snowflake-connector-python packages
+# which require urllib3<2 for now.
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/32222
+urllib3<2.0.0
+
+# Date: 2024-04-24
+# xmlsec==1.3.14 breaking tests or all builds, can be removed once a fix is available
+# Issue for unpinning: https://github.com/openedx/edx-platform/issues/35264
+xmlsec<1.3.14
diff --git a/requirements/edx-sandbox/base.txt b/requirements/edx-sandbox/base.txt
index bf0a4376da..ad75d4564b 100644
--- a/requirements/edx-sandbox/base.txt
+++ b/requirements/edx-sandbox/base.txt
@@ -31,7 +31,7 @@ lxml==4.9.4
# -c requirements/edx-sandbox/../constraints.txt
# -r requirements/edx-sandbox/base.in
# openedx-calc
-markupsafe==2.1.5
+markupsafe==3.0.1
# via
# chem
# openedx-calc
@@ -39,7 +39,7 @@ matplotlib==3.9.2
# via -r requirements/edx-sandbox/base.in
mpmath==1.3.0
# via sympy
-networkx==3.3
+networkx==3.4.1
# via -r requirements/edx-sandbox/base.in
nltk==3.9.1
# via
@@ -61,7 +61,7 @@ pillow==10.4.0
# via matplotlib
pycparser==2.22
# via cffi
-pyparsing==3.1.4
+pyparsing==3.2.0
# via
# -r requirements/edx-sandbox/base.in
# chem
diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt
index 0eca9a532a..0872ad5c68 100644
--- a/requirements/edx/base.txt
+++ b/requirements/edx/base.txt
@@ -6,11 +6,11 @@
#
-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack
# via -r requirements/edx/github.in
-acid-xblock==0.3.1
+acid-xblock==0.4.1
# via -r requirements/edx/kernel.in
-aiohappyeyeballs==2.4.0
+aiohappyeyeballs==2.4.3
# via aiohttp
-aiohttp==3.10.6
+aiohttp==3.10.10
# via
# geoip2
# openai
@@ -70,13 +70,13 @@ bleach[css]==6.1.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/kernel.in
-boto3==1.35.27
+boto3==1.35.40
# via
# -r requirements/edx/kernel.in
# django-ses
# fs-s3fs
# ora2
-botocore==1.35.27
+botocore==1.35.40
# via
# -r requirements/edx/kernel.in
# boto3
@@ -87,7 +87,7 @@ cachecontrol==0.14.0
# via firebase-admin
cachetools==5.5.0
# via google-auth
-camel-converter[pydantic]==3.1.2
+camel-converter[pydantic]==4.0.1
# via meilisearch
celery==5.4.0
# via
@@ -254,7 +254,7 @@ django-config-models==2.7.0
# edx-enterprise
# edx-name-affirmation
# lti-consumer-xblock
-django-cors-headers==4.4.0
+django-cors-headers==4.5.0
# via -r requirements/edx/kernel.in
django-countries==7.6.1
# via
@@ -328,7 +328,7 @@ django-sekizai==4.1.0
# via
# -r requirements/edx/kernel.in
# openedx-django-wiki
-django-ses==4.1.1
+django-ses==4.2.0
# via -r requirements/edx/bundled.in
django-simple-history==3.4.0
# via
@@ -387,7 +387,7 @@ djangorestframework==3.14.0
# super-csv
djangorestframework-xml==2.0.0
# via edx-enterprise
-dnspython==2.6.1
+dnspython==2.7.0
# via
# -r requirements/edx/paver.txt
# pymongo
@@ -401,7 +401,7 @@ drf-yasg==1.21.7
# via
# django-user-tasks
# edx-api-doc-tools
-edx-ace==1.11.2
+edx-ace==1.11.3
# via -r requirements/edx/kernel.in
edx-api-doc-tools==2.0.0
# via
@@ -429,7 +429,7 @@ edx-celeryutils==1.3.0
# super-csv
edx-codejail==3.4.1
# via -r requirements/edx/kernel.in
-edx-completion==4.7.1
+edx-completion==4.7.2
# via -r requirements/edx/kernel.in
edx-django-release-util==1.4.0
# via
@@ -438,7 +438,7 @@ edx-django-release-util==1.4.0
# edxval
edx-django-sites-extensions==4.2.0
# via -r requirements/edx/kernel.in
-edx-django-utils==5.16.0
+edx-django-utils==6.0.0
# via
# -r requirements/edx/kernel.in
# django-config-models
@@ -467,13 +467,13 @@ edx-drf-extensions==10.4.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.27.0
+edx-enterprise==4.28.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
edx-event-bus-kafka==5.8.1
# via -r requirements/edx/kernel.in
-edx-event-bus-redis==0.5.0
+edx-event-bus-redis==0.5.1
# via -r requirements/edx/kernel.in
edx-i18n-tools==1.5.0
# via
@@ -517,7 +517,7 @@ edx-search==4.0.0
# via -r requirements/edx/kernel.in
edx-sga==0.25.0
# via -r requirements/edx/bundled.in
-edx-submissions==3.8.0
+edx-submissions==3.8.1
# via
# -r requirements/edx/kernel.in
# ora2
@@ -584,14 +584,14 @@ geoip2==4.8.0
# via -r requirements/edx/kernel.in
glob2==0.7
# via -r requirements/edx/kernel.in
-google-api-core[grpc]==2.20.0
+google-api-core[grpc]==2.21.0
# via
# firebase-admin
# google-api-python-client
# google-cloud-core
# google-cloud-firestore
# google-cloud-storage
-google-api-python-client==2.147.0
+google-api-python-client==2.149.0
# via firebase-admin
google-auth==2.35.0
# via
@@ -621,11 +621,11 @@ googleapis-common-protos==1.65.0
# via
# google-api-core
# grpcio-status
-grpcio==1.66.1
+grpcio==1.66.2
# via
# google-api-core
# grpcio-status
-grpcio-status==1.66.1
+grpcio-status==1.66.2
# via google-api-core
gunicorn==23.0.0
# via -r requirements/edx/kernel.in
@@ -639,7 +639,7 @@ httplib2==0.22.0
# via
# google-api-python-client
# google-auth-httplib2
-icalendar==5.0.13
+icalendar==6.0.1
# via -r requirements/edx/kernel.in
idna==3.10
# via
@@ -658,7 +658,7 @@ interchange==2021.0.4
# via py2neo
ipaddress==1.0.23
# via -r requirements/edx/kernel.in
-isodate==0.6.1
+isodate==0.7.2
# via python3-saml
jinja2==3.1.4
# via code-annotations
@@ -683,7 +683,7 @@ jsonschema==4.23.0
# via
# drf-spectacular
# optimizely-sdk
-jsonschema-specifications==2023.12.1
+jsonschema-specifications==2024.10.1
# via jsonschema
jwcrypto==1.5.6
# via
@@ -737,7 +737,7 @@ markdown==3.3.7
# openedx-django-wiki
# staff-graded-xblock
# xblock-poll
-markupsafe==2.1.5
+markupsafe==3.0.1
# via
# -r requirements/edx/paver.txt
# chem
@@ -769,7 +769,7 @@ multidict==6.1.0
# yarl
mysqlclient==2.2.4
# via -r requirements/edx/kernel.in
-newrelic==9.13.0
+newrelic==10.1.0
# via
# -r requirements/edx/bundled.in
# edx-django-utils
@@ -811,7 +811,7 @@ openedx-django-require==2.1.0
# via -r requirements/edx/kernel.in
openedx-django-wiki==2.1.0
# via -r requirements/edx/kernel.in
-openedx-events==9.14.1
+openedx-events==9.15.0
# via
# -r requirements/edx/kernel.in
# edx-enterprise
@@ -820,16 +820,16 @@ openedx-events==9.14.1
# edx-name-affirmation
# event-tracking
# ora2
-openedx-filters==1.10.0
+openedx-filters==1.11.0
# via
# -r requirements/edx/kernel.in
# lti-consumer-xblock
# ora2
-openedx-learning==0.13.1
+openedx-learning==0.15.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/kernel.in
-openedx-mongodbproxy==0.2.1
+openedx-mongodbproxy==0.2.2
# via -r requirements/edx/kernel.in
optimizely-sdk==4.1.1
# via
@@ -881,6 +881,8 @@ polib==1.2.0
# via edx-i18n-tools
prompt-toolkit==3.0.48
# via click-repl
+propcache==0.2.0
+ # via yarl
proto-plus==1.24.0
# via
# google-api-core
@@ -911,7 +913,7 @@ pycountry==24.6.1
# via -r requirements/edx/kernel.in
pycparser==2.22
# via cffi
-pycryptodomex==3.20.0
+pycryptodomex==3.21.0
# via
# -r requirements/edx/kernel.in
# edx-proctoring
@@ -967,7 +969,7 @@ pyopenssl==24.2.1
# via
# optimizely-sdk
# snowflake-connector-python
-pyparsing==3.1.4
+pyparsing==3.2.0
# via
# chem
# httplib2
@@ -1017,7 +1019,6 @@ pytz==2024.2
# edx-tincan-py35
# event-tracking
# fs
- # icalendar
# interchange
# olxcleaner
# ora2
@@ -1039,7 +1040,7 @@ random2==1.0.2
# via -r requirements/edx/kernel.in
recommender-xblock==2.2.1
# via -r requirements/edx/bundled.in
-redis==5.0.8
+redis==5.1.1
# via
# -r requirements/edx/kernel.in
# walrus
@@ -1092,7 +1093,7 @@ rules==3.5
# edx-enterprise
# edx-proctoring
# openedx-learning
-s3transfer==0.10.2
+s3transfer==0.10.3
# via boto3
sailthru-client==2.2.3
# via edx-ace
@@ -1131,7 +1132,6 @@ six==1.16.0
# fs-s3fs
# html5lib
# interchange
- # isodate
# libsass
# optimizely-sdk
# pansi
@@ -1208,6 +1208,7 @@ typing-extensions==4.12.2
tzdata==2024.2
# via
# celery
+ # icalendar
# kombu
unicodecsv==0.14.1
# via
@@ -1237,7 +1238,7 @@ voluptuous==0.15.2
# via ora2
walrus==0.9.4
# via edx-event-bus-redis
-watchdog==5.0.2
+watchdog==5.0.3
# via -r requirements/edx/paver.txt
wcwidth==0.2.13
# via prompt-toolkit
@@ -1291,7 +1292,7 @@ xmlsec==1.3.13
# python3-saml
xss-utils==0.6.0
# via -r requirements/edx/kernel.in
-yarl==1.12.1
+yarl==1.15.2
# via aiohttp
zipp==3.20.2
# via importlib-metadata
diff --git a/requirements/edx/coverage.txt b/requirements/edx/coverage.txt
index a1faf5e740..feca49616a 100644
--- a/requirements/edx/coverage.txt
+++ b/requirements/edx/coverage.txt
@@ -6,13 +6,13 @@
#
chardet==5.2.0
# via diff-cover
-coverage==7.6.1
+coverage==7.6.3
# via -r requirements/edx/coverage.in
diff-cover==9.2.0
# via -r requirements/edx/coverage.in
jinja2==3.1.4
# via diff-cover
-markupsafe==2.1.5
+markupsafe==3.0.1
# via jinja2
pluggy==1.5.0
# via diff-cover
diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt
index abd9922253..75b966519d 100644
--- a/requirements/edx/development.txt
+++ b/requirements/edx/development.txt
@@ -12,16 +12,16 @@ accessible-pygments==0.0.5
# via
# -r requirements/edx/doc.txt
# pydata-sphinx-theme
-acid-xblock==0.3.1
+acid-xblock==0.4.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-aiohappyeyeballs==2.4.0
+aiohappyeyeballs==2.4.3
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# aiohttp
-aiohttp==3.10.6
+aiohttp==3.10.10
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -60,7 +60,7 @@ annotated-types==0.7.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# pydantic
-anyio==4.6.0
+anyio==4.6.2.post1
# via
# -r requirements/edx/testing.txt
# starlette
@@ -140,14 +140,14 @@ boto==2.49.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-boto3==1.35.27
+boto3==1.35.40
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# django-ses
# fs-s3fs
# ora2
-botocore==1.35.27
+botocore==1.35.40
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -157,7 +157,7 @@ bridgekeeper==0.9
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-build==1.2.2
+build==1.2.2.post1
# via
# -r requirements/edx/../pip-tools.txt
# pip-tools
@@ -172,7 +172,7 @@ cachetools==5.5.0
# -r requirements/edx/testing.txt
# google-auth
# tox
-camel-converter[pydantic]==3.1.2
+camel-converter[pydantic]==4.0.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -278,7 +278,7 @@ colorama==0.4.6
# via
# -r requirements/edx/testing.txt
# tox
-coverage[toml]==7.6.1
+coverage[toml]==7.6.3
# via
# -r requirements/edx/testing.txt
# pytest-cov
@@ -325,11 +325,11 @@ defusedxml==0.7.1
# social-auth-core
diff-cover==9.2.0
# via -r requirements/edx/testing.txt
-dill==0.3.8
+dill==0.3.9
# via
# -r requirements/edx/testing.txt
# pylint
-distlib==0.3.8
+distlib==0.3.9
# via
# -r requirements/edx/testing.txt
# virtualenv
@@ -435,7 +435,7 @@ django-config-models==2.7.0
# edx-enterprise
# edx-name-affirmation
# lti-consumer-xblock
-django-cors-headers==4.4.0
+django-cors-headers==4.5.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -544,7 +544,7 @@ django-sekizai==4.1.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# openedx-django-wiki
-django-ses==4.1.1
+django-ses==4.2.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -627,7 +627,7 @@ djangorestframework-xml==2.0.0
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-enterprise
-dnspython==2.6.1
+dnspython==2.7.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -657,7 +657,7 @@ drf-yasg==1.21.7
# -r requirements/edx/testing.txt
# django-user-tasks
# edx-api-doc-tools
-edx-ace==1.11.2
+edx-ace==1.11.3
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -696,7 +696,7 @@ edx-codejail==3.4.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-edx-completion==4.7.1
+edx-completion==4.7.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -710,7 +710,7 @@ edx-django-sites-extensions==4.2.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-edx-django-utils==5.16.0
+edx-django-utils==6.0.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -741,7 +741,7 @@ edx-drf-extensions==10.4.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.27.0
+edx-enterprise==4.28.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
@@ -750,7 +750,7 @@ edx-event-bus-kafka==5.8.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-edx-event-bus-redis==0.5.0
+edx-event-bus-redis==0.5.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -814,7 +814,7 @@ edx-sga==0.25.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-edx-submissions==3.8.0
+edx-submissions==3.8.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -879,11 +879,11 @@ execnet==2.1.1
# pytest-xdist
factory-boy==3.3.1
# via -r requirements/edx/testing.txt
-faker==30.0.0
+faker==30.3.0
# via
# -r requirements/edx/testing.txt
# factory-boy
-fastapi==0.115.0
+fastapi==0.115.2
# via
# -r requirements/edx/testing.txt
# pact-python
@@ -943,7 +943,7 @@ glob2==0.7
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-google-api-core[grpc]==2.20.0
+google-api-core[grpc]==2.21.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -952,7 +952,7 @@ google-api-core[grpc]==2.20.0
# google-cloud-core
# google-cloud-firestore
# google-cloud-storage
-google-api-python-client==2.147.0
+google-api-python-client==2.149.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1005,17 +1005,17 @@ googleapis-common-protos==1.65.0
# -r requirements/edx/testing.txt
# google-api-core
# grpcio-status
-grimp==3.4.1
+grimp==3.5
# via
# -r requirements/edx/testing.txt
# import-linter
-grpcio==1.66.1
+grpcio==1.66.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# google-api-core
# grpcio-status
-grpcio-status==1.66.1
+grpcio-status==1.66.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1045,7 +1045,7 @@ httplib2==0.22.0
# google-auth-httplib2
httpretty==1.1.4
# via -r requirements/edx/testing.txt
-icalendar==5.0.13
+icalendar==6.0.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1062,7 +1062,7 @@ imagesize==1.4.1
# via
# -r requirements/edx/doc.txt
# sphinx
-import-linter==2.0
+import-linter==2.1
# via -r requirements/edx/testing.txt
importlib-metadata==8.5.0
# via
@@ -1087,7 +1087,7 @@ ipaddress==1.0.23
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-isodate==0.6.1
+isodate==0.7.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1136,7 +1136,7 @@ jsonschema==4.23.0
# drf-spectacular
# optimizely-sdk
# sphinxcontrib-openapi
-jsonschema-specifications==2023.12.1
+jsonschema-specifications==2024.10.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1218,7 +1218,7 @@ markdown==3.3.7
# openedx-django-wiki
# staff-graded-xblock
# xblock-poll
-markupsafe==2.1.5
+markupsafe==3.0.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1279,7 +1279,7 @@ multidict==6.1.0
# -r requirements/edx/testing.txt
# aiohttp
# yarl
-mypy==1.11.2
+mypy==1.12.0
# via
# -r requirements/edx/development.in
# django-stubs
@@ -1290,7 +1290,7 @@ mysqlclient==2.2.4
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-newrelic==9.13.0
+newrelic==10.1.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1358,7 +1358,7 @@ openedx-django-wiki==2.1.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-openedx-events==9.14.1
+openedx-events==9.15.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1368,18 +1368,18 @@ openedx-events==9.14.1
# edx-name-affirmation
# event-tracking
# ora2
-openedx-filters==1.10.0
+openedx-filters==1.11.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# lti-consumer-xblock
# ora2
-openedx-learning==0.13.1
+openedx-learning==0.15.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-openedx-mongodbproxy==0.2.1
+openedx-mongodbproxy==0.2.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1407,7 +1407,7 @@ packaging==24.1
# snowflake-connector-python
# sphinx
# tox
-pact-python==2.2.1
+pact-python==2.2.2
# via -r requirements/edx/testing.txt
pansi==2020.7.3
# via
@@ -1488,6 +1488,11 @@ prompt-toolkit==3.0.48
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# click-repl
+propcache==0.2.0
+ # via
+ # -r requirements/edx/doc.txt
+ # -r requirements/edx/testing.txt
+ # yarl
proto-plus==1.24.0
# via
# -r requirements/edx/doc.txt
@@ -1542,7 +1547,7 @@ pycparser==2.22
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# cffi
-pycryptodomex==3.20.0
+pycryptodomex==3.21.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1655,7 +1660,7 @@ pyopenssl==24.2.1
# -r requirements/edx/testing.txt
# optimizely-sdk
# snowflake-connector-python
-pyparsing==3.1.4
+pyparsing==3.2.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1666,7 +1671,7 @@ pyproject-api==1.8.0
# via
# -r requirements/edx/testing.txt
# tox
-pyproject-hooks==1.1.0
+pyproject-hooks==1.2.0
# via
# -r requirements/edx/../pip-tools.txt
# build
@@ -1767,7 +1772,6 @@ pytz==2024.2
# edx-tincan-py35
# event-tracking
# fs
- # icalendar
# interchange
# olxcleaner
# ora2
@@ -1799,7 +1803,7 @@ recommender-xblock==2.2.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-redis==5.0.8
+redis==5.1.1
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1869,7 +1873,7 @@ rules==3.5
# edx-enterprise
# edx-proctoring
# openedx-learning
-s3transfer==0.10.2
+s3transfer==0.10.3
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
@@ -1926,7 +1930,6 @@ six==1.16.0
# fs-s3fs
# html5lib
# interchange
- # isodate
# libsass
# optimizely-sdk
# pact-python
@@ -1986,7 +1989,7 @@ soupsieve==2.6
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# beautifulsoup4
-sphinx==8.0.2
+sphinx==8.1.3
# via
# -r requirements/edx/doc.txt
# pydata-sphinx-theme
@@ -2049,7 +2052,7 @@ staff-graded-xblock==2.3.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-starlette==0.38.6
+starlette==0.39.2
# via
# -r requirements/edx/testing.txt
# fastapi
@@ -2087,7 +2090,7 @@ tinycss2==1.2.1
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# bleach
-tomli==2.0.1
+tomli==2.0.2
# via django-stubs
tomlkit==0.13.2
# via
@@ -2095,7 +2098,7 @@ tomlkit==0.13.2
# -r requirements/edx/testing.txt
# pylint
# snowflake-connector-python
-tox==4.20.0
+tox==4.21.2
# via -r requirements/edx/testing.txt
tqdm==4.66.5
# via
@@ -2103,7 +2106,7 @@ tqdm==4.66.5
# -r requirements/edx/testing.txt
# nltk
# openai
-types-pytz==2024.2.0.20240913
+types-pytz==2024.2.0.20241003
# via django-stubs
types-pyyaml==6.0.12.20240917
# via
@@ -2122,6 +2125,7 @@ typing-extensions==4.12.2
# django-stubs-ext
# djangorestframework-stubs
# edx-opaque-keys
+ # faker
# fastapi
# grimp
# import-linter
@@ -2137,6 +2141,7 @@ tzdata==2024.2
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# celery
+ # icalendar
# kombu
unicodecsv==0.14.1
# via
@@ -2165,7 +2170,7 @@ user-util==1.1.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-uvicorn==0.30.6
+uvicorn==0.31.1
# via
# -r requirements/edx/testing.txt
# pact-python
@@ -2176,7 +2181,7 @@ vine==5.1.0
# amqp
# celery
# kombu
-virtualenv==20.26.5
+virtualenv==20.26.6
# via
# -r requirements/edx/testing.txt
# tox
@@ -2185,14 +2190,14 @@ voluptuous==0.15.2
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# ora2
-vulture==2.12
+vulture==2.13
# via -r requirements/edx/development.in
walrus==0.9.4
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
# edx-event-bus-redis
-watchdog==5.0.2
+watchdog==5.0.3
# via
# -r requirements/edx/development.in
# -r requirements/edx/doc.txt
@@ -2276,7 +2281,7 @@ xss-utils==0.6.0
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
-yarl==1.12.1
+yarl==1.15.2
# via
# -r requirements/edx/doc.txt
# -r requirements/edx/testing.txt
diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt
index f8b45c5ddc..e724321395 100644
--- a/requirements/edx/doc.txt
+++ b/requirements/edx/doc.txt
@@ -8,13 +8,13 @@
# via -r requirements/edx/base.txt
accessible-pygments==0.0.5
# via pydata-sphinx-theme
-acid-xblock==0.3.1
+acid-xblock==0.4.1
# via -r requirements/edx/base.txt
-aiohappyeyeballs==2.4.0
+aiohappyeyeballs==2.4.3
# via
# -r requirements/edx/base.txt
# aiohttp
-aiohttp==3.10.6
+aiohttp==3.10.10
# via
# -r requirements/edx/base.txt
# geoip2
@@ -102,13 +102,13 @@ bleach[css]==6.1.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/base.txt
-boto3==1.35.27
+boto3==1.35.40
# via
# -r requirements/edx/base.txt
# django-ses
# fs-s3fs
# ora2
-botocore==1.35.27
+botocore==1.35.40
# via
# -r requirements/edx/base.txt
# boto3
@@ -123,7 +123,7 @@ cachetools==5.5.0
# via
# -r requirements/edx/base.txt
# google-auth
-camel-converter[pydantic]==3.1.2
+camel-converter[pydantic]==4.0.1
# via
# -r requirements/edx/base.txt
# meilisearch
@@ -314,7 +314,7 @@ django-config-models==2.7.0
# edx-enterprise
# edx-name-affirmation
# lti-consumer-xblock
-django-cors-headers==4.4.0
+django-cors-headers==4.5.0
# via -r requirements/edx/base.txt
django-countries==7.6.1
# via
@@ -398,7 +398,7 @@ django-sekizai==4.1.0
# via
# -r requirements/edx/base.txt
# openedx-django-wiki
-django-ses==4.1.1
+django-ses==4.2.0
# via -r requirements/edx/base.txt
django-simple-history==3.4.0
# via
@@ -459,7 +459,7 @@ djangorestframework-xml==2.0.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
-dnspython==2.6.1
+dnspython==2.7.0
# via
# -r requirements/edx/base.txt
# pymongo
@@ -481,7 +481,7 @@ drf-yasg==1.21.7
# -r requirements/edx/base.txt
# django-user-tasks
# edx-api-doc-tools
-edx-ace==1.11.2
+edx-ace==1.11.3
# via -r requirements/edx/base.txt
edx-api-doc-tools==2.0.0
# via
@@ -509,7 +509,7 @@ edx-celeryutils==1.3.0
# super-csv
edx-codejail==3.4.1
# via -r requirements/edx/base.txt
-edx-completion==4.7.1
+edx-completion==4.7.2
# via -r requirements/edx/base.txt
edx-django-release-util==1.4.0
# via
@@ -518,7 +518,7 @@ edx-django-release-util==1.4.0
# edxval
edx-django-sites-extensions==4.2.0
# via -r requirements/edx/base.txt
-edx-django-utils==5.16.0
+edx-django-utils==6.0.0
# via
# -r requirements/edx/base.txt
# django-config-models
@@ -547,13 +547,13 @@ edx-drf-extensions==10.4.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.27.0
+edx-enterprise==4.28.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
edx-event-bus-kafka==5.8.1
# via -r requirements/edx/base.txt
-edx-event-bus-redis==0.5.0
+edx-event-bus-redis==0.5.1
# via -r requirements/edx/base.txt
edx-i18n-tools==1.5.0
# via
@@ -598,7 +598,7 @@ edx-search==4.0.0
# via -r requirements/edx/base.txt
edx-sga==0.25.0
# via -r requirements/edx/base.txt
-edx-submissions==3.8.0
+edx-submissions==3.8.1
# via
# -r requirements/edx/base.txt
# ora2
@@ -683,7 +683,7 @@ gitpython==3.1.43
# via -r requirements/edx/doc.in
glob2==0.7
# via -r requirements/edx/base.txt
-google-api-core[grpc]==2.20.0
+google-api-core[grpc]==2.21.0
# via
# -r requirements/edx/base.txt
# firebase-admin
@@ -691,7 +691,7 @@ google-api-core[grpc]==2.20.0
# google-cloud-core
# google-cloud-firestore
# google-cloud-storage
-google-api-python-client==2.147.0
+google-api-python-client==2.149.0
# via
# -r requirements/edx/base.txt
# firebase-admin
@@ -735,12 +735,12 @@ googleapis-common-protos==1.65.0
# -r requirements/edx/base.txt
# google-api-core
# grpcio-status
-grpcio==1.66.1
+grpcio==1.66.2
# via
# -r requirements/edx/base.txt
# google-api-core
# grpcio-status
-grpcio-status==1.66.1
+grpcio-status==1.66.2
# via
# -r requirements/edx/base.txt
# google-api-core
@@ -757,7 +757,7 @@ httplib2==0.22.0
# -r requirements/edx/base.txt
# google-api-python-client
# google-auth-httplib2
-icalendar==5.0.13
+icalendar==6.0.1
# via -r requirements/edx/base.txt
idna==3.10
# via
@@ -781,7 +781,7 @@ interchange==2021.0.4
# py2neo
ipaddress==1.0.23
# via -r requirements/edx/base.txt
-isodate==0.6.1
+isodate==0.7.2
# via
# -r requirements/edx/base.txt
# python3-saml
@@ -818,7 +818,7 @@ jsonschema==4.23.0
# drf-spectacular
# optimizely-sdk
# sphinxcontrib-openapi
-jsonschema-specifications==2023.12.1
+jsonschema-specifications==2024.10.1
# via
# -r requirements/edx/base.txt
# jsonschema
@@ -879,7 +879,7 @@ markdown==3.3.7
# openedx-django-wiki
# staff-graded-xblock
# xblock-poll
-markupsafe==2.1.5
+markupsafe==3.0.1
# via
# -r requirements/edx/base.txt
# chem
@@ -923,7 +923,7 @@ multidict==6.1.0
# yarl
mysqlclient==2.2.4
# via -r requirements/edx/base.txt
-newrelic==9.13.0
+newrelic==10.1.0
# via
# -r requirements/edx/base.txt
# edx-django-utils
@@ -970,7 +970,7 @@ openedx-django-require==2.1.0
# via -r requirements/edx/base.txt
openedx-django-wiki==2.1.0
# via -r requirements/edx/base.txt
-openedx-events==9.14.1
+openedx-events==9.15.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -979,16 +979,16 @@ openedx-events==9.14.1
# edx-name-affirmation
# event-tracking
# ora2
-openedx-filters==1.10.0
+openedx-filters==1.11.0
# via
# -r requirements/edx/base.txt
# lti-consumer-xblock
# ora2
-openedx-learning==0.13.1
+openedx-learning==0.15.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
-openedx-mongodbproxy==0.2.1
+openedx-mongodbproxy==0.2.2
# via -r requirements/edx/base.txt
optimizely-sdk==4.1.1
# via
@@ -1057,6 +1057,10 @@ prompt-toolkit==3.0.48
# via
# -r requirements/edx/base.txt
# click-repl
+propcache==0.2.0
+ # via
+ # -r requirements/edx/base.txt
+ # yarl
proto-plus==1.24.0
# via
# -r requirements/edx/base.txt
@@ -1094,7 +1098,7 @@ pycparser==2.22
# via
# -r requirements/edx/base.txt
# cffi
-pycryptodomex==3.20.0
+pycryptodomex==3.21.0
# via
# -r requirements/edx/base.txt
# edx-proctoring
@@ -1163,7 +1167,7 @@ pyopenssl==24.2.1
# -r requirements/edx/base.txt
# optimizely-sdk
# snowflake-connector-python
-pyparsing==3.1.4
+pyparsing==3.2.0
# via
# -r requirements/edx/base.txt
# chem
@@ -1222,7 +1226,6 @@ pytz==2024.2
# edx-tincan-py35
# event-tracking
# fs
- # icalendar
# interchange
# olxcleaner
# ora2
@@ -1245,7 +1248,7 @@ random2==1.0.2
# via -r requirements/edx/base.txt
recommender-xblock==2.2.1
# via -r requirements/edx/base.txt
-redis==5.0.8
+redis==5.1.1
# via
# -r requirements/edx/base.txt
# walrus
@@ -1305,7 +1308,7 @@ rules==3.5
# edx-enterprise
# edx-proctoring
# openedx-learning
-s3transfer==0.10.2
+s3transfer==0.10.3
# via
# -r requirements/edx/base.txt
# boto3
@@ -1350,7 +1353,6 @@ six==1.16.0
# fs-s3fs
# html5lib
# interchange
- # isodate
# libsass
# optimizely-sdk
# pansi
@@ -1394,7 +1396,7 @@ soupsieve==2.6
# via
# -r requirements/edx/base.txt
# beautifulsoup4
-sphinx==8.0.2
+sphinx==8.1.3
# via
# -r requirements/edx/doc.in
# pydata-sphinx-theme
@@ -1489,6 +1491,7 @@ tzdata==2024.2
# via
# -r requirements/edx/base.txt
# celery
+ # icalendar
# kombu
unicodecsv==0.14.1
# via
@@ -1524,7 +1527,7 @@ walrus==0.9.4
# via
# -r requirements/edx/base.txt
# edx-event-bus-redis
-watchdog==5.0.2
+watchdog==5.0.3
# via -r requirements/edx/base.txt
wcwidth==0.2.13
# via
@@ -1583,7 +1586,7 @@ xmlsec==1.3.13
# python3-saml
xss-utils==0.6.0
# via -r requirements/edx/base.txt
-yarl==1.12.1
+yarl==1.15.2
# via
# -r requirements/edx/base.txt
# aiohttp
diff --git a/requirements/edx/paver.txt b/requirements/edx/paver.txt
index a0b1896919..2d8f510e03 100644
--- a/requirements/edx/paver.txt
+++ b/requirements/edx/paver.txt
@@ -10,7 +10,7 @@ charset-normalizer==2.0.12
# via
# -c requirements/edx/../constraints.txt
# requests
-dnspython==2.6.1
+dnspython==2.7.0
# via pymongo
edx-opaque-keys==2.11.0
# via -r requirements/edx/paver.in
@@ -22,7 +22,7 @@ libsass==0.10.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/paver.in
-markupsafe==2.1.5
+markupsafe==3.0.1
# via -r requirements/edx/paver.in
mock==5.1.0
# via -r requirements/edx/paver.in
@@ -61,7 +61,7 @@ urllib3==1.26.20
# via
# -c requirements/edx/../constraints.txt
# requests
-watchdog==5.0.2
+watchdog==5.0.3
# via -r requirements/edx/paver.in
wrapt==1.16.0
# via -r requirements/edx/paver.in
diff --git a/requirements/edx/semgrep.txt b/requirements/edx/semgrep.txt
index 102289def2..174fa87d08 100644
--- a/requirements/edx/semgrep.txt
+++ b/requirements/edx/semgrep.txt
@@ -15,7 +15,7 @@ boltons==21.0.0
# face
# glom
# semgrep
-bracex==2.5
+bracex==2.5.post1
# via wcmatch
certifi==2024.8.30
# via requests
@@ -42,7 +42,7 @@ idna==3.10
# via requests
jsonschema==4.23.0
# via semgrep
-jsonschema-specifications==2023.12.1
+jsonschema-specifications==2024.10.1
# via jsonschema
markdown-it-py==3.0.0
# via rich
@@ -60,7 +60,7 @@ referencing==0.35.1
# jsonschema-specifications
requests==2.32.3
# via semgrep
-rich==13.8.1
+rich==13.9.2
# via semgrep
rpds-py==0.20.0
# via
@@ -72,7 +72,7 @@ ruamel-yaml-clib==0.2.8
# via ruamel-yaml
semgrep==1.52.0
# via -r requirements/edx/semgrep.in
-tomli==2.0.1
+tomli==2.0.2
# via semgrep
typing-extensions==4.12.2
# via semgrep
diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt
index 7fa8962c4f..b73820d4c1 100644
--- a/requirements/edx/testing.txt
+++ b/requirements/edx/testing.txt
@@ -6,13 +6,13 @@
#
-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack
# via -r requirements/edx/base.txt
-acid-xblock==0.3.1
+acid-xblock==0.4.1
# via -r requirements/edx/base.txt
-aiohappyeyeballs==2.4.0
+aiohappyeyeballs==2.4.3
# via
# -r requirements/edx/base.txt
# aiohttp
-aiohttp==3.10.6
+aiohttp==3.10.10
# via
# -r requirements/edx/base.txt
# geoip2
@@ -39,7 +39,7 @@ annotated-types==0.7.0
# via
# -r requirements/edx/base.txt
# pydantic
-anyio==4.6.0
+anyio==4.6.2.post1
# via starlette
appdirs==1.4.4
# via
@@ -102,13 +102,13 @@ bleach[css]==6.1.0
# xblock-poll
boto==2.49.0
# via -r requirements/edx/base.txt
-boto3==1.35.27
+boto3==1.35.40
# via
# -r requirements/edx/base.txt
# django-ses
# fs-s3fs
# ora2
-botocore==1.35.27
+botocore==1.35.40
# via
# -r requirements/edx/base.txt
# boto3
@@ -124,7 +124,7 @@ cachetools==5.5.0
# -r requirements/edx/base.txt
# google-auth
# tox
-camel-converter[pydantic]==3.1.2
+camel-converter[pydantic]==4.0.1
# via
# -r requirements/edx/base.txt
# meilisearch
@@ -209,7 +209,7 @@ codejail-includes==1.0.0
# via -r requirements/edx/base.txt
colorama==0.4.6
# via tox
-coverage[toml]==7.6.1
+coverage[toml]==7.6.3
# via
# -r requirements/edx/coverage.txt
# pytest-cov
@@ -247,9 +247,9 @@ defusedxml==0.7.1
# social-auth-core
diff-cover==9.2.0
# via -r requirements/edx/coverage.txt
-dill==0.3.8
+dill==0.3.9
# via pylint
-distlib==0.3.8
+distlib==0.3.9
# via virtualenv
django==4.2.16
# via
@@ -343,7 +343,7 @@ django-config-models==2.7.0
# edx-enterprise
# edx-name-affirmation
# lti-consumer-xblock
-django-cors-headers==4.4.0
+django-cors-headers==4.5.0
# via -r requirements/edx/base.txt
django-countries==7.6.1
# via
@@ -427,7 +427,7 @@ django-sekizai==4.1.0
# via
# -r requirements/edx/base.txt
# openedx-django-wiki
-django-ses==4.1.1
+django-ses==4.2.0
# via -r requirements/edx/base.txt
django-simple-history==3.4.0
# via
@@ -488,7 +488,7 @@ djangorestframework-xml==2.0.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
-dnspython==2.6.1
+dnspython==2.7.0
# via
# -r requirements/edx/base.txt
# pymongo
@@ -505,7 +505,7 @@ drf-yasg==1.21.7
# -r requirements/edx/base.txt
# django-user-tasks
# edx-api-doc-tools
-edx-ace==1.11.2
+edx-ace==1.11.3
# via -r requirements/edx/base.txt
edx-api-doc-tools==2.0.0
# via
@@ -533,7 +533,7 @@ edx-celeryutils==1.3.0
# super-csv
edx-codejail==3.4.1
# via -r requirements/edx/base.txt
-edx-completion==4.7.1
+edx-completion==4.7.2
# via -r requirements/edx/base.txt
edx-django-release-util==1.4.0
# via
@@ -542,7 +542,7 @@ edx-django-release-util==1.4.0
# edxval
edx-django-sites-extensions==4.2.0
# via -r requirements/edx/base.txt
-edx-django-utils==5.16.0
+edx-django-utils==6.0.0
# via
# -r requirements/edx/base.txt
# django-config-models
@@ -571,13 +571,13 @@ edx-drf-extensions==10.4.0
# edx-when
# edxval
# openedx-learning
-edx-enterprise==4.27.0
+edx-enterprise==4.28.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
edx-event-bus-kafka==5.8.1
# via -r requirements/edx/base.txt
-edx-event-bus-redis==0.5.0
+edx-event-bus-redis==0.5.1
# via -r requirements/edx/base.txt
edx-i18n-tools==1.5.0
# via
@@ -624,7 +624,7 @@ edx-search==4.0.0
# via -r requirements/edx/base.txt
edx-sga==0.25.0
# via -r requirements/edx/base.txt
-edx-submissions==3.8.0
+edx-submissions==3.8.1
# via
# -r requirements/edx/base.txt
# ora2
@@ -674,9 +674,9 @@ execnet==2.1.1
# via pytest-xdist
factory-boy==3.3.1
# via -r requirements/edx/testing.in
-faker==30.0.0
+faker==30.3.0
# via factory-boy
-fastapi==0.115.0
+fastapi==0.115.2
# via pact-python
fastavro==1.9.7
# via
@@ -717,7 +717,7 @@ geoip2==4.8.0
# via -r requirements/edx/base.txt
glob2==0.7
# via -r requirements/edx/base.txt
-google-api-core[grpc]==2.20.0
+google-api-core[grpc]==2.21.0
# via
# -r requirements/edx/base.txt
# firebase-admin
@@ -725,7 +725,7 @@ google-api-core[grpc]==2.20.0
# google-cloud-core
# google-cloud-firestore
# google-cloud-storage
-google-api-python-client==2.147.0
+google-api-python-client==2.149.0
# via
# -r requirements/edx/base.txt
# firebase-admin
@@ -769,14 +769,14 @@ googleapis-common-protos==1.65.0
# -r requirements/edx/base.txt
# google-api-core
# grpcio-status
-grimp==3.4.1
+grimp==3.5
# via import-linter
-grpcio==1.66.1
+grpcio==1.66.2
# via
# -r requirements/edx/base.txt
# google-api-core
# grpcio-status
-grpcio-status==1.66.1
+grpcio-status==1.66.2
# via
# -r requirements/edx/base.txt
# google-api-core
@@ -797,7 +797,7 @@ httplib2==0.22.0
# google-auth-httplib2
httpretty==1.1.4
# via -r requirements/edx/testing.in
-icalendar==5.0.13
+icalendar==6.0.1
# via -r requirements/edx/base.txt
idna==3.10
# via
@@ -807,7 +807,7 @@ idna==3.10
# requests
# snowflake-connector-python
# yarl
-import-linter==2.0
+import-linter==2.1
# via -r requirements/edx/testing.in
importlib-metadata==8.5.0
# via -r requirements/edx/base.txt
@@ -824,7 +824,7 @@ interchange==2021.0.4
# py2neo
ipaddress==1.0.23
# via -r requirements/edx/base.txt
-isodate==0.6.1
+isodate==0.7.2
# via
# -r requirements/edx/base.txt
# python3-saml
@@ -865,7 +865,7 @@ jsonschema==4.23.0
# -r requirements/edx/base.txt
# drf-spectacular
# optimizely-sdk
-jsonschema-specifications==2023.12.1
+jsonschema-specifications==2024.10.1
# via
# -r requirements/edx/base.txt
# jsonschema
@@ -929,7 +929,7 @@ markdown==3.3.7
# openedx-django-wiki
# staff-graded-xblock
# xblock-poll
-markupsafe==2.1.5
+markupsafe==3.0.1
# via
# -r requirements/edx/base.txt
# -r requirements/edx/coverage.txt
@@ -974,7 +974,7 @@ multidict==6.1.0
# yarl
mysqlclient==2.2.4
# via -r requirements/edx/base.txt
-newrelic==9.13.0
+newrelic==10.1.0
# via
# -r requirements/edx/base.txt
# edx-django-utils
@@ -1021,7 +1021,7 @@ openedx-django-require==2.1.0
# via -r requirements/edx/base.txt
openedx-django-wiki==2.1.0
# via -r requirements/edx/base.txt
-openedx-events==9.14.1
+openedx-events==9.15.0
# via
# -r requirements/edx/base.txt
# edx-enterprise
@@ -1030,16 +1030,16 @@ openedx-events==9.14.1
# edx-name-affirmation
# event-tracking
# ora2
-openedx-filters==1.10.0
+openedx-filters==1.11.0
# via
# -r requirements/edx/base.txt
# lti-consumer-xblock
# ora2
-openedx-learning==0.13.1
+openedx-learning==0.15.0
# via
# -c requirements/edx/../constraints.txt
# -r requirements/edx/base.txt
-openedx-mongodbproxy==0.2.1
+openedx-mongodbproxy==0.2.2
# via -r requirements/edx/base.txt
optimizely-sdk==4.1.1
# via
@@ -1057,7 +1057,7 @@ packaging==24.1
# pytest
# snowflake-connector-python
# tox
-pact-python==2.2.1
+pact-python==2.2.2
# via -r requirements/edx/testing.in
pansi==2020.7.3
# via
@@ -1119,6 +1119,10 @@ prompt-toolkit==3.0.48
# via
# -r requirements/edx/base.txt
# click-repl
+propcache==0.2.0
+ # via
+ # -r requirements/edx/base.txt
+ # yarl
proto-plus==1.24.0
# via
# -r requirements/edx/base.txt
@@ -1164,7 +1168,7 @@ pycparser==2.22
# via
# -r requirements/edx/base.txt
# cffi
-pycryptodomex==3.20.0
+pycryptodomex==3.21.0
# via
# -r requirements/edx/base.txt
# edx-proctoring
@@ -1248,7 +1252,7 @@ pyopenssl==24.2.1
# -r requirements/edx/base.txt
# optimizely-sdk
# snowflake-connector-python
-pyparsing==3.1.4
+pyparsing==3.2.0
# via
# -r requirements/edx/base.txt
# chem
@@ -1340,7 +1344,6 @@ pytz==2024.2
# edx-tincan-py35
# event-tracking
# fs
- # icalendar
# interchange
# olxcleaner
# ora2
@@ -1362,7 +1365,7 @@ random2==1.0.2
# via -r requirements/edx/base.txt
recommender-xblock==2.2.1
# via -r requirements/edx/base.txt
-redis==5.0.8
+redis==5.1.1
# via
# -r requirements/edx/base.txt
# walrus
@@ -1422,7 +1425,7 @@ rules==3.5
# edx-enterprise
# edx-proctoring
# openedx-learning
-s3transfer==0.10.2
+s3transfer==0.10.3
# via
# -r requirements/edx/base.txt
# boto3
@@ -1470,7 +1473,6 @@ six==1.16.0
# fs-s3fs
# html5lib
# interchange
- # isodate
# libsass
# optimizely-sdk
# pact-python
@@ -1518,7 +1520,7 @@ sqlparse==0.5.1
# django
staff-graded-xblock==2.3.0
# via -r requirements/edx/base.txt
-starlette==0.38.6
+starlette==0.39.2
# via fastapi
stevedore==5.3.0
# via
@@ -1554,7 +1556,7 @@ tomlkit==0.13.2
# -r requirements/edx/base.txt
# pylint
# snowflake-connector-python
-tox==4.20.0
+tox==4.21.2
# via -r requirements/edx/testing.in
tqdm==4.66.5
# via
@@ -1566,6 +1568,7 @@ typing-extensions==4.12.2
# -r requirements/edx/base.txt
# django-countries
# edx-opaque-keys
+ # faker
# fastapi
# grimp
# import-linter
@@ -1578,6 +1581,7 @@ tzdata==2024.2
# via
# -r requirements/edx/base.txt
# celery
+ # icalendar
# kombu
unicodecsv==0.14.1
# via
@@ -1601,7 +1605,7 @@ urllib3==1.26.20
# requests
user-util==1.1.0
# via -r requirements/edx/base.txt
-uvicorn==0.30.6
+uvicorn==0.31.1
# via pact-python
vine==5.1.0
# via
@@ -1609,7 +1613,7 @@ vine==5.1.0
# amqp
# celery
# kombu
-virtualenv==20.26.5
+virtualenv==20.26.6
# via tox
voluptuous==0.15.2
# via
@@ -1619,7 +1623,7 @@ walrus==0.9.4
# via
# -r requirements/edx/base.txt
# edx-event-bus-redis
-watchdog==5.0.2
+watchdog==5.0.3
# via -r requirements/edx/base.txt
wcwidth==0.2.13
# via
@@ -1680,7 +1684,7 @@ xmlsec==1.3.13
# python3-saml
xss-utils==0.6.0
# via -r requirements/edx/base.txt
-yarl==1.12.1
+yarl==1.15.2
# via
# -r requirements/edx/base.txt
# aiohttp
diff --git a/requirements/pip-tools.txt b/requirements/pip-tools.txt
index 5bcb2aa550..110663ff6a 100644
--- a/requirements/pip-tools.txt
+++ b/requirements/pip-tools.txt
@@ -4,7 +4,7 @@
#
# make upgrade
#
-build==1.2.2
+build==1.2.2.post1
# via pip-tools
click==8.1.6
# via
@@ -14,7 +14,7 @@ packaging==24.1
# via build
pip-tools==7.4.1
# via -r requirements/pip-tools.in
-pyproject-hooks==1.1.0
+pyproject-hooks==1.2.0
# via
# build
# pip-tools
diff --git a/scripts/structures_pruning/requirements/base.txt b/scripts/structures_pruning/requirements/base.txt
index b80c660b87..a3fcacad2f 100644
--- a/scripts/structures_pruning/requirements/base.txt
+++ b/scripts/structures_pruning/requirements/base.txt
@@ -11,7 +11,7 @@ click==8.1.6
# click-log
click-log==0.4.0
# via -r scripts/structures_pruning/requirements/base.in
-dnspython==2.6.1
+dnspython==2.7.0
# via pymongo
edx-opaque-keys==2.11.0
# via -r scripts/structures_pruning/requirements/base.in
diff --git a/scripts/structures_pruning/requirements/testing.txt b/scripts/structures_pruning/requirements/testing.txt
index 8be2e15973..94c6ac6982 100644
--- a/scripts/structures_pruning/requirements/testing.txt
+++ b/scripts/structures_pruning/requirements/testing.txt
@@ -12,7 +12,7 @@ click-log==0.4.0
# via -r scripts/structures_pruning/requirements/base.txt
ddt==1.7.2
# via -r scripts/structures_pruning/requirements/testing.in
-dnspython==2.6.1
+dnspython==2.7.0
# via
# -r scripts/structures_pruning/requirements/base.txt
# pymongo
diff --git a/scripts/user_retirement/requirements/base.txt b/scripts/user_retirement/requirements/base.txt
index 9f57da73d0..1ae503010f 100644
--- a/scripts/user_retirement/requirements/base.txt
+++ b/scripts/user_retirement/requirements/base.txt
@@ -10,9 +10,9 @@ attrs==24.2.0
# via zeep
backoff==2.2.1
# via -r scripts/user_retirement/requirements/base.in
-boto3==1.35.27
+boto3==1.35.40
# via -r scripts/user_retirement/requirements/base.in
-botocore==1.35.27
+botocore==1.35.40
# via
# boto3
# s3transfer
@@ -46,13 +46,13 @@ django-crum==0.7.9
# via edx-django-utils
django-waffle==4.1.0
# via edx-django-utils
-edx-django-utils==5.16.0
+edx-django-utils==6.0.0
# via edx-rest-api-client
edx-rest-api-client==6.0.0
# via -r scripts/user_retirement/requirements/base.in
-google-api-core==2.20.0
+google-api-core==2.21.0
# via google-api-python-client
-google-api-python-client==2.147.0
+google-api-python-client==2.149.0
# via -r scripts/user_retirement/requirements/base.in
google-auth==2.35.0
# via
@@ -69,7 +69,7 @@ httplib2==0.22.0
# google-auth-httplib2
idna==3.10
# via requests
-isodate==0.6.1
+isodate==0.7.2
# via zeep
jenkinsapi==0.3.13
# via -r scripts/user_retirement/requirements/base.in
@@ -83,7 +83,7 @@ lxml==4.9.4
# zeep
more-itertools==10.5.0
# via simple-salesforce
-newrelic==9.13.0
+newrelic==10.1.0
# via edx-django-utils
pbr==6.1.0
# via stevedore
@@ -112,7 +112,7 @@ pyjwt[crypto]==2.9.0
# simple-salesforce
pynacl==1.5.0
# via edx-django-utils
-pyparsing==3.1.4
+pyparsing==3.2.0
# via httplib2
python-dateutil==2.9.0.post0
# via botocore
@@ -138,7 +138,7 @@ requests-toolbelt==1.0.0
# via zeep
rsa==4.9
# via google-auth
-s3transfer==0.10.2
+s3transfer==0.10.3
# via boto3
simple-salesforce==1.12.6
# via -r scripts/user_retirement/requirements/base.in
@@ -146,7 +146,6 @@ simplejson==3.19.3
# via -r scripts/user_retirement/requirements/base.in
six==1.16.0
# via
- # isodate
# jenkinsapi
# python-dateutil
sqlparse==0.5.1
@@ -164,5 +163,5 @@ urllib3==1.26.20
# -c scripts/user_retirement/requirements/../../../requirements/constraints.txt
# botocore
# requests
-zeep==4.2.1
+zeep==4.3.0
# via simple-salesforce
diff --git a/scripts/user_retirement/requirements/testing.txt b/scripts/user_retirement/requirements/testing.txt
index d5aac1cd06..d14082cf23 100644
--- a/scripts/user_retirement/requirements/testing.txt
+++ b/scripts/user_retirement/requirements/testing.txt
@@ -14,11 +14,11 @@ attrs==24.2.0
# zeep
backoff==2.2.1
# via -r scripts/user_retirement/requirements/base.txt
-boto3==1.35.27
+boto3==1.35.40
# via
# -r scripts/user_retirement/requirements/base.txt
# moto
-botocore==1.35.27
+botocore==1.35.40
# via
# -r scripts/user_retirement/requirements/base.txt
# boto3
@@ -66,17 +66,17 @@ django-waffle==4.1.0
# via
# -r scripts/user_retirement/requirements/base.txt
# edx-django-utils
-edx-django-utils==5.16.0
+edx-django-utils==6.0.0
# via
# -r scripts/user_retirement/requirements/base.txt
# edx-rest-api-client
edx-rest-api-client==6.0.0
# via -r scripts/user_retirement/requirements/base.txt
-google-api-core==2.20.0
+google-api-core==2.21.0
# via
# -r scripts/user_retirement/requirements/base.txt
# google-api-python-client
-google-api-python-client==2.147.0
+google-api-python-client==2.149.0
# via -r scripts/user_retirement/requirements/base.txt
google-auth==2.35.0
# via
@@ -103,7 +103,7 @@ idna==3.10
# requests
iniconfig==2.0.0
# via pytest
-isodate==0.6.1
+isodate==0.7.2
# via
# -r scripts/user_retirement/requirements/base.txt
# zeep
@@ -120,7 +120,7 @@ lxml==4.9.4
# via
# -r scripts/user_retirement/requirements/base.txt
# zeep
-markupsafe==2.1.5
+markupsafe==3.0.1
# via
# jinja2
# werkzeug
@@ -132,7 +132,7 @@ more-itertools==10.5.0
# simple-salesforce
moto==4.2.14
# via -r scripts/user_retirement/requirements/testing.in
-newrelic==9.13.0
+newrelic==10.1.0
# via
# -r scripts/user_retirement/requirements/base.txt
# edx-django-utils
@@ -184,7 +184,7 @@ pynacl==1.5.0
# via
# -r scripts/user_retirement/requirements/base.txt
# edx-django-utils
-pyparsing==3.1.4
+pyparsing==3.2.0
# via
# -r scripts/user_retirement/requirements/base.txt
# httplib2
@@ -235,7 +235,7 @@ rsa==4.9
# via
# -r scripts/user_retirement/requirements/base.txt
# google-auth
-s3transfer==0.10.2
+s3transfer==0.10.3
# via
# -r scripts/user_retirement/requirements/base.txt
# boto3
@@ -246,7 +246,6 @@ simplejson==3.19.3
six==1.16.0
# via
# -r scripts/user_retirement/requirements/base.txt
- # isodate
# jenkinsapi
# python-dateutil
sqlparse==0.5.1
@@ -275,9 +274,9 @@ urllib3==1.26.20
# responses
werkzeug==3.0.4
# via moto
-xmltodict==0.13.0
+xmltodict==0.14.1
# via moto
-zeep==4.2.1
+zeep==4.3.0
# via
# -r scripts/user_retirement/requirements/base.txt
# simple-salesforce
diff --git a/setup.py b/setup.py
index 28a25cc914..21c8e537c9 100644
--- a/setup.py
+++ b/setup.py
@@ -21,7 +21,7 @@ XBLOCKS = [
"html = xmodule.html_block:HtmlBlock",
"image = xmodule.template_block:TranslateCustomTagBlock",
"library = xmodule.library_root_xblock:LibraryRoot",
- "library_content = xmodule.library_content_block:LibraryContentBlock",
+ "library_content = xmodule.library_content_block:LegacyLibraryContentBlock",
"lti = xmodule.lti_block:LTIBlock",
"poll_question = xmodule.poll_block:PollBlock",
"problem = xmodule.capa_block:ProblemBlock",
diff --git a/webpack.builtinblocks.config.js b/webpack.builtinblocks.config.js
index d86f891dc6..1c5a9b1e0e 100644
--- a/webpack.builtinblocks.config.js
+++ b/webpack.builtinblocks.config.js
@@ -38,6 +38,10 @@ module.exports = {
'./xmodule/js/src/xmodule.js',
'./xmodule/js/src/html/edit.js'
],
+ LibraryContentBlockEditor: [
+ './xmodule/js/src/xmodule.js',
+ './xmodule/js/src/vertical/edit.js'
+ ],
LTIBlockDisplay: [
'./xmodule/js/src/xmodule.js',
'./xmodule/js/src/lti/lti.js'
@@ -46,11 +50,6 @@ module.exports = {
'./xmodule/js/src/xmodule.js',
'./xmodule/js/src/raw/edit/metadata-only.js'
],
- LibraryContentBlockDisplay: './xmodule/js/src/xmodule.js',
- LibraryContentBlockEditor: [
- './xmodule/js/src/xmodule.js',
- './xmodule/js/src/vertical/edit.js'
- ],
PollBlockDisplay: [
'./xmodule/js/src/xmodule.js',
'./xmodule/js/src/javascript_loader.js',
diff --git a/xmodule/assets/library_content/public/js/library_content_edit_helpers.js b/xmodule/assets/library_content/public/js/library_content_edit_helpers.js
deleted file mode 100644
index 564cc5fb0f..0000000000
--- a/xmodule/assets/library_content/public/js/library_content_edit_helpers.js
+++ /dev/null
@@ -1,54 +0,0 @@
-/* JavaScript for special editing operations that can be done on LibraryContentXBlock */
-// This is a temporary UI improvements that will be removed when V2 content libraries became
-// fully functional
-
-/**
- * Toggle the "Problem Type" settings section depending on selected library type.
- * As for now, the V2 libraries don't support different problem types, so they can't be
- * filtered by it. We're hiding the Problem Type field for them.
- */
-function checkProblemTypeShouldBeVisible(editor) {
- var libraries = editor.find('.wrapper-comp-settings.metadata_edit.is-active')
- .data().metadata.source_library_id.options;
- var selectedIndex = $("select[name='Library']", editor)[0].selectedIndex;
- var libraryKey = libraries[selectedIndex].value;
- var url = URI('/xblock')
- .segment(editor.find('.xblock.xblock-studio_view.xblock-studio_view-library_content.xblock-initialized')
- .data('usage-id'))
- .segment('handler')
- .segment('is_v2_library');
-
- $.ajax({
- type: 'POST',
- url: url,
- data: JSON.stringify({'library_key': libraryKey}),
- success: function(data) {
- var problemTypeSelect = editor.find("select[name='Problem Type']")
- .parents("li.field.comp-setting-entry.metadata_entry");
- data.is_v2 ? problemTypeSelect.hide() : problemTypeSelect.show();
- }
- });
-}
-
-/**
- * Waits untill editor html loaded, than calls checks for Program Type field toggling.
- */
-function waitForEditorLoading() {
- var checkContent = setInterval(function() {
- var $modal = $('.xblock-editor');
- var content = $modal.html();
- if (content) {
- clearInterval(checkContent);
- checkProblemTypeShouldBeVisible($modal);
- }
- }, 10);
-}
-// Initial call
-waitForEditorLoading();
-
-var $librarySelect = $("select[name='Library']");
-$(document).on('change', $librarySelect, waitForEditorLoading)
-
-var $libraryContentEditors = $('.xblock-header.xblock-header-library_content');
-var $editBtns = $libraryContentEditors.find('.action-item.action-edit');
-$(document).on('click', $editBtns, waitForEditorLoading)
diff --git a/xmodule/library_content_block.py b/xmodule/library_content_block.py
index 09a5d1dee1..adb07101d0 100644
--- a/xmodule/library_content_block.py
+++ b/xmodule/library_content_block.py
@@ -1,5 +1,12 @@
"""
-LibraryContent: The XBlock used to include blocks from a library in a course.
+LegacyLibraryContent: The XBlock used to randomly select a subset of blocks from a "v1" (modulestore-backed) library.
+
+In Studio, it's called the "Randomized Content Module".
+
+In the long-term, this block is deprecated in favor of "v2" (learning core-backed) library references:
+https://github.com/openedx/edx-platform/issues/32457
+
+We need to retain backwards-compatibility, but please do not build any new features into this.
"""
from __future__ import annotations
@@ -15,8 +22,7 @@ from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
from django.utils.functional import classproperty
from lxml import etree
from lxml.etree import XMLSyntaxError
-from opaque_keys import InvalidKeyError
-from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2
+from opaque_keys.edx.locator import LibraryLocator
from rest_framework import status
from web_fragments.fragment import Fragment
from webob import Response
@@ -78,7 +84,7 @@ class LibraryToolsUnavailable(ValueError):
@XBlock.wants('studio_user_permissions') # Only available in CMS.
@XBlock.wants('user')
@XBlock.needs('mako')
-class LibraryContentBlock(
+class LegacyLibraryContentBlock(
MakoTemplateBlockBase,
XmlMixin,
XModuleToXBlockMixin,
@@ -87,7 +93,7 @@ class LibraryContentBlock(
StudioEditableBlock,
):
"""
- An XBlock whose children are chosen dynamically from a content library.
+ An XBlock whose children are chosen dynamically from a legacy (v1) content library.
Can be used to create randomized assessments among other things.
Note: technically, all matching blocks from the content library are added
@@ -135,17 +141,6 @@ class LibraryContentBlock(
display_name=_("Library Version"),
scope=Scope.settings,
)
- mode = String(
- display_name=_("Mode"),
- help=_("Determines how content is drawn from the library"),
- default="random",
- values=[
- {"display_name": _("Choose n at random"), "value": "random"}
- # Future addition: Choose a new random set of n every time the student refreshes the block, for self tests
- # Future addition: manually selected blocks
- ],
- scope=Scope.settings,
- )
max_count = Integer(
display_name=_("Count"),
help=_("Enter the number of components to display to each student. Set it to -1 to display all components."),
@@ -179,15 +174,12 @@ class LibraryContentBlock(
"""
Convenience method to get the library ID as a LibraryLocator and not just a string.
- Supports either library v1 or library v2 locators.
+ Supports only v1 libraries.
"""
- try:
- return LibraryLocator.from_string(self.source_library_id)
- except InvalidKeyError:
- return LibraryLocatorV2.from_string(self.source_library_id)
+ return LibraryLocator.from_string(self.source_library_id)
@classmethod
- def make_selection(cls, selected, children, max_count, mode):
+ def make_selection(cls, selected, children, max_count):
"""
Dynamically selects block_ids indicating which of the possible children are displayed to the current user.
@@ -195,7 +187,6 @@ class LibraryContentBlock(
selected - list of (block_type, block_id) tuples assigned to this student
children - children of this block
max_count - number of components to display to each student
- mode - how content is drawn from the library
Returns:
A dict containing the following keys:
@@ -231,12 +222,9 @@ class LibraryContentBlock(
if num_to_add > 0:
# We need to select [more] blocks to display to this user:
pool = valid_block_keys - selected_keys
- if mode == "random":
- num_to_add = min(len(pool), num_to_add)
- added_block_keys = set(rand.sample(list(pool), num_to_add))
- # We now have the correct n random children to show for this user.
- else:
- raise NotImplementedError("Unsupported mode.")
+ num_to_add = min(len(pool), num_to_add)
+ added_block_keys = set(rand.sample(list(pool), num_to_add))
+ # We now have the correct n random children to show for this user.
selected_keys |= added_block_keys
if any((invalid_block_keys, overlimit_block_keys, added_block_keys)):
@@ -334,7 +322,7 @@ class LibraryContentBlock(
if max_count < 0:
max_count = len(self.children)
- block_keys = self.make_selection(self.selected, self.children, max_count, "random") # pylint: disable=no-member
+ block_keys = self.make_selection(self.selected, self.children, max_count) # pylint: disable=no-member
# Publish events for analytics purposes:
lib_tools = self.get_tools()
@@ -467,7 +455,6 @@ class LibraryContentBlock(
fragment = Fragment(
self.runtime.service(self, 'mako').render_cms_template(self.mako_template, self.get_context())
)
- fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/library_content_edit_helpers.js'))
add_webpack_js_to_fragment(fragment, 'LibraryContentBlockEditor')
shim_xmodule_js(fragment, self.studio_js_module_name)
return fragment
@@ -481,16 +468,12 @@ class LibraryContentBlock(
@property
def non_editable_metadata_fields(self):
non_editable_fields = super().non_editable_metadata_fields
- # The only supported mode is currently 'random'.
- # Add the mode field to non_editable_metadata_fields so that it doesn't
- # render in the edit form.
non_editable_fields.extend([
- LibraryContentBlock.mode,
- LibraryContentBlock.source_library_version,
+ LegacyLibraryContentBlock.source_library_version,
])
return non_editable_fields
- def get_tools(self, to_read_library_content: bool = False) -> 'LibraryToolsService':
+ def get_tools(self, to_read_library_content: bool = False) -> 'LegacyLibraryToolsService':
"""
Grab the library tools service and confirm that it'll work for us. Else, raise LibraryToolsUnavailable.
"""
@@ -564,22 +547,6 @@ class LibraryContentBlock(
library_version=(None if upgrade_to_latest else self.source_library_version),
)
- @XBlock.json_handler
- def is_v2_library(self, data, suffix=''): # pylint: disable=unused-argument
- """
- Check the library version by library_id.
-
- This is a temporary handler needed for hiding the Problem Type xblock editor field for V2 libraries.
- """
- lib_key = data.get('library_key')
- try:
- LibraryLocatorV2.from_string(lib_key)
- except InvalidKeyError:
- is_v2 = False
- else:
- is_v2 = True
- return {'is_v2': is_v2}
-
@XBlock.handler
def children_are_syncing(self, request, suffix=''): # pylint: disable=unused-argument
"""
@@ -809,14 +776,14 @@ class LibraryContentBlock(
return xml_object
-class LibrarySummary:
+class LegacyLibrarySummary:
"""
A library summary object which contains the fields required for library listing on studio.
"""
def __init__(self, library_locator, display_name):
"""
- Initialize LibrarySummary
+ Initialize LegacyLibrarySummary
Arguments:
library_locator (LibraryLocator): LibraryLocator object of the library.
diff --git a/xmodule/library_tools.py b/xmodule/library_tools.py
index 2c077a8884..7f9e83a937 100644
--- a/xmodule/library_tools.py
+++ b/xmodule/library_tools.py
@@ -1,18 +1,17 @@
"""
-XBlock runtime services for LibraryContentBlock
+XBlock runtime services for LegacyLibraryContentBlock
"""
from __future__ import annotations
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
-from django.conf import settings
from django.core.exceptions import ObjectDoesNotExist
+from opaque_keys.edx.locator import LibraryLocator
from user_tasks.models import UserTaskStatus
from openedx.core.lib import ensure_cms
-from openedx.core.djangoapps.content_libraries import api as library_api
from openedx.core.djangoapps.content_libraries import tasks as library_tasks
-from xmodule.library_content_block import LibraryContentBlock
-from xmodule.library_root_xblock import LibraryRoot as LibraryRootV1
+from xmodule.library_content_block import LegacyLibraryContentBlock
+from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.exceptions import ItemNotFoundError
@@ -21,9 +20,9 @@ def normalize_key_for_search(library_key):
return library_key.replace(version_guid=None, branch=None)
-class LibraryToolsService:
+class LegacyLibraryToolsService:
"""
- Service for LibraryContentBlock.
+ Service for LegacyLibraryContentBlock.
Allows to interact with libraries in the modulestore and learning core.
@@ -33,24 +32,31 @@ class LibraryToolsService:
self.store = modulestore
self.user_id = user_id
- def get_latest_library_version(self, lib_key) -> str | None:
+ def get_latest_library_version(self, library_id: str | LibraryLocator) -> str | None:
"""
Get the version of the given library as string.
The return value (library version) could be:
str() - for V1 library;
- str() - for V2 library.
None - if the library does not exist.
"""
- library = library_api.get_v1_or_v2_library(lib_key, version=None)
+ library_key: LibraryLocator
+ if isinstance(library_id, str):
+ library_key = LibraryLocator.from_string(library_id)
+ else:
+ library_key = library_id
+ library_key = library_key.for_branch(ModuleStoreEnum.BranchName.library).for_version(None)
+ try:
+ library = self.store.get_library(
+ library_key, remove_version=False, remove_branch=False, head_validation=False
+ )
+ except ItemNotFoundError:
+ return None
if not library:
return None
- elif isinstance(library, LibraryRootV1):
- # We need to know the library's version so ensure it's set in library.location.library_key.version_guid
- assert library.location.library_key.version_guid is not None
- return str(library.location.library_key.version_guid)
- elif isinstance(library, library_api.ContentLibraryMetadata):
- return str(library.version)
+ # We need to know the library's version so ensure it's set in library.location.library_key.version_guid
+ assert library.location.library_key.version_guid is not None
+ return str(library.location.library_key.version_guid)
def create_block_analytics_summary(self, course_key, block_keys):
"""
@@ -96,7 +102,7 @@ class LibraryToolsService:
"""
return self.store.check_supports(block.location.course_key, 'copy_from_template')
- def trigger_library_sync(self, dest_block: LibraryContentBlock, library_version: str | int | None) -> None:
+ def trigger_library_sync(self, dest_block: LegacyLibraryContentBlock, library_version: str | None) -> None:
"""
Queue task to synchronize the children of `dest_block` with it source library (at `library_version` or latest).
@@ -118,16 +124,20 @@ class LibraryToolsService:
`dest_block.children`.
"""
ensure_cms("library_content block children may only be synced in a CMS context")
- if not isinstance(dest_block, LibraryContentBlock):
+ if not isinstance(dest_block, LegacyLibraryContentBlock):
raise ValueError(f"Can only sync children for library_content blocks, not {dest_block.tag} blocks.")
if not dest_block.source_library_id:
dest_block.source_library_version = ""
return
- library_key = dest_block.source_library_key
- if not library_api.get_v1_or_v2_library(library_key, version=library_version):
+ library_key = dest_block.source_library_key.for_branch(
+ ModuleStoreEnum.BranchName.library
+ ).for_version(library_version)
+ try:
+ self.store.get_library(library_key, remove_version=False, remove_branch=False, head_validation=False)
+ except ItemNotFoundError as exc:
if library_version:
- raise ObjectDoesNotExist(f"Version {library_version} of library {library_key} not found.")
- raise ObjectDoesNotExist(f"Library {library_key} not found.")
+ raise ObjectDoesNotExist(f"Version {library_version} of library {library_key} not found.") from exc
+ raise ObjectDoesNotExist(f"Library {library_key} not found.") from exc
# TODO: This task is synchronous until we can figure out race conditions with import.
# These race conditions lead to failed imports of library content from course import.
@@ -140,12 +150,14 @@ class LibraryToolsService:
),
)
- def trigger_duplication(self, source_block: LibraryContentBlock, dest_block: LibraryContentBlock) -> None:
+ def trigger_duplication(
+ self, source_block: LegacyLibraryContentBlock, dest_block: LegacyLibraryContentBlock
+ ) -> None:
"""
Queue a task to duplicate the children of `source_block` to `dest_block`.
"""
ensure_cms("library_content block children may only be duplicated in a CMS context")
- if not isinstance(dest_block, LibraryContentBlock):
+ if not isinstance(dest_block, LegacyLibraryContentBlock):
raise ValueError(f"Can only duplicate children for library_content blocks, not {dest_block.tag} blocks.")
if source_block.scope_ids.usage_id.context_key != source_block.scope_ids.usage_id.context_key:
raise ValueError(
@@ -163,7 +175,7 @@ class LibraryToolsService:
dest_block_id=str(dest_block.scope_ids.usage_id),
)
- def are_children_syncing(self, library_content_block: LibraryContentBlock) -> bool:
+ def are_children_syncing(self, library_content_block: LegacyLibraryContentBlock) -> bool:
"""
Is a task currently running to sync the children of `library_content_block`?
@@ -179,21 +191,12 @@ class LibraryToolsService:
def list_available_libraries(self):
"""
- List all known libraries.
+ List all known legacy libraries.
- Collects Only V2 Libaries if the FEATURES[ENABLE_LIBRARY_AUTHORING_MICROFRONTEND] setting is True.
- Otherwise, return all v1 and v2 libraries.
Returns tuples of (library key, display_name).
"""
user = User.objects.get(id=self.user_id)
- v1_libs = [
+ return [
(lib.location.library_key.replace(version_guid=None, branch=None), lib.display_name)
for lib in self.store.get_library_summaries()
]
- v2_query = library_api.get_libraries_for_user(user)
- v2_libs_with_meta = library_api.get_metadata(v2_query)
- v2_libs = [(lib.key, lib.title) for lib in v2_libs_with_meta]
-
- if settings.FEATURES.get('ENABLE_LIBRARY_AUTHORING_MICROFRONTEND'):
- return v2_libs
- return v1_libs + v2_libs
diff --git a/xmodule/modulestore/mixed.py b/xmodule/modulestore/mixed.py
index e1ea6640ac..fb5be17041 100644
--- a/xmodule/modulestore/mixed.py
+++ b/xmodule/modulestore/mixed.py
@@ -343,7 +343,7 @@ class MixedModuleStore(ModuleStoreDraftAndPublished, ModuleStoreWriteBase):
@strip_key
def get_library_summaries(self, **kwargs):
"""
- Returns a list of LibrarySummary objects.
+ Returns a list of LegacyLibrarySummary objects.
Information contains `location`, `display_name`, `locator` of the libraries in this modulestore.
"""
library_summaries = {}
diff --git a/xmodule/modulestore/split_mongo/caching_descriptor_system.py b/xmodule/modulestore/split_mongo/caching_descriptor_system.py
index b0965d63fe..a83fec32ba 100644
--- a/xmodule/modulestore/split_mongo/caching_descriptor_system.py
+++ b/xmodule/modulestore/split_mongo/caching_descriptor_system.py
@@ -13,7 +13,7 @@ from xblock.runtime import KeyValueStore, KvsFieldData
from xmodule.error_block import ErrorBlock
from xmodule.errortracker import exc_info_to_str
-from xmodule.library_tools import LibraryToolsService
+from xmodule.library_tools import LegacyLibraryToolsService
from xmodule.mako_block import MakoDescriptorSystem
from xmodule.modulestore import BlockData
from xmodule.modulestore.edit_info import EditInfoRuntimeMixin
@@ -78,7 +78,7 @@ class CachingDescriptorSystem(MakoDescriptorSystem, EditInfoRuntimeMixin): # li
user = get_current_user()
user_id = user.id if user else None
- self._services['library_tools'] = LibraryToolsService(modulestore, user_id=user_id)
+ self._services['library_tools'] = LegacyLibraryToolsService(modulestore, user_id=user_id)
# Cache of block field datas, keyed by the XBlock instance (since the ScopeId changes!)
self.block_field_datas = weakref.WeakKeyDictionary()
diff --git a/xmodule/modulestore/split_mongo/split.py b/xmodule/modulestore/split_mongo/split.py
index 64e19420a1..e69a2ca0e5 100644
--- a/xmodule/modulestore/split_mongo/split.py
+++ b/xmodule/modulestore/split_mongo/split.py
@@ -81,7 +81,7 @@ from xmodule.assetstore import AssetMetadata
from xmodule.course_block import CourseSummary
from xmodule.error_block import ErrorBlock
from xmodule.errortracker import null_error_tracker
-from xmodule.library_content_block import LibrarySummary
+from xmodule.library_content_block import LegacyLibrarySummary
from xmodule.modulestore import (
BlockData,
BulkOperationsMixin,
@@ -1029,7 +1029,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
@autoretry_read()
def get_library_summaries(self, **kwargs):
"""
- Returns a list of `LibrarySummary` objects.
+ Returns a list of `LegacyLibrarySummary` objects.
kwargs can be valid db fields to match against active_versions
collection e.g org='example_org'.
"""
@@ -1057,7 +1057,7 @@ class SplitMongoModuleStore(SplitBulkWriteMixin, ModuleStoreWriteBase):
display_name = library_block_fields['display_name']
libraries_summaries.append(
- LibrarySummary(library_locator, display_name)
+ LegacyLibrarySummary(library_locator, display_name)
)
return libraries_summaries
diff --git a/xmodule/tests/test_library_content.py b/xmodule/tests/test_library_content.py
index e30e19922b..9914ef2d90 100644
--- a/xmodule/tests/test_library_content.py
+++ b/xmodule/tests/test_library_content.py
@@ -1,7 +1,5 @@
"""
-Basic unit tests for LibraryContentBlock
-
-Higher-level tests are in `cms/djangoapps/contentstore/tests/test_libraries.py`.
+Basic unit tests for LegacyLibraryContentBlock
"""
from unittest.mock import MagicMock, Mock, patch
@@ -9,15 +7,15 @@ import ddt
from bson.objectid import ObjectId
from fs.memoryfs import MemoryFS
from lxml import etree
-from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2
+from opaque_keys.edx.locator import LibraryLocator
from rest_framework import status
from search.search_engine_base import SearchEngine
from web_fragments.fragment import Fragment
from xblock.runtime import Runtime as VanillaRuntime
from openedx.core.djangolib.testing.utils import skip_unless_cms
-from xmodule.library_content_block import ANY_CAPA_TYPE_VALUE, LibraryContentBlock
-from xmodule.library_tools import LibraryToolsService
+from xmodule.library_content_block import ANY_CAPA_TYPE_VALUE, LegacyLibraryContentBlock
+from xmodule.library_tools import LegacyLibraryToolsService
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory
from xmodule.modulestore.tests.utils import MixedSplitTestCase
@@ -33,15 +31,15 @@ dummy_render = lambda block, _: Fragment(block.data) # pylint: disable=invalid-
@skip_unless_cms
-class LibraryContentTest(MixedSplitTestCase):
+class LegacyLibraryContentTest(MixedSplitTestCase):
"""
- Base class for tests of LibraryContentBlock (library_content_block.py)
+ Base class for tests of LegacyLibraryContentBlock (library_content_block.py)
"""
def setUp(self):
super().setUp()
self.user_id = UserFactory().id
- self.tools = LibraryToolsService(self.store, self.user_id)
+ self.tools = LegacyLibraryToolsService(self.store, self.user_id)
self.library = LibraryFactory.create(modulestore=self.store)
self.lib_blocks = [
self.make_block("html", self.library, data=f"Hello world from block {i}")
@@ -88,29 +86,22 @@ class LibraryContentTest(MixedSplitTestCase):
@ddt.ddt
-class LibraryContentGeneralTest(LibraryContentTest):
+class LegacyLibraryContentGeneralTest(LegacyLibraryContentTest):
"""
- Test the base functionality of the LibraryContentBlock.
+ Test the base functionality of the LegacyLibraryContentBlock.
"""
- @ddt.data(
- ('library-v1:ProblemX+PR0B', LibraryLocator),
- ('lib:ORG:test-1', LibraryLocatorV2)
- )
- @ddt.unpack
- def test_source_library_key(self, library_key, expected_locator_type):
+ def test_source_library_key(self):
"""
Test the source_library_key property of the xblock.
-
- The method should correctly work either with V1 or V2 libraries.
"""
library = self.make_block(
"library_content",
self.vertical,
max_count=1,
- source_library_id=library_key
+ source_library_id='library-v1:ProblemX+PR0B',
)
- assert isinstance(library.source_library_key, expected_locator_type)
+ assert isinstance(library.source_library_key, LibraryLocator)
def test_initial_sync_from_library(self):
"""
@@ -133,9 +124,9 @@ class LibraryContentGeneralTest(LibraryContentTest):
assert len(self.lc_block.children) == len(self.lib_blocks)
-class TestLibraryContentExportImport(LibraryContentTest):
+class TestLibraryContentExportImport(LegacyLibraryContentTest):
"""
- Export and import tests for LibraryContentBlock
+ Export and import tests for LegacyLibraryContentBlock
"""
def setUp(self):
super().setUp()
@@ -173,7 +164,6 @@ class TestLibraryContentExportImport(LibraryContentTest):
assert imported_lc_block.display_name == self.lc_block.display_name
assert imported_lc_block.source_library_id == self.lc_block.source_library_id
assert imported_lc_block.source_library_version == self.lc_block.source_library_version
- assert imported_lc_block.mode == self.lc_block.mode
assert imported_lc_block.max_count == self.lc_block.max_count
assert imported_lc_block.capa_type == self.lc_block.capa_type
assert len(imported_lc_block.children) == len(self.lc_block.children)
@@ -195,13 +185,13 @@ class TestLibraryContentExportImport(LibraryContentTest):
# Now import it.
olx_element = etree.fromstring(exported_olx)
- imported_lc_block = LibraryContentBlock.parse_xml(olx_element, self.runtime, None)
+ imported_lc_block = LegacyLibraryContentBlock.parse_xml(olx_element, self.runtime, None)
self._verify_xblock_properties(imported_lc_block)
def test_xml_import_with_comments(self):
"""
- Test that XML comments within LibraryContentBlock are ignored during the import.
+ Test that XML comments within LegacyLibraryContentBlock are ignored during the import.
"""
olx_with_comments = (
'\n'
@@ -219,15 +209,15 @@ class TestLibraryContentExportImport(LibraryContentTest):
# Import the olx.
olx_element = etree.fromstring(olx_with_comments)
- imported_lc_block = LibraryContentBlock.parse_xml(olx_element, self.runtime, None)
+ imported_lc_block = LegacyLibraryContentBlock.parse_xml(olx_element, self.runtime, None)
self._verify_xblock_properties(imported_lc_block)
@ddt.ddt
-class LibraryContentBlockTestMixin:
+class LegacyLibraryContentBlockTestMixin:
"""
- Basic unit tests for LibraryContentBlock
+ Basic unit tests for LegacyLibraryContentBlock
"""
problem_types = [
["multiplechoiceresponse"], ["optionresponse"], ["optionresponse", "coderesponse"],
@@ -424,8 +414,7 @@ class LibraryContentBlockTestMixin:
Test the settings that are marked as "non-editable".
"""
non_editable_metadata_fields = self.lc_block.non_editable_metadata_fields
- assert LibraryContentBlock.mode in non_editable_metadata_fields
- assert LibraryContentBlock.display_name not in non_editable_metadata_fields
+ assert LegacyLibraryContentBlock.display_name not in non_editable_metadata_fields
def test_overlimit_blocks_chosen_randomly(self):
"""
@@ -503,7 +492,7 @@ search_index_mock = Mock(spec=SearchEngine) # pylint: disable=invalid-name
@patch.object(SearchEngine, 'get_search_engine', Mock(return_value=None, autospec=True))
-class TestLibraryContentBlockWithSearchIndex(LibraryContentBlockTestMixin, LibraryContentTest):
+class TestLegacyLibraryContentBlockWithSearchIndex(LegacyLibraryContentBlockTestMixin, LegacyLibraryContentTest):
"""
Tests for library container with mocked search engine response.
"""
@@ -532,9 +521,9 @@ class TestLibraryContentBlockWithSearchIndex(LibraryContentBlockTestMixin, Libra
)
@patch('xmodule.html_block.HtmlBlock.author_view', dummy_render, create=True)
@patch('xmodule.x_module.DescriptorSystem.applicable_aside_types', lambda self, block: [])
-class TestLibraryContentRender(LibraryContentTest):
+class TestLibraryContentRender(LegacyLibraryContentTest):
"""
- Rendering unit tests for LibraryContentBlock
+ Rendering unit tests for LegacyLibraryContentBlock
"""
def setUp(self):
@@ -559,9 +548,9 @@ class TestLibraryContentRender(LibraryContentTest):
# but some js initialization should happen
-class TestLibraryContentAnalytics(LibraryContentTest):
+class TestLibraryContentAnalytics(LegacyLibraryContentTest):
"""
- Test analytics features of LibraryContentBlock
+ Test analytics features of LegacyLibraryContentBlock
"""
def setUp(self):
@@ -573,7 +562,7 @@ class TestLibraryContentAnalytics(LibraryContentTest):
def _assert_event_was_published(self, event_type):
"""
- Check that a LibraryContentBlock analytics event was published by self.lc_block.
+ Check that a LegacyLibraryContentBlock analytics event was published by self.lc_block.
"""
assert self.publisher.called
assert len(self.publisher.call_args[0]) == 3 # pylint:disable=unsubscriptable-object
diff --git a/xmodule/tests/test_library_tools.py b/xmodule/tests/test_library_tools.py
index f93066cd5c..30b007c3d9 100644
--- a/xmodule/tests/test_library_tools.py
+++ b/xmodule/tests/test_library_tools.py
@@ -1,23 +1,20 @@
"""
-Tests for library tools service (only used by CMS)
+Tests for legacy library tools service (only used by CMS)
-Currently, the only known user of the LibraryToolsService is the
-LibraryContentBlock, so these tests are all written with only that
+The only known user of the LegacyLibraryToolsService is the
+LegacyLibraryContentBlock, so these tests are all written with only that
block type in mind.
"""
from unittest import mock
import ddt
-from django.conf import settings
-from django.test import override_settings
-from opaque_keys.edx.locator import LibraryLocator, LibraryLocatorV2
+from opaque_keys.edx.locator import LibraryLocator
-from common.djangoapps.student.roles import CourseInstructorRole
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangolib.testing.utils import skip_unless_cms
from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest
-from xmodule.library_tools import LibraryToolsService
+from xmodule.library_tools import LegacyLibraryToolsService
from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory
from xmodule.modulestore.tests.utils import MixedSplitTestCase
@@ -26,34 +23,23 @@ from xmodule.modulestore.tests.utils import MixedSplitTestCase
@ddt.ddt
class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest):
"""
- Tests for LibraryToolsService.
-
- Tests interaction with learning-core-based (V2) and mongo-based (V1) content libraries.
+ Tests for LegacyLibraryToolsService.
"""
def setUp(self):
super().setUp()
UserFactory(is_staff=True, id=self.user_id)
- self.tools = LibraryToolsService(self.store, self.user_id)
+ self.tools = LegacyLibraryToolsService(self.store, self.user_id)
def test_list_available_libraries(self):
"""
- Test listing of libraries.
-
- Collects Only V2 Libaries if the FEATURES[ENABLE_LIBRARY_AUTHORING_MICROFRONTEND] setting is True.
- Otherwise, return all v1 and v2 libraries.
+ Test listing of v1 libraries.
"""
# create V1 library
_ = LibraryFactory.create(modulestore=self.store)
- # create V2 library
+ # create V2 library (should not be included in this list)
self._create_library(slug="testlib1_preview", title="Test Library 1", description="Testing XBlocks")
all_libraries = self.tools.list_available_libraries()
- assert all_libraries
- assert len(all_libraries) == 2
-
- with override_settings(FEATURES={**settings.FEATURES, "ENABLE_LIBRARY_AUTHORING_MICROFRONTEND": True}):
- all_libraries = self.tools.list_available_libraries()
- assert all_libraries
- assert len(all_libraries) == 1
+ assert len(all_libraries) == 1
@mock.patch('xmodule.modulestore.split_mongo.split.SplitMongoModuleStore.get_library_summaries')
def test_list_available_libraries_fetch(self, mock_get_library_summaries):
@@ -63,7 +49,7 @@ class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest):
_ = self.tools.list_available_libraries()
assert mock_get_library_summaries.called
- def test_get_latest_v1_library_version(self):
+ def test_get_latest_library_version(self):
"""
Test get_v1_library_version for V1 libraries.
@@ -84,49 +70,16 @@ class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest):
assert result == str(lib.location.library_key.version_guid)
@ddt.data(
- 'library-v1:Fake+Key', # V1 library key
- 'lib:Fake:V-2', # V2 library key
+ 'library-v1:Fake+Key',
LibraryLocator.from_string('library-v1:Fake+Key'),
- LibraryLocatorV2.from_string('lib:Fake:V-2'),
)
def test_get_latest_library_version_no_library(self, lib_key):
"""
Test get_latest_library_version result when the library does not exist.
-
- Provided lib_key's are valid V1 or V2 keys.
"""
assert self.tools.get_latest_library_version(lib_key) is None
- def test_update_children_for_v2_lib(self):
- """
- Test update_children with V2 library as a source.
- """
- library = self._create_library(
- slug="cool-v2-lib", title="The best Library", description="Spectacular description"
- )
- self._add_block_to_library(library["id"], "unit", "unit1_id")
-
- course = CourseFactory.create(modulestore=self.store, user_id=self.user.id)
- CourseInstructorRole(course.id).add_users(self.user)
-
- content_block = self.make_block(
- "library_content",
- course,
- max_count=1,
- source_library_id=library['id']
- )
- assert len(content_block.children) == 0
-
- # Populate children from library
- self.tools.trigger_library_sync(content_block, library_version=None)
-
- # The updates happen in a Celery task, so this particular content_block instance is no updated.
- # We must re-instantiate it from modulstore in order to see the updated children list.
- content_block = self.store.get_item(content_block.location)
-
- assert len(content_block.children) == 1
-
- def test_update_children_for_v1_lib(self):
+ def test_update_children(self):
"""
Test update_children with V1 library as a source.
diff --git a/xmodule/tests/test_randomize_block.py b/xmodule/tests/test_randomize_block.py
index deebdfe4f1..52413faad3 100644
--- a/xmodule/tests/test_randomize_block.py
+++ b/xmodule/tests/test_randomize_block.py
@@ -16,7 +16,7 @@ from .test_course_block import DummySystem as TestImportSystem
class RandomizeBlockTest(MixedSplitTestCase):
"""
- Base class for tests of LibraryContentBlock (library_content_block.py)
+ Base class for tests of RandomizeBlock (randomize_block.py)
"""
maxDiff = None
diff --git a/xmodule/vertical_block.py b/xmodule/vertical_block.py
index 2a10ae4449..81621a7a7b 100644
--- a/xmodule/vertical_block.py
+++ b/xmodule/vertical_block.py
@@ -188,7 +188,7 @@ class VerticalBlock(
if has_access_error:
return True
- # Check child nodes if they exist (e.g. randomized library question aka LibraryContentBlock)
+ # Check child nodes if they exist (e.g. randomized library question aka LegacyLibraryContentBlock)
for child in block.get_children():
has_access_error = getattr(child, 'has_access_error', False)
if has_access_error:
diff --git a/xmodule/video_block/transcripts_utils.py b/xmodule/video_block/transcripts_utils.py
index 132b8cff1e..866edf5968 100644
--- a/xmodule/video_block/transcripts_utils.py
+++ b/xmodule/video_block/transcripts_utils.py
@@ -8,17 +8,20 @@ import copy
import html
import logging
import os
+import pathlib
import re
from functools import wraps
import requests
import simplejson as json
from django.conf import settings
+from django.core.exceptions import ObjectDoesNotExist
from lxml import etree
from opaque_keys.edx.keys import UsageKeyV2
from pysrt import SubRipFile, SubRipItem, SubRipTime
from pysrt.srtexc import Error
+from openedx.core.djangoapps.xblock.api import get_component_from_usage_key
from xmodule.contentstore.content import StaticContent
from xmodule.contentstore.django import contentstore
from xmodule.exceptions import NotFoundError
@@ -1039,27 +1042,29 @@ def get_transcript_from_contentstore(video, language, output_format, transcripts
def get_transcript_from_learning_core(video_block, language, output_format, transcripts_info):
"""
- Get video transcript from Learning Core.
+ Get video transcript from Learning Core (used for Content Libraries)
- HISTORIC INFORMATION FROM WHEN THIS FUNCTION WAS `get_transcript_from_blockstore`:
+ Limitation: This is only going to grab from the Draft version.
- Blockstore expects video transcripts to be placed into the 'static/'
- subfolder of the XBlock's folder in a Blockstore bundle. For example, if the
- video XBlock's definition is in the standard location of
- video/video1/definition.xml
- Then the .srt files should be placed at e.g.
- video/video1/static/video1-en.srt
- This is the same place where other public static files are placed for other
- XBlocks, such as image files used by HTML blocks.
+ Learning Core models a VideoBlock's data in a more generic thing it calls a
+ Component. Each Component has its own virtual space for file-like data. The
+ OLX for the VideoBlock itself is stored at the root of that space, as
+ ``block.xml``. Static assets that are meant to be user-downloadable are
+ placed in a `static/` directory for that Component, and this is where we
+ expect to store transcript files.
- Video XBlocks in Blockstore must set the 'transcripts' XBlock field to a
- JSON dictionary listing the filename of the transcript for each language:
-
+ So if there is a ``video1-en.srt`` file for a particular VideoBlock, we
+ expect that to be stored as ``static/video1-en.srt`` in the Component. Any
+ other downloadable files would be here as well, such as thumbnails.
+
+ Video XBlocks in Blockstore must set the 'transcripts' XBlock field to a
+ JSON dictionary listing the filename of the transcript for each language:
+
This method is tested in openedx/core/djangoapps/content_libraries/tests/test_static_assets.py
@@ -1072,9 +1077,71 @@ def get_transcript_from_learning_core(video_block, language, output_format, tran
Returns:
tuple containing content, filename, mimetype
"""
- # TODO: Update to use Learning Core data models once static assets support
- # has been added.
- raise NotFoundError("No transcript - transcripts not supported yet by learning core components.")
+ usage_key = video_block.usage_key
+
+ # Validate that the format is something we even support...
+ if output_format not in (Transcript.SRT, Transcript.SJSON, Transcript.TXT):
+ raise NotFoundError(f'Invalid transcript format `{output_format}`')
+
+ # See if the requested language exists.
+ transcripts = transcripts_info['transcripts']
+ if language not in transcripts:
+ raise NotFoundError(
+ f"Video {usage_key} does not have a transcript file defined for the "
+ f"'{language}' language in its OLX."
+ )
+
+ # Grab the underlying Component. There's no version parameter to this call,
+ # so we're just going to grab the file associated with the latest draft
+ # version for now.
+ component = get_component_from_usage_key(usage_key)
+ component_version = component.versioning.draft
+ if not component_version:
+ raise NotFoundError(
+ f"No transcript for {usage_key} because Component {component.uuid} "
+ "was soft-deleted."
+ )
+
+ file_path = pathlib.Path(f"static/{transcripts[language]}")
+ if file_path.suffix != '.srt':
+ # We want to standardize on .srt
+ raise NotFoundError(
+ "Video XBlocks in Content Libraries only support storing .srt "
+ f"transcript files, but we tried to look up {file_path} for {usage_key}"
+ )
+
+ # TODO: There should be a Learning Core API call for this:
+ try:
+ content = (
+ component_version
+ .componentversioncontent_set
+ .filter(content__has_file=True)
+ .select_related('content')
+ .get(key=file_path)
+ .content
+ )
+ data = content.read_file().read()
+ except ObjectDoesNotExist as exc:
+ raise NotFoundError(
+ f"No file {file_path} found for {usage_key} "
+ f"(ComponentVersion {component_version.uuid})"
+ ) from exc
+
+ # Now convert the transcript data to the requested format:
+ output_filename = f'{file_path.stem}.{output_format}'
+ output_transcript = Transcript.convert(
+ data.decode('utf-8'),
+ input_format=Transcript.SRT,
+ output_format=output_format,
+ )
+ if not output_transcript.strip():
+ raise NotFoundError(
+ f"Transcript file {file_path} found for {usage_key} "
+ f"(ComponentVersion {component_version.uuid}), but it has no "
+ "content or is malformed."
+ )
+
+ return output_transcript, output_filename, Transcript.mime_types[output_format]
def get_transcript(video, lang=None, output_format=Transcript.SRT, youtube_id=None):