Merge branch 'master' into kireiev/AXM-549/feat/upstream_PR_active_inactive_courses_API
This commit is contained in:
20
.github/CODEOWNERS
vendored
20
.github/CODEOWNERS
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/ci-static-analysis.yml
vendored
2
.github/workflows/ci-static-analysis.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
matrix:
|
||||
python-version:
|
||||
- "3.11"
|
||||
os: ["ubuntu-latest"]
|
||||
os: ["ubuntu-22.04"]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
4
.github/workflows/migrations-check.yml
vendored
4
.github/workflows/migrations-check.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-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
|
||||
|
||||
18
.github/workflows/pylint-checks.yml
vendored
18
.github/workflows/pylint-checks.yml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/quality-checks.yml
vendored
2
.github/workflows/quality-checks.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
os: [ubuntu-22.04]
|
||||
python-version:
|
||||
- "3.11"
|
||||
node-version: [20]
|
||||
|
||||
2
.github/workflows/static-assets-check.yml
vendored
2
.github/workflows/static-assets-check.yml
vendored
@@ -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]
|
||||
|
||||
12
.github/workflows/unit-tests.yml
vendored
12
.github/workflows/unit-tests.yml
vendored
@@ -15,7 +15,7 @@ concurrency:
|
||||
jobs:
|
||||
run-tests:
|
||||
name: ${{ matrix.shard_name }}(py=${{ matrix.python-version }},dj=${{ matrix.django-version }},mongo=${{ matrix.mongo-version }})
|
||||
runs-on: ubuntu-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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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, """
|
||||
<problem display_name="MCQ" max_attempts="1">
|
||||
library = LibraryFactory.create(display_name='Library')
|
||||
lib_block = BlockFactory.create(
|
||||
parent_location=library.usage_key,
|
||||
category="problem",
|
||||
display_name="MCQ",
|
||||
max_attempts=1,
|
||||
data="""
|
||||
<multiplechoiceresponse>
|
||||
<label>Q</label>
|
||||
<choicegroup type="MultipleChoice">
|
||||
@@ -445,9 +440,9 @@ class ClipboardLibraryContentPasteTestCase(ModuleStoreTestCase):
|
||||
<choice correct="true">Right</choice>
|
||||
</choicegroup>
|
||||
</multiplechoiceresponse>
|
||||
</problem>
|
||||
""")
|
||||
library_api.publish_changes(library.key)
|
||||
""",
|
||||
publish_item=False,
|
||||
)
|
||||
return library
|
||||
|
||||
def test_paste_library_content_block(self):
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.")}
|
||||
</div>
|
||||
<div style="margin-left:auto; width:fit-content;">
|
||||
%if settings.DISCUSSIONS_INCONTEXT_LEARNMORE_URL:
|
||||
<span>
|
||||
<a href="${settings.DISCUSSIONS_INCONTEXT_LEARNMORE_URL}" target="_blank" rel="noreferrer noopener">${_(" Learn more")}</a>
|
||||
<i class="fa fa-share-square-o" aria-hidden="true"></i>
|
||||
</span>
|
||||
%endif
|
||||
%if settings.DISCUSSIONS_INCONTEXT_FEEDBACK_URL:
|
||||
<span style="margin-left: 1rem">
|
||||
<a href="${settings.DISCUSSIONS_INCONTEXT_FEEDBACK_URL}" target="_blank" rel="noreferrer noopener">${_("Share feedback")}</a>
|
||||
<i class="fa fa-share-square-o" aria-hidden="true"></i>
|
||||
</span>
|
||||
%endif
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -348,9 +348,6 @@ from openedx.core.djangolib.js_utils import (
|
||||
% endif
|
||||
|
||||
% if libraries_enabled:
|
||||
% if redirect_to_library_authoring_mfe:
|
||||
<li><a href="${library_authoring_mfe_url}">${_("Libraries")}</a></li>
|
||||
%else:
|
||||
<li class="libraries-tab ${ 'active' if active_tab == 'libraries' else ''}">
|
||||
% if split_studio_home:
|
||||
<a href="${reverse('home_library')}">${_("Libraries")}</a>
|
||||
@@ -358,7 +355,6 @@ from openedx.core.djangolib.js_utils import (
|
||||
<a href="#" >${_("Libraries")}</a>
|
||||
% endif
|
||||
</li>
|
||||
% endif
|
||||
% endif
|
||||
% if taxonomies_enabled:
|
||||
<li><a href="${taxonomy_list_mfe_url}">${_("Taxonomies")}</li>
|
||||
|
||||
@@ -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(
|
||||
'',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -156,7 +156,7 @@
|
||||
<!-- The default stylesheet will set the body min-height to 100% (a common strategy to allow for background
|
||||
images to fill the viewport), but this has the undesireable side-effect of causing an infinite loop via the onResize
|
||||
event listeners below, in certain situations. Resetting it to the default "auto" skirts the problem.-->
|
||||
<body style="min-height: auto">
|
||||
<body style="min-height: auto; background-color: white;">
|
||||
<!-- fragment body -->
|
||||
{{ fragment.body_html | safe }}
|
||||
<!-- fragment foot -->
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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"]):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -104,14 +104,14 @@ class TestCleanThreadHtmlBody(unittest.TestCase):
|
||||
<p>This is a <a href="#">link</a> to a page.</p>
|
||||
<p>Here is an image: <img src="image.jpg" alt="image"></p>
|
||||
<p>Embedded video: <iframe src="video.mp4"></iframe></p>
|
||||
<p>Script test: <script>alert('hello');</script></p>
|
||||
<p>Script test: <script>alert("hello");</script></p>
|
||||
<p>Some other content that should remain.</p>
|
||||
"""
|
||||
expected_output = ("<p>This is a link to a page.</p>"
|
||||
"<p>Here is an image: </p>"
|
||||
"<p>Embedded video: </p>"
|
||||
"<p>Script test: alert('hello');</p>"
|
||||
"<p>Some other content that should remain.</p>")
|
||||
expected_output = ('<p style="margin: 0">This is a link to a page.</p>'
|
||||
'<p style="margin: 0">Here is an image: </p>'
|
||||
'<p style="margin: 0">Embedded video: </p>'
|
||||
'<p style="margin: 0">Script test: alert("hello");</p>'
|
||||
'<p style="margin: 0">Some other content that should remain.</p>')
|
||||
|
||||
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 = """
|
||||
<p>This is a long text that should be truncated to 500 characters.</p>
|
||||
""" * 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"<p>{html_body}</p>")
|
||||
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 = "<p>This paragraph has no tags to remove.</p>"
|
||||
expected_output = "<p>This paragraph has no tags to remove.</p>"
|
||||
expected_output = '<p style="margin: 0">This paragraph has no tags to remove.</p>'
|
||||
|
||||
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, '<p style="margin: 0">Text</p>')
|
||||
|
||||
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 class="abc">Button</button>'
|
||||
expected_output = '<span class="abc">Button</span>'
|
||||
expected_output = '<span style="margin: 0">Button</span>'
|
||||
result = clean_thread_html_body(html_body)
|
||||
self.assertEqual(result, expected_output)
|
||||
|
||||
# Tests button tag replacement without text
|
||||
html_body = '<p><p>abc</p><button class="abc"></button><p>abc</p></p>'
|
||||
expected_output = '<p style="margin: 0"><p style="margin: 0">abc</p>'\
|
||||
'<span style="margin: 0"></span><p style="margin: 0">abc</p></p>'
|
||||
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 = '<button class="abc"></button>'
|
||||
expected_output = '<span class="abc"></span>'
|
||||
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 = '<p class="abc" style="color:red" aria-disabled=true>Paragraph</p>'
|
||||
result = clean_thread_html_body(html_body)
|
||||
self.assertEqual(result, '<p style="margin: 0">Paragraph</p>')
|
||||
|
||||
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 = '<h4>Heading</h4>'
|
||||
result = clean_thread_html_body(html_body)
|
||||
self.assertEqual(result, expected_output)
|
||||
html_body = '<div><p></p><p>content</p><p></p></div>'
|
||||
result = clean_thread_html_body(html_body)
|
||||
self.assertEqual(result, '<p style="margin: 0"><p style="margin: 0">content</p></p>')
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"});
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</%block>
|
||||
@@ -112,7 +112,7 @@ from openedx.core.djangolib.js_utils import js_escaped_string
|
||||
</span>
|
||||
<p class="price-display upgrade-price-string">${currency_symbol}${min_price} ${currency}</p>
|
||||
<div class="choice-title"><h4>${_("Earn a certificate")}</h4></div>
|
||||
<%block name="track_selection_certificate_bullets"/>
|
||||
<%block name="track_selection_certificate_bullets"/>
|
||||
<ul class="list-actions">
|
||||
<li class="action action-select track-selection-button">
|
||||
<input type="hidden" name="contribution" value="${price_before_discount or min_price}" />
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
../../../cms/templates/content_libraries/xblock_iframe.html
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) -> <LibraryRootV1>
|
||||
* get_v1_or_v2_library("library-v1:ProblemX+PR0B", "65ff...") -> <LibraryRootV1>
|
||||
* get_v1_or_v2_library("lib:RG:rg-1", None) -> <ContentLibraryMetadata>
|
||||
* get_v1_or_v2_library("lib:RG:rg-1", "36") -> <ContentLibraryMetadata>
|
||||
* get_v1_or_v2_library("lib:RG:rg-1", "xyz") -> <ValueError>
|
||||
* get_v1_or_v2_library("notakey", "xyz") -> <InvalidKeyError>
|
||||
|
||||
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
|
||||
# ======================
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'<h1>{self.display_name}</h1>\n')
|
||||
fragment.add_content(f'<p>SF: {self.setting_field}</p>\n')
|
||||
fragment.add_content(f'<p>CF: {self.content_field}</p>\n')
|
||||
handler_url = self.runtime.handler_url(self, 'get_fields')
|
||||
fragment.add_content(f'<p>handler URL: {handler_url}</p>\n')
|
||||
return fragment
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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, "<problem/>", 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": "<problem />", "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.",
|
||||
})
|
||||
|
||||
@@ -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, """
|
||||
<fields-test
|
||||
display_name="Field Test Block (Old, v2)"
|
||||
setting_field="Old setting value 2."
|
||||
content_field="Old content value 2."
|
||||
/>
|
||||
""")
|
||||
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, """
|
||||
<fields-test
|
||||
display_name="Field Test Block (Published, v3)"
|
||||
setting_field="Published setting value 3."
|
||||
content_field="Published content value 3."
|
||||
/>
|
||||
""")
|
||||
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, """
|
||||
<fields-test
|
||||
display_name="Field Test Block (Draft, v4)"
|
||||
setting_field="Draft setting value 4."
|
||||
content_field="Draft content value 4."
|
||||
/>
|
||||
""")
|
||||
|
||||
# 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'<h1>{display_name}</h1>' in html
|
||||
assert f'<p>SF: {setting_value}</p>' in html
|
||||
assert f'<p>CF: {content_value}</p>' in html
|
||||
handler_url = re.search(r'<p>handler URL: ([^<]+)</p>', 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'<p>handler URL: ([^<]+)</p>', 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'<h1>{display_name}</h1>' in html
|
||||
assert f'<p>SF: {setting_field}</p>' in html
|
||||
assert f'<p>CF: {content_field}</p>' 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.
|
||||
@@ -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 = """<svg xmlns="http://www.w3.org/2000/svg" height="30" width="100">
|
||||
@@ -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
|
||||
|
||||
@@ -57,6 +57,8 @@ urlpatterns = [
|
||||
path('blocks/<str:usage_key_str>/', 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/<uuid:component_version_uuid>/<path:asset_path>',
|
||||
views.component_version_asset,
|
||||
name='library-assets',
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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"<Digest-One-Click-Unsubscribe> - user.id is null - {encrypted_username} ")
|
||||
notification_preference_unsubscribe_event(user)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -32,6 +32,6 @@ urlpatterns = [
|
||||
path('xblocks/v2/<str:usage_key_str>/', 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<view_name>[\w\-]+)/$', views.embed_block_view),
|
||||
path('embed/<str:view_name>/', views.embed_block_view),
|
||||
])),
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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)
|
||||
|
||||
9
pylintrc
9
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
|
||||
|
||||
@@ -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+ =
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
2
setup.py
2
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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user